203 lines
6 KiB
Go
203 lines
6 KiB
Go
package evolution_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/evolution"
|
|
"github.com/sipeed/picoclaw/pkg/providers"
|
|
"github.com/sipeed/picoclaw/pkg/skills"
|
|
)
|
|
|
|
type recordingDraftGenerator struct {
|
|
draft evolution.SkillDraft
|
|
err error
|
|
calls int
|
|
}
|
|
|
|
func (g *recordingDraftGenerator) GenerateDraft(
|
|
_ context.Context,
|
|
_ evolution.LearningRecord,
|
|
_ []skills.SkillInfo,
|
|
) (evolution.SkillDraft, error) {
|
|
g.calls++
|
|
return g.draft, g.err
|
|
}
|
|
|
|
type llmDraftTestProvider struct {
|
|
response *providers.LLMResponse
|
|
err error
|
|
defaultModel string
|
|
lastModel string
|
|
lastMessages []providers.Message
|
|
chatCallCount int
|
|
}
|
|
|
|
func (p *llmDraftTestProvider) Chat(
|
|
_ context.Context,
|
|
messages []providers.Message,
|
|
_ []providers.ToolDefinition,
|
|
model string,
|
|
_ map[string]any,
|
|
) (*providers.LLMResponse, error) {
|
|
p.chatCallCount++
|
|
p.lastModel = model
|
|
p.lastMessages = append([]providers.Message(nil), messages...)
|
|
return p.response, p.err
|
|
}
|
|
|
|
func (p *llmDraftTestProvider) GetDefaultModel() string {
|
|
return p.defaultModel
|
|
}
|
|
|
|
func testLearningRule() evolution.LearningRecord {
|
|
return evolution.LearningRecord{
|
|
ID: "rule-1",
|
|
Summary: "weather native-name path",
|
|
EventCount: 7,
|
|
SuccessRate: 0.86,
|
|
WinningPath: []string{"weather", "native-name"},
|
|
MatchedSkillNames: []string{"weather"},
|
|
}
|
|
}
|
|
|
|
func testSkillMatches() []skills.SkillInfo {
|
|
return []skills.SkillInfo{
|
|
{
|
|
Name: "weather",
|
|
Path: "/tmp/weather/SKILL.md",
|
|
Source: "workspace",
|
|
Description: "Find weather details.",
|
|
},
|
|
}
|
|
}
|
|
|
|
func TestLLMDraftGenerator_GenerateDraft_ParsesJSONResponse(t *testing.T) {
|
|
provider := &llmDraftTestProvider{
|
|
defaultModel: "test-model",
|
|
response: &providers.LLMResponse{
|
|
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`,
|
|
},
|
|
}
|
|
fallback := &recordingDraftGenerator{
|
|
draft: evolution.SkillDraft{TargetSkillName: "fallback"},
|
|
}
|
|
generator := evolution.NewLLMDraftGenerator(provider, "", fallback)
|
|
|
|
draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
|
if err != nil {
|
|
t.Fatalf("GenerateDraft: %v", err)
|
|
}
|
|
|
|
if provider.chatCallCount != 1 {
|
|
t.Fatalf("chatCallCount = %d, want 1", provider.chatCallCount)
|
|
}
|
|
if provider.lastModel != "test-model" {
|
|
t.Fatalf("lastModel = %q, want test-model", provider.lastModel)
|
|
}
|
|
if len(provider.lastMessages) == 0 {
|
|
t.Fatal("expected prompt messages")
|
|
}
|
|
if fallback.calls != 0 {
|
|
t.Fatalf("fallback.calls = %d, want 0", fallback.calls)
|
|
}
|
|
if draft.TargetSkillName != "weather" {
|
|
t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName)
|
|
}
|
|
if draft.DraftType != evolution.DraftTypeShortcut {
|
|
t.Fatalf("DraftType = %q, want %q", draft.DraftType, evolution.DraftTypeShortcut)
|
|
}
|
|
if draft.ChangeKind != evolution.ChangeKindAppend {
|
|
t.Fatalf("ChangeKind = %q, want %q", draft.ChangeKind, evolution.ChangeKindAppend)
|
|
}
|
|
if draft.HumanSummary == "" || draft.BodyOrPatch == "" {
|
|
t.Fatal("expected non-empty draft content")
|
|
}
|
|
}
|
|
|
|
func TestLLMDraftGenerator_GenerateDraft_PrefersExplicitModelIDOverProviderDefault(t *testing.T) {
|
|
provider := &llmDraftTestProvider{
|
|
defaultModel: "provider-default-model",
|
|
response: &providers.LLMResponse{
|
|
Content: `{"target_skill_name":"weather","draft_type":"shortcut","change_kind":"append","human_summary":"Prefer native-name lookup first","body_or_patch":"## Start Here\nUse native-name first."}`,
|
|
},
|
|
}
|
|
generator := evolution.NewLLMDraftGenerator(provider, "explicit-model-id", &recordingDraftGenerator{})
|
|
|
|
_, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
|
if err != nil {
|
|
t.Fatalf("GenerateDraft: %v", err)
|
|
}
|
|
if provider.lastModel != "explicit-model-id" {
|
|
t.Fatalf("lastModel = %q, want explicit-model-id", provider.lastModel)
|
|
}
|
|
}
|
|
|
|
func TestLLMDraftGenerator_GenerateDraft_FallsBackOnProviderError(t *testing.T) {
|
|
fallback := &recordingDraftGenerator{
|
|
draft: evolution.SkillDraft{
|
|
TargetSkillName: "weather-fallback",
|
|
DraftType: evolution.DraftTypeWorkflow,
|
|
ChangeKind: evolution.ChangeKindCreate,
|
|
HumanSummary: "fallback summary",
|
|
BodyOrPatch: "fallback body",
|
|
},
|
|
}
|
|
generator := evolution.NewLLMDraftGenerator(&llmDraftTestProvider{
|
|
defaultModel: "test-model",
|
|
err: errors.New("provider unavailable"),
|
|
}, "", fallback)
|
|
|
|
draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
|
if err != nil {
|
|
t.Fatalf("GenerateDraft: %v", err)
|
|
}
|
|
|
|
if fallback.calls != 1 {
|
|
t.Fatalf("fallback.calls = %d, want 1", fallback.calls)
|
|
}
|
|
if draft.TargetSkillName != "weather-fallback" {
|
|
t.Fatalf("TargetSkillName = %q, want weather-fallback", draft.TargetSkillName)
|
|
}
|
|
}
|
|
|
|
func TestLLMDraftGenerator_GenerateDraft_FallsBackOnInvalidOrEmptyContent(t *testing.T) {
|
|
testCases := []struct {
|
|
name string
|
|
content string
|
|
}{
|
|
{name: "invalid json", content: `not-json`},
|
|
{name: "empty content", content: ``},
|
|
}
|
|
|
|
for _, tt := range testCases {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
fallback := &recordingDraftGenerator{
|
|
draft: evolution.SkillDraft{
|
|
TargetSkillName: "weather-fallback",
|
|
DraftType: evolution.DraftTypeWorkflow,
|
|
ChangeKind: evolution.ChangeKindCreate,
|
|
HumanSummary: "fallback summary",
|
|
BodyOrPatch: "fallback body",
|
|
},
|
|
}
|
|
generator := evolution.NewLLMDraftGenerator(&llmDraftTestProvider{
|
|
defaultModel: "test-model",
|
|
response: &providers.LLMResponse{Content: tt.content},
|
|
}, "", fallback)
|
|
|
|
draft, err := generator.GenerateDraft(context.Background(), testLearningRule(), testSkillMatches())
|
|
if err != nil {
|
|
t.Fatalf("GenerateDraft: %v", err)
|
|
}
|
|
|
|
if fallback.calls != 1 {
|
|
t.Fatalf("fallback.calls = %d, want 1", fallback.calls)
|
|
}
|
|
if draft.TargetSkillName != "weather-fallback" {
|
|
t.Fatalf("TargetSkillName = %q, want weather-fallback", draft.TargetSkillName)
|
|
}
|
|
})
|
|
}
|
|
}
|