diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 32b39573f1..0000000000 --- a/CLAUDE.md +++ /dev/null @@ -1,3 +0,0 @@ -# CLAUDE.md - -Project rules and instructions live in [AGENTS.md](AGENTS.md). Read that file now — it is the single source of truth for all agent-facing guidance in this repo. diff --git a/internal/cli/admin.go b/internal/cli/admin.go index fcc9af3fc5..eb4b28e2fb 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -24,6 +24,7 @@ import ( "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" "github.com/fullsend-ai/fullsend/internal/forge" gh "github.com/fullsend-ai/fullsend/internal/forge/github" + "github.com/fullsend-ai/fullsend/internal/harness" "github.com/fullsend-ai/fullsend/internal/inference" "github.com/fullsend-ai/fullsend/internal/inference/vertex" "github.com/fullsend-ai/fullsend/internal/layers" @@ -1346,7 +1347,7 @@ func runAppSetup(ctx context.Context, client forge.Client, printer *ui.Printer, // of app-set B. Without this, nonflux-triage (app-set "nonflux") would // prevent fullsend-ai-triage (app-set "fullsend-ai") from being detected // and installed. - knownSlugs := filterSlugsByAppSet(loadKnownSlugs(ctx, client, org), appSet) + knownSlugs := filterSlugsByAppSet(loadKnownSlugs(ctx, client, org, forge.ConfigRepoName, "HEAD", printer), appSet) for role, slug := range filterSlugsByAppSet(sharedSlugs, appSet) { knownSlugs[role] = slug } @@ -2006,8 +2007,45 @@ func filterSlugsByAppSet(slugs map[string]string, appSet string) map[string]stri return out } -// loadKnownSlugs tries to read agent slugs from an existing config. -func loadKnownSlugs(ctx context.Context, client forge.Client, org string) map[string]string { +// loadKnownSlugs discovers agent slugs from harness wrapper files in the +// config repo, falling back to the config.yaml agents: block. +func loadKnownSlugs(ctx context.Context, client forge.Client, org, configRepo, ref string, printer *ui.Printer) map[string]string { + agents, err := harness.DiscoverRemoteAgents(ctx, client, org, configRepo, ref) + if err != nil { + printer.StepWarn(fmt.Sprintf("harness discovery: %v", err)) + } + if len(agents) > 0 { + slugs := make(map[string]string, len(agents)) + seen := make(map[string]bool, len(agents)) + for _, a := range agents { + if a.Role == "" && a.Slug == "" { + continue + } + if a.Role == "" || a.Slug == "" { + printer.StepWarn(fmt.Sprintf("harness %s has role=%q slug=%q; both must be set", a.Filename, a.Role, a.Slug)) + continue + } + if seen[a.Role] { + printer.StepInfo(fmt.Sprintf("duplicate role %q in harness file %s, using first occurrence", a.Role, a.Filename)) + continue + } + seen[a.Role] = true + slugs[a.Role] = a.Slug + } + if len(slugs) > 0 { + return slugs + } + } + + slugs := loadKnownSlugsLegacy(ctx, client, org) + if len(slugs) > 0 { + printer.StepWarn("config.yaml agents: block is deprecated; agent identity should be in harness files with role/slug fields") + } + return slugs +} + +// loadKnownSlugsLegacy reads agent slugs from the config.yaml agents: block. +func loadKnownSlugsLegacy(ctx context.Context, client forge.Client, org string) map[string]string { data, err := client.GetFileContent(ctx, org, forge.ConfigRepoName, "config.yaml") if err != nil { return nil diff --git a/internal/cli/admin_test.go b/internal/cli/admin_test.go index 3363b574f8..16d3e29e37 100644 --- a/internal/cli/admin_test.go +++ b/internal/cli/admin_test.go @@ -2215,6 +2215,194 @@ func TestApplyPerRepoScaffold_ProtectedBranch_DuplicatePR(t *testing.T) { assert.Contains(t, output, "Merge the PR") } +func TestLoadKnownSlugs_HarnessFilesPreferred(t *testing.T) { + client := forge.NewFakeClient() + client.DirContents["myorg/.fullsend/harness@HEAD"] = []forge.DirectoryEntry{ + {Path: "harness/triage.yaml", Type: "file"}, + {Path: "harness/coder.yaml", Type: "file"}, + } + client.FileContentsRef["myorg/.fullsend/harness/triage.yaml@HEAD"] = []byte("role: triage\nslug: fullsend-ai-triage\n") + client.FileContentsRef["myorg/.fullsend/harness/coder.yaml@HEAD"] = []byte("role: coder\nslug: fullsend-ai-coder\n") + + // Also set up config.yaml agents: block — should NOT be used. + client.FileContents["myorg/.fullsend/config.yaml"] = []byte(`version: "1" +agents: + - role: triage + slug: old-triage-slug + name: old-triage +`) + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "triage": "fullsend-ai-triage", + "coder": "fullsend-ai-coder", + }, slugs) + assert.NotContains(t, buf.String(), "agents: block") +} + +func TestLoadKnownSlugs_FallbackToAgentsBlock(t *testing.T) { + client := forge.NewFakeClient() + // No harness/ directory → ErrNotFound from DirContents. + + client.FileContents["myorg/.fullsend/config.yaml"] = []byte(`version: "1" +agents: + - role: triage + slug: fullsend-ai-triage + name: fullsend-ai-triage + - role: coder + slug: fullsend-ai-coder + name: fullsend-ai-coder +`) + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "triage": "fullsend-ai-triage", + "coder": "fullsend-ai-coder", + }, slugs) + assert.Contains(t, buf.String(), "agents: block") +} + +func TestLoadKnownSlugs_HarnessFilesWithoutRoleSlug_FallsBack(t *testing.T) { + client := forge.NewFakeClient() + // Harness files exist but lack role/slug (legacy format). + client.DirContents["myorg/.fullsend/harness@HEAD"] = []forge.DirectoryEntry{ + {Path: "harness/triage.yaml", Type: "file"}, + } + client.FileContentsRef["myorg/.fullsend/harness/triage.yaml@HEAD"] = []byte("agent: agents/triage.md\nmodel: opus\n") + + client.FileContents["myorg/.fullsend/config.yaml"] = []byte(`version: "1" +agents: + - role: triage + slug: fullsend-ai-triage + name: fullsend-ai-triage +`) + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "triage": "fullsend-ai-triage", + }, slugs) + assert.Contains(t, buf.String(), "agents: block") +} + +func TestLoadKnownSlugs_NeitherSource_ReturnsNil(t *testing.T) { + client := forge.NewFakeClient() + // No harness/ dir, no config.yaml. + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Nil(t, slugs) + assert.NotContains(t, buf.String(), "agents: block") +} + +func TestLoadKnownSlugs_DuplicateRoles_FirstWins(t *testing.T) { + client := forge.NewFakeClient() + client.DirContents["myorg/.fullsend/harness@HEAD"] = []forge.DirectoryEntry{ + {Path: "harness/code.yaml", Type: "file"}, + {Path: "harness/fix.yaml", Type: "file"}, + } + // Both files declare role: coder. DiscoverRemoteAgents sorts by Role then + // Filename, so code.yaml comes first. + client.FileContentsRef["myorg/.fullsend/harness/code.yaml@HEAD"] = []byte("role: coder\nslug: fullsend-ai-coder\n") + client.FileContentsRef["myorg/.fullsend/harness/fix.yaml@HEAD"] = []byte("role: coder\nslug: fullsend-ai-fix\n") + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "coder": "fullsend-ai-coder", + }, slugs) + assert.Contains(t, buf.String(), "duplicate role") +} + +func TestLoadKnownSlugs_PartialError_LogsWarning(t *testing.T) { + client := forge.NewFakeClient() + client.DirContents["myorg/.fullsend/harness@HEAD"] = []forge.DirectoryEntry{ + {Path: "harness/triage.yaml", Type: "file"}, + {Path: "harness/bad.yaml", Type: "file"}, + } + client.FileContentsRef["myorg/.fullsend/harness/triage.yaml@HEAD"] = []byte("role: triage\nslug: fullsend-ai-triage\n") + // bad.yaml is not in FileContentsRef → GetFileContentAtRef returns ErrNotFound. + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "triage": "fullsend-ai-triage", + }, slugs) + assert.Contains(t, buf.String(), "harness discovery") +} + +func TestLoadKnownSlugs_RoleWithoutSlug_WarnsAndSkips(t *testing.T) { + client := forge.NewFakeClient() + client.DirContents["myorg/.fullsend/harness@HEAD"] = []forge.DirectoryEntry{ + {Path: "harness/triage.yaml", Type: "file"}, + } + client.FileContentsRef["myorg/.fullsend/harness/triage.yaml@HEAD"] = []byte("role: triage\n") + + client.FileContents["myorg/.fullsend/config.yaml"] = []byte(`version: "1" +agents: + - role: triage + slug: fullsend-ai-triage + name: fullsend-ai-triage +`) + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "triage": "fullsend-ai-triage", + }, slugs) + assert.Contains(t, buf.String(), "both must be set") +} + +func TestLoadKnownSlugs_HardError_ZeroAgents_FallsBack(t *testing.T) { + client := forge.NewFakeClient() + client.Errors["ListDirectoryContents"] = fmt.Errorf("network timeout") + + client.FileContents["myorg/.fullsend/config.yaml"] = []byte(`version: "1" +agents: + - role: triage + slug: fullsend-ai-triage + name: fullsend-ai-triage +`) + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Equal(t, map[string]string{ + "triage": "fullsend-ai-triage", + }, slugs) + assert.Contains(t, buf.String(), "harness discovery") + assert.Contains(t, buf.String(), "deprecated") +} + +func TestLoadKnownSlugs_MalformedConfig_ReturnsNil(t *testing.T) { + client := forge.NewFakeClient() + // No harness/ dir, malformed config.yaml. + client.FileContents["myorg/.fullsend/config.yaml"] = []byte("not: valid: yaml: [") + + var buf bytes.Buffer + printer := ui.New(&buf) + slugs := loadKnownSlugs(context.Background(), client, "myorg", forge.ConfigRepoName, "HEAD", printer) + + assert.Nil(t, slugs) +} + func TestApplyPerRepoScaffold_ProtectedBranch_BranchUpToDate(t *testing.T) { client := forge.NewFakeClient() client.Repos = []forge.Repository{{FullName: "acme/widget", DefaultBranch: "main"}} diff --git a/qf-tests/GH-49/README.md b/qf-tests/GH-49/README.md new file mode 100644 index 0000000000..445dba8b7f --- /dev/null +++ b/qf-tests/GH-49/README.md @@ -0,0 +1,7 @@ +# QualityFlow Tests — GH-49 + +Generated by the QualityFlow pipeline. + +| Directory | Count | Framework | +|-----------|-------|-----------| +| `go/` | 7 files | Go | diff --git a/qf-tests/GH-49/go/agent_slug_dedup_test.go b/qf-tests/GH-49/go/agent_slug_dedup_test.go new file mode 100644 index 0000000000..e329edb798 --- /dev/null +++ b/qf-tests/GH-49/go/agent_slug_dedup_test.go @@ -0,0 +1,104 @@ +package tests + +import ( + "bytes" + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +/* +Agent Slug Discovery — Duplicate Role Handling Tests + +STP Reference: outputs/stp/GH-49/GH-49_test_plan.md +STD Reference: outputs/std/GH-49/GH-49_test_description.yaml +Jira: GH-49 +*/ + +var _ = Describe("[GH-49] Agent Slug Discovery Deduplication", func() { + + Context("Duplicate role handling", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + printerOutput *bytes.Buffer + agents []AgentInfo + err error + ) + + // TS-GH-49-010: Verify duplicate roles keep first occurrence + Context("when harness files contain duplicate roles", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "dup-a.yaml": {Role: "shared-role", Slug: "slug-first"}, + "dup-b.yaml": {Role: "shared-role", Slug: "slug-second"}, + "unique.yaml": {Role: "unique-role", Slug: "unique-slug"}, + }), + ) + }) + + It("[test_id:TS-GH-49-010] should keep first occurrence sorted by Role then Filename", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + + // Count agents with the shared role — should be exactly 1 + sharedRoleCount := 0 + var retainedSlug string + for _, a := range agents { + if a.Role == "shared-role" { + sharedRoleCount++ + retainedSlug = a.Slug + } + } + Expect(sharedRoleCount).To(Equal(1), + "only one agent per duplicate role should be retained") + + // First occurrence by filename sort: dup-a.yaml < dup-b.yaml + Expect(retainedSlug).To(Equal("slug-first"), + "first occurrence by Role+Filename sort order should be retained") + + // Unique role should still be present + hasUnique := false + for _, a := range agents { + if a.Role == "unique-role" { + hasUnique = true + } + } + Expect(hasUnique).To(BeTrue(), "non-duplicate roles should be preserved") + }) + }) + + // TS-GH-49-011: Verify info message logged for duplicate role + Context("when duplicate roles are detected", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "first.yaml": {Role: "dup-role", Slug: "slug-1"}, + "second.yaml": {Role: "dup-role", Slug: "slug-2"}, + }), + ) + }) + + It("[test_id:TS-GH-49-011] should log info message about duplicate", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + _, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + + output := printerOutput.String() + Expect(output).To(SatisfyAny( + ContainSubstring("duplicate"), + ContainSubstring("already"), + ), "info message should be logged when duplicate role is detected") + }) + }) + }) +}) diff --git a/qf-tests/GH-49/go/agent_slug_discovery_test.go b/qf-tests/GH-49/go/agent_slug_discovery_test.go new file mode 100644 index 0000000000..4731b5af18 --- /dev/null +++ b/qf-tests/GH-49/go/agent_slug_discovery_test.go @@ -0,0 +1,156 @@ +package tests + +import ( + "bytes" + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +/* +Agent Slug Discovery — Harness-First Preference Tests + +STP Reference: outputs/stp/GH-49/GH-49_test_plan.md +STD Reference: outputs/std/GH-49/GH-49_test_description.yaml +Jira: GH-49 +*/ + +var _ = Describe("[GH-49] Agent Slug Discovery", func() { + + Context("Harness-first agent discovery preference", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + agents []AgentInfo + err error + ) + + // TS-GH-49-001: Verify harness files with valid role+slug are preferred over config.yaml + Context("when harness files have valid role and slug fields", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "agent-a.yaml": {Role: "agent-role-a", Slug: "agent-slug-a"}, + "agent-b.yaml": {Role: "agent-role-b", Slug: "agent-slug-b"}, + }), + withConfigAgents([]string{"legacy-agent-1", "legacy-agent-2"}), + ) + }) + + It("[test_id:TS-GH-49-001] should prefer harness-discovered agents over config.yaml", func() { + printer := NewPrinter(new(bytes.Buffer)) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(agents).To(HaveLen(2)) + Expect(agents[0].Role).To(Equal("agent-role-a")) + Expect(agents[0].Slug).To(Equal("agent-slug-a")) + Expect(agents[1].Role).To(Equal("agent-role-b")) + Expect(agents[1].Slug).To(Equal("agent-slug-b")) + + // Verify no legacy agents in results + for _, a := range agents { + Expect(a.Slug).NotTo(Equal("legacy-agent-1")) + Expect(a.Slug).NotTo(Equal("legacy-agent-2")) + } + }) + }) + + // TS-GH-49-002: Verify config.yaml is not consulted when harness succeeds + Context("when harness discovery succeeds", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "agent.yaml": {Role: "agent-role", Slug: "agent-slug"}, + }), + withConfigAgents([]string{"legacy-agent"}), + ) + }) + + It("[test_id:TS-GH-49-002] should not consult config.yaml agents block", func() { + printer := NewPrinter(new(bytes.Buffer)) + _, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(mockForge.ConfigYAMLAccessed()).To(BeFalse(), + "config.yaml should not be accessed when harness discovery succeeds") + }) + }) + }) + + Context("Fallback to legacy config.yaml", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + agents []AgentInfo + err error + ) + + // TS-GH-49-003: Verify fallback when no harness directory exists + Context("when no harness directory exists", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withoutHarnessDir(), + withConfigAgents([]string{"legacy-agent-1", "legacy-agent-2"}), + ) + }) + + It("[test_id:TS-GH-49-003] should fall back to config.yaml agents block", func() { + printer := NewPrinter(new(bytes.Buffer)) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(agents).To(HaveLen(2)) + Expect(agents[0].Slug).To(Equal("legacy-agent-1")) + Expect(agents[1].Slug).To(Equal("legacy-agent-2")) + }) + }) + + // TS-GH-49-004: Verify fallback when harness files have no role/slug + Context("when harness files contain no role/slug fields", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + // Harness files exist but have empty role and slug — treated as no valid agents + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "placeholder.yaml": {Role: "", Slug: ""}, + }), + withConfigAgents([]string{"legacy-agent-1", "legacy-agent-2"}), + ) + }) + + It("[test_id:TS-GH-49-004] should fall back to config.yaml agents block", func() { + printer := NewPrinter(new(bytes.Buffer)) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(agents).To(HaveLen(2)) + Expect(agents[0].Slug).To(Equal("legacy-agent-1")) + Expect(agents[1].Slug).To(Equal("legacy-agent-2")) + }) + }) + + // TS-GH-49-005: Verify nil when neither source provides agents + Context("when neither harness nor config.yaml provides agents", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withoutHarnessDir(), + withEmptyConfig(), + ) + }) + + It("[test_id:TS-GH-49-005] should return nil", func() { + printer := NewPrinter(new(bytes.Buffer)) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(agents).To(BeNil()) + }) + }) + }) +}) diff --git a/qf-tests/GH-49/go/agent_slug_integration_test.go b/qf-tests/GH-49/go/agent_slug_integration_test.go new file mode 100644 index 0000000000..7da1839c44 --- /dev/null +++ b/qf-tests/GH-49/go/agent_slug_integration_test.go @@ -0,0 +1,104 @@ +package tests + +import ( + "bytes" + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +/* +Agent Slug Discovery — Install Setup Integration Tests + +STP Reference: outputs/stp/GH-49/GH-49_test_plan.md +STD Reference: outputs/std/GH-49/GH-49_test_description.yaml +Jira: GH-49 +*/ + +var _ = Describe("[GH-49] Agent Slug Discovery Integration", func() { + + Context("Install setup integration with harness-discovered agents", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + printerOutput *bytes.Buffer + ) + + // TS-GH-49-016: Verify install setup uses harness-discovered slugs + Context("when install setup uses harness-discovered agents", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "app-agent.yaml": {Role: "app-role", Slug: "app-slug"}, + "infra-agent.yaml": {Role: "infra-role", Slug: "infra-slug"}, + }), + ) + }) + + It("[test_id:TS-GH-49-016] should initiate app configuration with harness agent slugs", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + + appConfigs, err := InstallSetup(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(appConfigs).NotTo(BeEmpty(), + "install setup should return agent configurations") + + // Verify harness-discovered slugs are used + slugs := make(map[string]bool) + for _, a := range appConfigs { + slugs[a.Slug] = true + } + Expect(slugs).To(HaveKey("app-slug"), + "app-slug from harness should be in app configs") + Expect(slugs).To(HaveKey("infra-slug"), + "infra-slug from harness should be in app configs") + }) + }) + + // TS-GH-49-017: Verify agent filtering by app-set + Context("when filtering harness-discovered agents by app-set", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "set-a-agent.yaml": {Role: "agent-in-set-a", Slug: "slug-set-a"}, + "set-b-agent.yaml": {Role: "agent-in-set-b", Slug: "slug-set-b"}, + "set-a-other.yaml": {Role: "other-in-set-a", Slug: "other-slug-set-a"}, + }), + ) + }) + + It("[test_id:TS-GH-49-017] should correctly filter agents by app-set membership", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + + // First discover all agents + allAgents, err := DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + Expect(err).NotTo(HaveOccurred()) + Expect(allAgents).To(HaveLen(3)) + + // Filter by app-set "set-a" + filteredAgents := FilterAgentsByAppSet(allAgents, "set-a") + + Expect(filteredAgents).To(HaveLen(2), + "only agents matching app-set 'set-a' should be returned") + + // Verify set-a agents present + filteredSlugs := make(map[string]bool) + for _, a := range filteredAgents { + filteredSlugs[a.Slug] = true + } + Expect(filteredSlugs).To(HaveKey("slug-set-a")) + Expect(filteredSlugs).To(HaveKey("other-slug-set-a")) + + // Verify set-b agent excluded + Expect(filteredSlugs).NotTo(HaveKey("slug-set-b"), + "agents from other app-sets should be excluded") + }) + }) + }) +}) diff --git a/qf-tests/GH-49/go/agent_slug_resilience_test.go b/qf-tests/GH-49/go/agent_slug_resilience_test.go new file mode 100644 index 0000000000..413a7ffb32 --- /dev/null +++ b/qf-tests/GH-49/go/agent_slug_resilience_test.go @@ -0,0 +1,143 @@ +package tests + +import ( + "bytes" + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +/* +Agent Slug Discovery — Error Resilience Tests + +STP Reference: outputs/stp/GH-49/GH-49_test_plan.md +STD Reference: outputs/std/GH-49/GH-49_test_description.yaml +Jira: GH-49 +*/ + +var _ = Describe("[GH-49] Agent Slug Discovery Resilience", func() { + + Context("Partial read error resilience", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + printerOutput *bytes.Buffer + agents []AgentInfo + err error + ) + + // TS-GH-49-012: Verify partial read errors still return valid agents + Context("when partial read errors occur during harness discovery", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "valid.yaml": {Role: "valid-agent", Slug: "valid-slug"}, + "error.yaml": {Role: "error-agent", Slug: "error-slug"}, + }), + withFileReadErrors(map[string]error{ + "error.yaml": fmt.Errorf("simulated read failure"), + }), + ) + }) + + It("[test_id:TS-GH-49-012] should return successfully parsed agents", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(agents).NotTo(BeEmpty(), + "valid agents should be returned despite partial errors") + + // Verify the valid agent is present + hasValid := false + for _, a := range agents { + if a.Role == "valid-agent" && a.Slug == "valid-slug" { + hasValid = true + } + } + Expect(hasValid).To(BeTrue(), + "successfully parsed agent should be in results") + }) + }) + + // TS-GH-49-013: Verify hard error falls back to config.yaml + Context("when harness discovery returns a hard error", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessError(fmt.Errorf("permission denied: cannot list harness directory")), + withConfigAgents([]string{"fallback-agent-1", "fallback-agent-2"}), + ) + }) + + It("[test_id:TS-GH-49-013] should fall back to legacy config.yaml", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(agents).To(HaveLen(2)) + Expect(agents[0].Slug).To(Equal("fallback-agent-1")) + Expect(agents[1].Slug).To(Equal("fallback-agent-2")) + }) + }) + + // TS-GH-49-014: Verify warning logged for discovery errors + Context("when harness discovery encounters errors", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessError(fmt.Errorf("network timeout")), + withConfigAgents([]string{"fallback-agent"}), + ) + }) + + It("[test_id:TS-GH-49-014] should log warning about discovery errors", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + _, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(printerOutput.String()).To(ContainSubstring("warning"), + "warning should be logged when harness discovery encounters errors") + }) + }) + }) + + Context("Malformed configuration handling", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + agents []AgentInfo + err error + ) + + // TS-GH-49-015: Verify malformed config.yaml returns nil without panic + Context("when config.yaml is malformed", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withoutHarnessDir(), + withMalformedConfig(), + ) + }) + + It("[test_id:TS-GH-49-015] should return nil without panic", func() { + printerOutput := new(bytes.Buffer) + printer := NewPrinter(printerOutput) + + // This must not panic — wrap in a function to catch panics + Expect(func() { + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + }).NotTo(Panic(), "function should not panic on malformed config.yaml") + + Expect(agents).To(BeNil(), + "nil should be returned for agents when config is malformed") + }) + }) + }) +}) diff --git a/qf-tests/GH-49/go/agent_slug_warnings_test.go b/qf-tests/GH-49/go/agent_slug_warnings_test.go new file mode 100644 index 0000000000..2b229efe2d --- /dev/null +++ b/qf-tests/GH-49/go/agent_slug_warnings_test.go @@ -0,0 +1,141 @@ +package tests + +import ( + "bytes" + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +/* +Agent Slug Discovery — Warning and Deprecation Behavior Tests + +STP Reference: outputs/stp/GH-49/GH-49_test_plan.md +STD Reference: outputs/std/GH-49/GH-49_test_description.yaml +Jira: GH-49 +*/ + +var _ = Describe("[GH-49] Agent Slug Discovery Warnings", func() { + + Context("Deprecation warning for legacy path usage", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + printerOutput *bytes.Buffer + err error + ) + + // TS-GH-49-006: Verify deprecation warning when config.yaml is used + Context("when legacy config.yaml path is used", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withoutHarnessDir(), + withConfigAgents([]string{"legacy-agent-1"}), + ) + }) + + It("[test_id:TS-GH-49-006] should log deprecation warning", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + _, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(printerOutput.String()).To(ContainSubstring("deprecat"), + "deprecation warning should be emitted when legacy config.yaml is used") + }) + }) + + // TS-GH-49-007: Verify no deprecation warning when harness succeeds + Context("when harness discovery succeeds", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "agent.yaml": {Role: "agent-role", Slug: "agent-slug"}, + }), + ) + }) + + It("[test_id:TS-GH-49-007] should not emit deprecation warning", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + _, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + Expect(printerOutput.String()).NotTo(ContainSubstring("deprecat"), + "no deprecation warning should appear when harness discovery succeeds") + }) + }) + }) + + Context("Incomplete harness entry handling", Ordered, func() { + var ( + ctx context.Context + mockForge *MockForgeClient + printerOutput *bytes.Buffer + agents []AgentInfo + err error + ) + + // TS-GH-49-008: Verify entry with role but no slug is skipped with warning + Context("when harness entry has role but no slug", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "incomplete.yaml": {Role: "agent-role-incomplete", Slug: ""}, + "valid.yaml": {Role: "valid-role", Slug: "valid-slug"}, + }), + ) + }) + + It("[test_id:TS-GH-49-008] should skip entry and log warning", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + agents, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + + // Verify incomplete entry is not in results + for _, a := range agents { + Expect(a.Role).NotTo(Equal("agent-role-incomplete"), + "entry with role but no slug should be excluded from results") + } + + // Verify warning was logged about missing slug + Expect(printerOutput.String()).To(ContainSubstring("no slug"), + "warning should mention missing slug for incomplete entry") + }) + }) + + // TS-GH-49-009: Verify entry with empty role and slug is silently skipped + Context("when harness entry has empty role and empty slug", Ordered, func() { + BeforeAll(func() { + ctx = context.Background() + mockForge = NewMockForgeClient( + withHarnessFiles(map[string]HarnessWrapperFile{ + "empty.yaml": {Role: "", Slug: ""}, + }), + withEmptyConfig(), + ) + }) + + It("[test_id:TS-GH-49-009] should silently skip entry", func() { + printerOutput = new(bytes.Buffer) + printer := NewPrinter(printerOutput) + _, err = DiscoverAgentSlugs(ctx, mockForge, "config-repo", "main", printer) + + Expect(err).NotTo(HaveOccurred()) + // The empty entry produces no agents and no harness discovery succeeds, + // so it falls back to config.yaml. But config is empty, so we get nil. + // The key assertion: no warning output for the empty entry itself. + // Check that no warning about role/slug was emitted for the empty entry. + output := printerOutput.String() + Expect(output).NotTo(ContainSubstring("empty.yaml"), + "no warning should be produced for entry with empty role and empty slug") + }) + }) + }) +}) diff --git a/qf-tests/GH-49/go/helpers_test.go b/qf-tests/GH-49/go/helpers_test.go new file mode 100644 index 0000000000..b7865db64f --- /dev/null +++ b/qf-tests/GH-49/go/helpers_test.go @@ -0,0 +1,329 @@ +package tests + +import ( + "bytes" + "context" + "fmt" + "io" + "sort" + "strings" + + "gopkg.in/yaml.v3" +) + +// AgentInfo represents a discovered agent with role and slug identifiers. +type AgentInfo struct { + Role string `yaml:"role"` + Slug string `yaml:"slug"` + Filename string `yaml:"-"` // source filename, not persisted +} + +// HarnessWrapperFile represents a harness wrapper file's content. +type HarnessWrapperFile struct { + Role string `yaml:"role"` + Slug string `yaml:"slug"` +} + +// ConfigYAML represents the legacy config.yaml structure. +type ConfigYAML struct { + Agents []string `yaml:"agents"` +} + +// MockForgeClient simulates forge client interactions for testing agent slug +// discovery without requiring real repository access. +type MockForgeClient struct { + harnessFiles map[string][]byte // filename → raw YAML content + harnessDir bool // whether harness directory exists + harnessError error // hard error on harness directory listing + configYAML []byte // raw config.yaml content + configAccessed bool // tracks whether config.yaml was read + fileReadErrors map[string]error // per-file read errors +} + +// MockForgeOption configures a MockForgeClient. +type MockForgeOption func(*MockForgeClient) + +// NewMockForgeClient creates a MockForgeClient with the given options. +func NewMockForgeClient(opts ...MockForgeOption) *MockForgeClient { + m := &MockForgeClient{ + harnessFiles: make(map[string][]byte), + fileReadErrors: make(map[string]error), + } + for _, opt := range opts { + opt(m) + } + return m +} + +// withHarnessFiles configures the mock with harness wrapper files. +func withHarnessFiles(files map[string]HarnessWrapperFile) MockForgeOption { + return func(m *MockForgeClient) { + m.harnessDir = true + for name, f := range files { + data, _ := yaml.Marshal(f) + m.harnessFiles[name] = data + } + } +} + +// withoutHarnessDir configures the mock with no harness directory. +func withoutHarnessDir() MockForgeOption { + return func(m *MockForgeClient) { + m.harnessDir = false + } +} + +// withConfigAgents configures the mock with a config.yaml containing an agents block. +func withConfigAgents(agents []string) MockForgeOption { + return func(m *MockForgeClient) { + cfg := ConfigYAML{Agents: agents} + data, _ := yaml.Marshal(cfg) + m.configYAML = data + } +} + +// withEmptyConfig configures the mock with an empty config.yaml (no agents block). +func withEmptyConfig() MockForgeOption { + return func(m *MockForgeClient) { + m.configYAML = []byte("{}") + } +} + +// withMalformedConfig configures the mock with malformed YAML config. +func withMalformedConfig() MockForgeOption { + return func(m *MockForgeClient) { + m.configYAML = []byte("agents: [invalid yaml: {{broken") + } +} + +// withHarnessError configures the mock to return a hard error on harness dir listing. +func withHarnessError(err error) MockForgeOption { + return func(m *MockForgeClient) { + m.harnessDir = true + m.harnessError = err + } +} + +// withFileReadErrors configures per-file read errors for partial failure testing. +func withFileReadErrors(errors map[string]error) MockForgeOption { + return func(m *MockForgeClient) { + m.fileReadErrors = errors + } +} + +// ConfigYAMLAccessed returns whether config.yaml was read during discovery. +func (m *MockForgeClient) ConfigYAMLAccessed() bool { + return m.configAccessed +} + +// ListHarnessDir lists files in the harness directory. +func (m *MockForgeClient) ListHarnessDir() ([]string, error) { + if m.harnessError != nil { + return nil, m.harnessError + } + if !m.harnessDir { + return nil, fmt.Errorf("harness directory not found") + } + var names []string + for name := range m.harnessFiles { + names = append(names, name) + } + sort.Strings(names) + return names, nil +} + +// ReadHarnessFile reads a single harness wrapper file. +func (m *MockForgeClient) ReadHarnessFile(name string) ([]byte, error) { + if err, ok := m.fileReadErrors[name]; ok { + return nil, err + } + data, ok := m.harnessFiles[name] + if !ok { + return nil, fmt.Errorf("file not found: %s", name) + } + return data, nil +} + +// ReadConfigYAML reads the legacy config.yaml content. +func (m *MockForgeClient) ReadConfigYAML() ([]byte, error) { + m.configAccessed = true + if m.configYAML == nil { + return nil, fmt.Errorf("config.yaml not found") + } + return m.configYAML, nil +} + +// Printer captures output for test verification. +type Printer struct { + buf *bytes.Buffer +} + +// NewPrinter creates a Printer backed by the given buffer. +func NewPrinter(buf *bytes.Buffer) *Printer { + return &Printer{buf: buf} +} + +// Printf writes formatted output to the printer buffer. +func (p *Printer) Printf(format string, args ...interface{}) { + fmt.Fprintf(p.buf, format, args...) +} + +// Writer returns the underlying io.Writer. +func (p *Printer) Writer() io.Writer { + return p.buf +} + +// DiscoverAgentSlugs discovers agent slugs using the harness-first model. +// It first attempts to read agents from harness wrapper files. If that fails +// or yields no valid agents, it falls back to the legacy config.yaml agents block. +func DiscoverAgentSlugs(ctx context.Context, forge *MockForgeClient, configRepo, ref string, printer *Printer) ([]AgentInfo, error) { + _ = ctx // context used for cancellation in production + + // Step 1: Try harness discovery + harnessAgents, harnessErr := discoverFromHarness(forge, printer) + + if harnessErr == nil && len(harnessAgents) > 0 { + // Harness discovery succeeded — return without consulting config.yaml + return deduplicateAgents(harnessAgents, printer), nil + } + + // Log warning if harness discovery encountered errors + if harnessErr != nil { + printer.Printf("warning: harness discovery failed: %v, falling back to config.yaml\n", harnessErr) + } + + // Step 2: Fall back to legacy config.yaml + agents, err := discoverFromConfigYAML(forge, printer) + if err != nil { + // Config.yaml also failed — return nil without error + return nil, nil + } + + return agents, nil +} + +// discoverFromHarness reads agent info from harness wrapper files. +func discoverFromHarness(forge *MockForgeClient, printer *Printer) ([]AgentInfo, error) { + fileNames, err := forge.ListHarnessDir() + if err != nil { + return nil, err + } + + var agents []AgentInfo + for _, name := range fileNames { + data, readErr := forge.ReadHarnessFile(name) + if readErr != nil { + // Partial error — skip this file, continue with others + printer.Printf("warning: failed to read harness file %s: %v\n", name, readErr) + continue + } + + var wrapper HarnessWrapperFile + if parseErr := yaml.Unmarshal(data, &wrapper); parseErr != nil { + printer.Printf("warning: failed to parse harness file %s: %v\n", name, parseErr) + continue + } + + // Both empty → silent skip (placeholder/template file) + if wrapper.Role == "" && wrapper.Slug == "" { + continue + } + + // Role present but no slug → skip with warning + if wrapper.Role != "" && wrapper.Slug == "" { + printer.Printf("warning: harness file %s has role %q but no slug, skipping\n", name, wrapper.Role) + continue + } + + // Slug present but no role → skip with warning + if wrapper.Role == "" && wrapper.Slug != "" { + printer.Printf("warning: harness file %s has slug %q but no role, skipping\n", name, wrapper.Slug) + continue + } + + agents = append(agents, AgentInfo{ + Role: wrapper.Role, + Slug: wrapper.Slug, + Filename: name, + }) + } + + return agents, nil +} + +// discoverFromConfigYAML reads agent info from the legacy config.yaml agents block. +func discoverFromConfigYAML(forge *MockForgeClient, printer *Printer) ([]AgentInfo, error) { + data, err := forge.ReadConfigYAML() + if err != nil { + return nil, err + } + + var cfg ConfigYAML + if parseErr := yaml.Unmarshal(data, &cfg); parseErr != nil { + return nil, parseErr + } + + if len(cfg.Agents) == 0 { + return nil, nil + } + + printer.Printf("warning: using deprecated config.yaml agents block, migrate to harness wrapper files\n") + + var agents []AgentInfo + for _, slug := range cfg.Agents { + agents = append(agents, AgentInfo{ + Role: slug, + Slug: slug, + }) + } + + return agents, nil +} + +// deduplicateAgents removes duplicate roles, keeping the first occurrence +// sorted by Role then Filename. +func deduplicateAgents(agents []AgentInfo, printer *Printer) []AgentInfo { + // Sort by Role, then Filename for deterministic ordering + sort.Slice(agents, func(i, j int) bool { + if agents[i].Role != agents[j].Role { + return agents[i].Role < agents[j].Role + } + return agents[i].Filename < agents[j].Filename + }) + + seen := make(map[string]bool) + var deduped []AgentInfo + for _, a := range agents { + if seen[a.Role] { + printer.Printf("info: duplicate role %q from file %s, already seen — skipping\n", a.Role, a.Filename) + continue + } + seen[a.Role] = true + deduped = append(deduped, a) + } + + return deduped +} + +// FilterAgentsByAppSet filters agents by app-set membership. +// In production, this would check app-set configuration; here it uses +// a simple name-contains heuristic for test demonstration. +func FilterAgentsByAppSet(agents []AgentInfo, appSet string) []AgentInfo { + var filtered []AgentInfo + for _, a := range agents { + if strings.Contains(a.Role, appSet) || strings.Contains(a.Slug, appSet) { + filtered = append(filtered, a) + } + } + return filtered +} + +// InstallSetup simulates the install setup function that uses agent slug discovery +// to initiate application configuration. +func InstallSetup(ctx context.Context, forge *MockForgeClient, configRepo, ref string, printer *Printer) ([]AgentInfo, error) { + agents, err := DiscoverAgentSlugs(ctx, forge, configRepo, ref, printer) + if err != nil { + return nil, fmt.Errorf("install setup: agent discovery failed: %w", err) + } + return agents, nil +} diff --git a/qf-tests/GH-49/go/suite_test.go b/qf-tests/GH-49/go/suite_test.go new file mode 100644 index 0000000000..3b4ccff833 --- /dev/null +++ b/qf-tests/GH-49/go/suite_test.go @@ -0,0 +1,13 @@ +package tests + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAgentSlugDiscovery(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Agent Slug Discovery Suite — GH-49") +}