package evolution_test import ( "context" "os" "path/filepath" "strings" "testing" "github.com/sipeed/picoclaw/pkg/evolution" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/skills" ) func TestDefaultDraftGenerator_PrefersLateAddedSkillAsTargetWhenNoMatches(t *testing.T) { generator := evolution.NewDefaultDraftGenerator(t.TempDir()) draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ Summary: "weather lookup", WinningPath: []string{"weather"}, LateAddedSkills: []string{"weather"}, FinalSnapshotTrigger: "context_retry_rebuild", EventCount: 4, SuccessRate: 1, }, nil) if err != nil { t.Fatalf("GenerateDraft: %v", err) } if draft.TargetSkillName != "weather" { t.Fatalf("TargetSkillName = %q, want weather", draft.TargetSkillName) } if !strings.Contains(draft.BodyOrPatch, "Late-added skill") { t.Fatalf("BodyOrPatch = %q, want late-added skill guidance", draft.BodyOrPatch) } } func TestDefaultDraftGenerator_PrefersCombinedSkillForStableMultiSkillPath(t *testing.T) { workspace := t.TempDir() generator := evolution.NewDefaultDraftGenerator(workspace) draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ Summary: "调用三一定理计算100", WinningPath: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"}, LateAddedSkills: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"}, EventCount: 3, SuccessRate: 1, }, []skills.SkillInfo{ { Name: "three-one-theorem", Path: filepath.Join(workspace, "skills", "three-one-theorem", "SKILL.md"), Source: "workspace", }, { Name: "four-two-theorem", Path: filepath.Join(workspace, "skills", "four-two-theorem", "SKILL.md"), Source: "workspace", }, { Name: "five-three-theorem", Path: filepath.Join(workspace, "skills", "five-three-theorem", "SKILL.md"), Source: "workspace", }, }) if err != nil { t.Fatalf("GenerateDraft: %v", err) } if draft.TargetSkillName != "calculate-100-via-theorems" { t.Fatalf("TargetSkillName = %q, want calculate-100-via-theorems", draft.TargetSkillName) } if draft.ChangeKind != evolution.ChangeKindCreate { t.Fatalf("ChangeKind = %q, want create", draft.ChangeKind) } if !strings.Contains(draft.BodyOrPatch, "---\nname: calculate-100-via-theorems") { t.Fatalf("BodyOrPatch should contain full skill document:\n%s", draft.BodyOrPatch) } } func TestDefaultDraftGenerator_CombinedSkillIncludesEvidenceAndSourceOperations(t *testing.T) { workspace := t.TempDir() generator := evolution.NewDefaultDraftGenerator(workspace) sourceSkills := []struct { name string body string }{ {name: "three-one-theorem", body: "Add 31 to the input value."}, {name: "four-two-theorem", body: "Add 42 to the current value."}, {name: "five-three-theorem", body: "Subtract 53 from the current value."}, } matches := make([]skills.SkillInfo, 0, len(sourceSkills)) for _, source := range sourceSkills { skillPath := filepath.Join(workspace, "skills", source.name, "SKILL.md") if err := os.MkdirAll(filepath.Dir(skillPath), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } content := "---\nname: " + source.name + "\ndescription: theorem helper\n---\n# " + source.name + "\n" + source.body + "\n" if err := os.WriteFile(skillPath, []byte(content), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } matches = append( matches, skills.SkillInfo{Name: source.name, Path: skillPath, Source: "workspace", Description: "theorem helper"}, ) } draft, err := generator.GenerateDraftWithEvidence(context.Background(), evolution.LearningRecord{ ID: "pattern-1", Summary: "调用三一定理计算100", TaskRecordIDs: []string{"task-1"}, }, matches, evolution.DraftEvidence{ TaskRecords: []evolution.LearningRecord{ { ID: "task-1", Summary: "调用三一定理计算100", FinalOutput: "100 + 31 = 131; 131 + 42 = 173; 173 - 53 = 120", UsedSkillNames: []string{"three-one-theorem", "four-two-theorem", "five-three-theorem"}, }, }, }) if err != nil { t.Fatalf("GenerateDraftWithEvidence: %v", err) } for _, want := range []string{ "calculate-100-via-theorems", "Add 31 to the input value", "Add 42 to the current value", "Subtract 53 from the current value", "100 + 31 = 131", "task-1", } { if !strings.Contains(draft.BodyOrPatch, want) && draft.TargetSkillName != want { t.Fatalf("draft missing %q:\nname=%s\n%s", want, draft.TargetSkillName, draft.BodyOrPatch) } } } func TestDefaultDraftGenerator_DoesNotInferNumericOnlyTargetFromSummary(t *testing.T) { generator := evolution.NewDefaultDraftGenerator(t.TempDir()) draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ Summary: "100", EventCount: 1, SuccessRate: 1, }, nil) if err != nil { t.Fatalf("GenerateDraft: %v", err) } if draft.TargetSkillName != "learned-100" { t.Fatalf("TargetSkillName = %q, want learned-100", draft.TargetSkillName) } } func TestDefaultDraftGenerator_UsesAppendWhenExtendingExistingSkill(t *testing.T) { workspace := t.TempDir() generator := evolution.NewDefaultDraftGenerator(workspace) existingPath := filepath.Join(workspace, "skills", "weather", "SKILL.md") if err := os.MkdirAll(filepath.Dir(existingPath), 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } existing := "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse city names.\n" if err := os.WriteFile(existingPath, []byte(existing), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } draft, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ Summary: "weather native-name path", WinningPath: []string{"weather"}, EventCount: 4, SuccessRate: 1, }, []skills.SkillInfo{ {Name: "weather", Path: existingPath, Source: "workspace", Description: "Weather helper"}, }) if err != nil { t.Fatalf("GenerateDraft: %v", err) } if draft.ChangeKind != evolution.ChangeKindAppend { t.Fatalf("ChangeKind = %q, want append", draft.ChangeKind) } if strings.Contains(draft.BodyOrPatch, "---\nname: weather") { t.Fatalf("BodyOrPatch should contain only appended section, got full document:\n%s", draft.BodyOrPatch) } if !strings.Contains(draft.BodyOrPatch, "## Learned Evolution") { t.Fatalf("BodyOrPatch = %q, want learned evolution section", draft.BodyOrPatch) } if len(draft.IntendedUseCases) != 1 || draft.IntendedUseCases[0] != "weather native-name path" { t.Fatalf("IntendedUseCases = %v, want [weather native-name path]", draft.IntendedUseCases) } if len(draft.PreferredEntryPath) != 1 || draft.PreferredEntryPath[0] != "weather" { t.Fatalf("PreferredEntryPath = %v, want [weather]", draft.PreferredEntryPath) } } func TestLLMDraftGenerator_BuildPromptIncludesLateAddedSkillHint(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."}`, }, } generator := evolution.NewLLMDraftGenerator(provider, "", &recordingDraftGenerator{}) _, err := generator.GenerateDraft(context.Background(), evolution.LearningRecord{ ID: "rule-1", Summary: "weather native-name path", EventCount: 7, SuccessRate: 0.86, WinningPath: []string{"geocode", "weather"}, MatchedSkillNames: []string{"weather"}, LateAddedSkills: []string{"weather"}, FinalSnapshotTrigger: "context_retry_rebuild", }, []skills.SkillInfo{ {Name: "weather", Path: "/tmp/weather/SKILL.md", Source: "workspace", Description: "Find weather details."}, }) if err != nil { t.Fatalf("GenerateDraft: %v", err) } prompt := provider.lastMessages[1].Content if !strings.Contains(prompt, "Late-added successful skills: weather") { t.Fatalf("prompt missing late-added skill hint:\n%s", prompt) } if !strings.Contains(prompt, "Final snapshot trigger: context_retry_rebuild") { t.Fatalf("prompt missing final snapshot trigger:\n%s", prompt) } if !strings.Contains(prompt, "Prefer creating a new combined shortcut skill") { t.Fatalf("prompt missing combined skill guidance:\n%s", prompt) } if !strings.Contains(prompt, "Suggested target skill name:") { t.Fatalf("prompt missing suggested target skill name:\n%s", prompt) } }