package evolution_test import ( "context" "os" "path/filepath" "strings" "testing" "time" "github.com/sipeed/picoclaw/pkg/evolution" ) func TestApplier_CreateDraftWritesSkillFile(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-1", WorkspaceID: workspace, SourceRecordID: "rule-1", TargetSkillName: "weather", DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "weather helper", BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\n## Start Here\nUse native-name query first.\n", Status: evolution.DraftStatusAccepted, } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md") data, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } if !strings.Contains(string(data), "# Weather") { t.Fatalf("unexpected content: %s", string(data)) } } func TestApplier_CreateDraftRendersDeployableSkillWithoutLearningTrace(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-1", WorkspaceID: workspace, SourceRecordID: "rule-1", TargetSkillName: "weather", DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "weather helper", BodyOrPatch: strings.Join([]string{ "---", "name: weather", "description: Create combined shortcut perform-mathematical-calculations-by-via-theorems for: Perform mathematical calculations by applying specific theorems and their associated rules.", "---", "# Weather", "", "## Learned Context", "- Learned task: use native-name weather lookup.", "", "## Source Evidence", "- Evidence: learned from task records: task-1", "", "## Procedure", "Use native-name query first.", "", }, "\n"), Status: evolution.DraftStatusAccepted, } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } data, err := os.ReadFile(filepath.Join(workspace, "skills", "weather", "SKILL.md")) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(data) for _, forbidden := range []string{ "Create combined shortcut", "perform-mathematical-calculations-by-via-theorems for:", "Learned Context", "Learned task", "Source Evidence", "task records", } { if strings.Contains(content, forbidden) { t.Fatalf("deployed skill contains %q:\n%s", forbidden, content) } } if !strings.Contains(content, "Use native-name query first.") { t.Fatalf("deployed skill lost procedure:\n%s", content) } if !strings.Contains( content, "description: Perform mathematical calculations by applying specific theorems and their associated rules.", ) { t.Fatalf("deployed skill did not clean description:\n%s", content) } } func TestApplier_CreateDraftDoesNotRewriteEvolutionDomainTextOrFrontmatter(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-evolution-domain", WorkspaceID: workspace, SourceRecordID: "rule-evolution-domain", TargetSkillName: "agent-evolution-helper", DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "agent evolution helper", BodyOrPatch: "---\nname: agent-evolution-helper\ndescription: Explain agent evolution workflows.\n---\n# Agent Evolution Helper\nUse this skill to reason about agent evolution behavior.\n", Status: evolution.DraftStatusAccepted, } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } data, err := os.ReadFile(filepath.Join(workspace, "skills", "agent-evolution-helper", "SKILL.md")) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(data) if !strings.Contains(content, "name: agent-evolution-helper") { t.Fatalf("frontmatter name was rewritten:\n%s", content) } if strings.Contains(content, "agent-update-helper") { t.Fatalf("frontmatter name should not be rewritten:\n%s", content) } if !strings.Contains(content, "agent evolution behavior") { t.Fatalf("domain text should preserve evolution wording:\n%s", content) } } func TestApplier_CreateDraftFailsWhenSkillAlreadyExists(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-create-existing", WorkspaceID: workspace, SourceRecordID: "rule-create-existing", TargetSkillName: "weather", DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "weather helper", BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n", } err := applier.ApplyDraft(context.Background(), workspace, draft) if err == nil { t.Fatal("expected ApplyDraft to fail") } if !strings.Contains(err.Error(), "already exists") { t.Fatalf("error = %v, want already exists", err) } got, readErr := os.ReadFile(skillPath) if readErr != nil { t.Fatalf("ReadFile: %v", readErr) } if string(got) != original { t.Fatalf("skill content changed unexpectedly:\n%s", string(got)) } } func TestApplier_CreateDraftRejectsMismatchedFrontmatterName(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-mismatched-name", WorkspaceID: workspace, SourceRecordID: "rule-mismatched-name", TargetSkillName: "weather", DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "weather helper", BodyOrPatch: "---\nname: other-skill\ndescription: other helper\n---\n# Other\nUse something else.\n", } err := applier.ApplyDraft(context.Background(), workspace, draft) if err == nil { t.Fatal("expected ApplyDraft to fail") } if !strings.Contains(err.Error(), "frontmatter name") { t.Fatalf("error = %v, want frontmatter name mismatch", err) } if _, statErr := os.Stat(filepath.Join(workspace, "skills", "weather", "SKILL.md")); !os.IsNotExist(statErr) { t.Fatalf("expected no skill file, got err=%v", statErr) } } func TestApplier_RollsBackOnInvalidSkillBody(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\nold body\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-2", WorkspaceID: workspace, SourceRecordID: "rule-2", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindReplace, HumanSummary: "broken draft", BodyOrPatch: "invalid-frontmatter", Status: evolution.DraftStatusAccepted, } err := applier.ApplyDraft(context.Background(), workspace, draft) if err == nil { t.Fatal("expected ApplyDraft to fail") } got, readErr := os.ReadFile(skillPath) if readErr != nil { t.Fatalf("ReadFile: %v", readErr) } if string(got) != original { t.Fatalf("skill content changed after rollback:\n%s", string(got)) } } func TestApplier_FailedNewSkillDoesNotLeaveEmptyDirectory(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-invalid-new-skill", WorkspaceID: workspace, SourceRecordID: "rule-invalid-new-skill", TargetSkillName: "calculate-100-via-theorems", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "broken new skill", BodyOrPatch: "invalid-frontmatter", Status: evolution.DraftStatusAccepted, } err := applier.ApplyDraft(context.Background(), workspace, draft) if err == nil { t.Fatal("expected ApplyDraft to fail") } skillPath := filepath.Join(workspace, "skills", "calculate-100-via-theorems", "SKILL.md") if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) { t.Fatalf("expected no skill file, got err=%v", statErr) } skillDir := filepath.Dir(skillPath) if _, statErr := os.Stat(skillDir); !os.IsNotExist(statErr) { t.Fatalf("expected no leftover skill dir, got err=%v", statErr) } } func TestApplier_ReplaceDraftFailsWhenSkillDoesNotExist(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-replace-missing", WorkspaceID: workspace, SourceRecordID: "rule-replace-missing", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindReplace, HumanSummary: "replace missing skill", BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nnew body\n", } err := applier.ApplyDraft(context.Background(), workspace, draft) if err == nil { t.Fatal("expected ApplyDraft to fail") } if !strings.Contains(err.Error(), "does not exist") { t.Fatalf("error = %v, want does not exist", err) } skillPath := filepath.Join(workspace, "skills", "weather", "SKILL.md") if _, statErr := os.Stat(skillPath); !os.IsNotExist(statErr) { t.Fatalf("expected no skill file, got err=%v", statErr) } } func TestApplier_AppendDraftPreservesOriginalBody(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-append", WorkspaceID: workspace, SourceRecordID: "rule-append", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindAppend, HumanSummary: "append draft", BodyOrPatch: "\n## Learned Pattern\nPrefer native-name query first.\n", } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } got, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(got) if !strings.Contains(content, "Use city names.") { t.Fatalf("appended content lost original body:\n%s", content) } if !strings.Contains(content, "Prefer native-name query first.") { t.Fatalf("appended content missing new body:\n%s", content) } } func TestApplier_AppendDraftAllowsExistingExtraFrontmatterFields(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := strings.Join([]string{ "---", "name: weather", "description: valid", "# Human-authored metadata should not block append updates.", "homepage: https://example.com/weather", "aliases:", "- forecast", "metadata:", " owner: human", "---", "# Weather", "## Start Here", "Use city names.", "", }, "\n") skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-append-extra-frontmatter", WorkspaceID: workspace, SourceRecordID: "rule-append-extra-frontmatter", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindAppend, HumanSummary: "append draft", BodyOrPatch: "\n## Learned Pattern\nPrefer native-name query first.\n", } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } got, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(got) for _, want := range []string{ "homepage: https://example.com/weather", "aliases:", "- forecast", "metadata:", " owner: human", "Prefer native-name query first.", } { if !strings.Contains(content, want) { t.Fatalf("appended content missing %q:\n%s", want, content) } } } func TestApplier_CreateDraftRejectsExtraFrontmatterFields(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-create-extra-frontmatter", WorkspaceID: workspace, SourceRecordID: "rule-create-extra-frontmatter", TargetSkillName: "weather", DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "weather helper", BodyOrPatch: "---\nname: weather\ndescription: weather helper\nhomepage: https://example.com/weather\n---\n# Weather\nUse weather.\n", } err := applier.ApplyDraft(context.Background(), workspace, draft) if err == nil { t.Fatal("expected ApplyDraft to fail") } if !strings.Contains(err.Error(), "unsupported skill frontmatter field") { t.Fatalf("error = %v, want unsupported field", err) } } func TestApplier_AppendDraftDoesNotRewriteExistingLearningTerms(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Evolution Notes\nKeep this manually-authored Learned phrase unchanged.\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-append-clean", WorkspaceID: workspace, SourceRecordID: "rule-append-clean", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindAppend, HumanSummary: "append draft", BodyOrPatch: "\n## Learned Pattern\nPrefer native-name query first.\n", } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } got, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(got) if !strings.Contains(content, "## Evolution Notes") { t.Fatalf("existing heading was rewritten:\n%s", content) } if !strings.Contains(content, "Keep this manually-authored Learned phrase unchanged.") { t.Fatalf("existing body was rewritten:\n%s", content) } if strings.Contains(content, "## Learned Pattern") { t.Fatalf("new patch should be deploy-sanitized:\n%s", content) } if !strings.Contains(content, "## Usage Pattern") { t.Fatalf("new patch missing sanitized heading:\n%s", content) } } func TestApplier_AppendDraftStripsPlainMarkdownTopLevelHeading(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-append-plain-doc", WorkspaceID: workspace, SourceRecordID: "rule-append-plain-doc", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindAppend, HumanSummary: "append draft", BodyOrPatch: "# Weather\n## Procedure\nPrefer native-name query first.\n", } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } got, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(got) if strings.Count(content, "# Weather") != 1 { t.Fatalf("appended content should not duplicate top-level heading:\n%s", content) } if !strings.Contains(content, "## Procedure") || !strings.Contains(content, "Prefer native-name query first.") { t.Fatalf("appended content lost patch body:\n%s", content) } } func TestApplier_AppendAndMergeRejectFullDocumentPatchWithMismatchedName(t *testing.T) { for _, kind := range []evolution.ChangeKind{evolution.ChangeKindAppend, evolution.ChangeKindMerge} { t.Run(string(kind), func(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{ ID: "draft-mismatched-patch", WorkspaceID: workspace, SourceRecordID: "rule-mismatched-patch", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: kind, HumanSummary: "append draft", BodyOrPatch: "---\nname: other-skill\ndescription: wrong target\n---\n# Other Skill\n## Procedure\nDo something else.\n", }) if err == nil { t.Fatal("expected ApplyDraft to fail") } if !strings.Contains(err.Error(), "patch frontmatter name") { t.Fatalf("error = %v, want patch frontmatter name mismatch", err) } got, readErr := os.ReadFile(skillPath) if readErr != nil { t.Fatalf("ReadFile: %v", readErr) } if string(got) != original { t.Fatalf("skill content changed unexpectedly:\n%s", string(got)) } }) } } func TestApplier_AppendDraftStripsFullSkillDocumentPatch(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-append-full-doc", WorkspaceID: workspace, SourceRecordID: "rule-append-full-doc", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindAppend, HumanSummary: "append draft", BodyOrPatch: "---\nname: weather\ndescription: duplicate document\n---\n# Weather\n## Procedure\nPrefer native-name query first.\n", } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } got, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(got) if strings.Count(content, "---") != 2 { t.Fatalf("appended content should keep only original frontmatter:\n%s", content) } if strings.Count(content, "# Weather") != 1 { t.Fatalf("appended content should not duplicate top-level heading:\n%s", content) } if !strings.Contains(content, "## Procedure") || !strings.Contains(content, "Prefer native-name query first.") { t.Fatalf("appended content lost patch body:\n%s", content) } } func TestApplier_BackupsAreScopedByWorkspace(t *testing.T) { sharedState := t.TempDir() workspaceA := t.TempDir() workspaceB := t.TempDir() now := time.Unix(1700000000, 0).UTC() for workspace, body := range map[string]string{ workspaceA: "---\nname: weather\ndescription: valid\n---\n# Weather\nworkspace A\n", workspaceB: "---\nname: weather\ndescription: valid\n---\n# Weather\nworkspace B\n", } { skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll(%s): %v", workspace, err) } if err := os.WriteFile(filepath.Join(skillDir, "SKILL.md"), []byte(body), 0o644); err != nil { t.Fatalf("WriteFile(%s): %v", workspace, err) } } for _, workspace := range []string{workspaceA, workspaceB} { applier := evolution.NewApplier(evolution.NewPaths(workspace, sharedState), func() time.Time { return now }) if err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{ ID: "draft-replace", WorkspaceID: workspace, SourceRecordID: "rule-replace", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindReplace, HumanSummary: "replace weather", BodyOrPatch: "---\nname: weather\ndescription: replacement\n---\n# Weather\nreplacement\n", }); err != nil { t.Fatalf("ApplyDraft(%s): %v", workspace, err) } } var backupBodies []string if err := filepath.WalkDir( filepath.Join(sharedState, "backups"), func(path string, entry os.DirEntry, err error) error { if err != nil { return err } if entry.IsDir() || entry.Name() != "SKILL.md" { return nil } data, err := os.ReadFile(path) if err != nil { return err } backupBodies = append(backupBodies, string(data)) return nil }, ); err != nil { t.Fatalf("WalkDir(backups): %v", err) } if len(backupBodies) != 2 { t.Fatalf("backup count = %d, want 2", len(backupBodies)) } joined := strings.Join(backupBodies, "\n") if !strings.Contains(joined, "workspace A") || !strings.Contains(joined, "workspace B") { t.Fatalf("backups should preserve both workspace bodies:\n%s", joined) } } func TestApplier_MergeDraftAddsMergedKnowledgeSection(t *testing.T) { workspace := t.TempDir() skillDir := filepath.Join(workspace, "skills", "weather") if err := os.MkdirAll(skillDir, 0o755); err != nil { t.Fatalf("MkdirAll: %v", err) } original := "---\nname: weather\ndescription: valid\n---\n# Weather\n## Start Here\nUse city names.\n" skillPath := filepath.Join(skillDir, "SKILL.md") if err := os.WriteFile(skillPath, []byte(original), 0o644); err != nil { t.Fatalf("WriteFile: %v", err) } applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) draft := evolution.SkillDraft{ ID: "draft-merge", WorkspaceID: workspace, SourceRecordID: "rule-merge", TargetSkillName: "weather", DraftType: evolution.DraftTypeWorkflow, ChangeKind: evolution.ChangeKindMerge, HumanSummary: "merge draft", BodyOrPatch: "Prefer native-name query first.", } if err := applier.ApplyDraft(context.Background(), workspace, draft); err != nil { t.Fatalf("ApplyDraft: %v", err) } got, err := os.ReadFile(skillPath) if err != nil { t.Fatalf("ReadFile: %v", err) } content := string(got) if !strings.Contains(content, "Use city names.") { t.Fatalf("merged content lost original body:\n%s", content) } if !strings.Contains(content, "## Merged Knowledge") { t.Fatalf("merged content missing merged section:\n%s", content) } if !strings.Contains(content, "Prefer native-name query first.") { t.Fatalf("merged content missing new knowledge:\n%s", content) } } func TestApplier_RejectsInvalidSkillName(t *testing.T) { workspace := t.TempDir() applier := evolution.NewApplier(evolution.NewPaths(workspace, ""), func() time.Time { return time.Unix(1700000000, 0).UTC() }) for _, name := range []string{"../escape", "/tmp/escape"} { err := applier.ApplyDraft(context.Background(), workspace, evolution.SkillDraft{ ID: "draft-invalid-name", WorkspaceID: workspace, SourceRecordID: "rule-invalid-name", TargetSkillName: name, DraftType: evolution.DraftTypeShortcut, ChangeKind: evolution.ChangeKindCreate, HumanSummary: "bad name", BodyOrPatch: "---\nname: weather\ndescription: weather helper\n---\n# Weather\nbody\n", }) if err == nil { t.Fatalf("TargetSkillName %q expected error", name) } } }