From d4140ee24480e1e0fcffbd32725a988d1abcb757 Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 14 Apr 2026 14:06:47 -0700 Subject: [PATCH 1/2] feat(platform): generic category_routing replaces hardcoded audit dispatch (#51) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a category_routing block to org.yaml schema (defaults + per-workspace, UNION semantics with per-key replace). The merged routing table is rendered into each workspace's config.yaml at import time. PM's system prompt loses the hardcoded security/ui/infra → role mapping from PR #50; instead it reads category_routing from /configs/config.yaml and delegates to whatever roles the org template lists for the incoming audit-summary's category. Future org templates ship their own routing without prompt churn. Tests: 4 new TestCategoryRouting_* cases covering YAML parse, UNION+drop semantics, deterministic config.yaml render, and empty-map handling. Co-Authored-By: Claude Opus 4.6 (1M context) --- org-templates/molecule-dev/org.yaml | 16 +++ .../molecule-dev/pm/system-prompt.md | 17 +-- platform/internal/handlers/org.go | 112 +++++++++++++++- platform/internal/handlers/org_test.go | 121 ++++++++++++++++++ 4 files changed, 251 insertions(+), 15 deletions(-) diff --git a/org-templates/molecule-dev/org.yaml b/org-templates/molecule-dev/org.yaml index 849f8b99..2bd885f0 100644 --- a/org-templates/molecule-dev/org.yaml +++ b/org-templates/molecule-dev/org.yaml @@ -39,6 +39,22 @@ defaults: - molecule-session-context - molecule-skill-cron-learnings - molecule-skill-update-docs + + # Audit-summary routing — generic per-template mapping (issue #51). + # Auditors (Security Auditor, UIUX Designer, QA Engineer) send A2A messages + # with metadata.audit_summary.category set. The receiver (PM) reads this + # table from its own /configs/config.yaml and delegates to each listed role. + # Each org template owns its own mapping — role names are NOT hardcoded in + # prompts, so adding/renaming roles is a config-only change. + category_routing: + security: [Backend Engineer, DevOps Engineer] + ui: [Frontend Engineer] + ux: [Frontend Engineer] + infra: [DevOps Engineer] + qa: [QA Engineer] + performance: [Backend Engineer] + mixed: [Dev Lead] + # workspace_dir: not set by default — each agent gets an isolated Docker volume # Set per-workspace to bind-mount a host directory as /workspace diff --git a/org-templates/molecule-dev/pm/system-prompt.md b/org-templates/molecule-dev/pm/system-prompt.md index 3de160e5..06a551f6 100644 --- a/org-templates/molecule-dev/pm/system-prompt.md +++ b/org-templates/molecule-dev/pm/system-prompt.md @@ -27,19 +27,16 @@ Security Auditor, UIUX Designer, and QA Engineer run hourly/half-daily audit cro - counts by severity (critical / high / medium / low / clean) - **list of GitHub issue numbers filed this cycle** - top recommendation +- **`metadata.audit_summary.category`** on the A2A message (set by the auditor) **Every such arrival with issue numbers is a dispatch trigger, not FYI.** The moment you receive one: -1. For each issue number in the summary, `gh issue view ` to read the full body and category. -2. Route each issue to the right dev agent by category: - - `security(...)`, auth, crypto, SQL/RCE/path-traversal, missing access control → **Backend Engineer** - - `ui`, `ux`, theme, a11y, keyboard-nav, WCAG → **Frontend Engineer** - - `infra`, Dockerfile, CI, provisioner, secrets, ops, deployment → **DevOps Engineer** - - test suite / coverage / flake / regression → **QA Engineer** - - mixed / unclear → **Dev Lead** to split further. -3. Delegate with a specific brief: issue number, proposed fix scope, acceptance criteria (close #N via `Closes #N` in PR, CI green, tests added if applicable, no `main` commits). -4. Use parallel `delegate_task_async` when issues span multiple categories — don't serialize what can be concurrent. -5. Track the fan-out. End of cycle, summary back to memory: "audit dispatched N issues, M still in flight, P landed as PRs #…". +1. **Look up the routing table.** Read `/configs/config.yaml` and find the `category_routing:` block. It maps each `category` (e.g. `security`, `ui`, `infra`) to a list of role names — these are the roles you should delegate to. The mapping is owned by the org template, not by this prompt; do not hardcode role names from memory. +2. For each issue number in the summary, `gh issue view ` to read the full body and category. The issue's `` label / title prefix should match a key in `category_routing`. +3. **Look up the category in your routing table** and `delegate_task` (or parallel `delegate_task_async` for multi-issue summaries) to **every role listed for that category**. If multiple roles are listed, delegate to all of them in parallel — that's the org's policy for that category. +4. **If the category is not in the routing table:** log it (`commit_memory` with key `audit-routing-miss-`), ack the auditor with "no routing rule for category=``; flagging for CEO", and move on. Do not invent a role to send it to. +5. Delegate with a specific brief: issue number, proposed fix scope, acceptance criteria (close #N via `Closes #N` in PR, CI green, tests added if applicable, no `main` commits). +6. Track the fan-out. End of cycle, summary back to memory: "audit dispatched N issues, M still in flight, P landed as PRs #…". **Clean cycles** (audit summary says "clean on SHA X", zero issue numbers) — acknowledge only; no delegation needed. diff --git a/platform/internal/handlers/org.go b/platform/internal/handlers/org.go index 77973edb..b9273c49 100644 --- a/platform/internal/handlers/org.go +++ b/platform/internal/handlers/org.go @@ -58,11 +58,16 @@ type OrgTemplate struct { } type OrgDefaults struct { - Runtime string `yaml:"runtime" json:"runtime"` - Tier int `yaml:"tier" json:"tier"` - Model string `yaml:"model" json:"model"` - Plugins []string `yaml:"plugins" json:"plugins"` - InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` + Runtime string `yaml:"runtime" json:"runtime"` + Tier int `yaml:"tier" json:"tier"` + Model string `yaml:"model" json:"model"` + Plugins []string `yaml:"plugins" json:"plugins"` + InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` + // CategoryRouting maps issue/audit category → list of target roles. + // Per-workspace blocks UNION + override per-key with these defaults. + // Rendered into each workspace's config.yaml so agent prompts can read it + // generically (no hardcoded role names in prompts). See issue #51. + CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"` } type OrgSchedule struct { @@ -96,6 +101,9 @@ type OrgWorkspace struct { WorkspaceAccess string `yaml:"workspace_access" json:"workspace_access"` // #65: "none" (default), "read_only", "read_write" Plugins []string `yaml:"plugins" json:"plugins"` InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` + // CategoryRouting overrides/extends defaults.category_routing per-workspace. + // UNION semantics on the keys (mirroring plugin merge in #68). + CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"` Schedules []OrgSchedule `yaml:"schedules" json:"schedules"` Channels []OrgChannel `yaml:"channels" json:"channels"` External bool `yaml:"external" json:"external"` @@ -369,6 +377,23 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa } } + // Render category_routing into config.yaml so the agent can read its routing + // table at runtime without hardcoded role names in prompts (issue #51). + // Per-workspace block UNIONs + per-key overrides defaults (mirrors plugin merge). + routing := mergeCategoryRouting(defaults.CategoryRouting, ws.CategoryRouting) + if len(routing) > 0 { + if configFiles == nil { + configFiles = map[string][]byte{} + } + block, err := renderCategoryRoutingYAML(routing) + if err != nil { + log.Printf("Org import: failed to render category_routing for %s: %v", ws.Name, err) + } else { + existing := configFiles["config.yaml"] + configFiles["config.yaml"] = append(existing, []byte(block)...) + } + } + // Inject initial_prompt into config.yaml (workspace-level overrides default) initialPrompt := ws.InitialPrompt if initialPrompt == "" { @@ -642,6 +667,83 @@ func parseEnvFile(path string, out map[string]string) { } } +// mergeCategoryRouting unions defaults.category_routing with per-workspace +// category_routing. Workspace-level keys override the default's value for that +// key (the role list is replaced wholesale, not unioned per-key, so a workspace +// can narrow a category — e.g. "infra: [DevOps Only]"). Empty role lists drop +// the category entirely. See issue #51. +func mergeCategoryRouting(defaultRouting, wsRouting map[string][]string) map[string][]string { + out := map[string][]string{} + for k, v := range defaultRouting { + if k == "" || len(v) == 0 { + continue + } + cp := make([]string, len(v)) + copy(cp, v) + out[k] = cp + } + for k, v := range wsRouting { + if k == "" { + continue + } + if len(v) == 0 { + // Empty list = explicit "drop this category for this workspace" + delete(out, k) + continue + } + cp := make([]string, len(v)) + copy(cp, v) + out[k] = cp + } + return out +} + +// renderCategoryRoutingYAML emits a deterministic YAML block of the form: +// +// category_routing: +// security: ["Backend Engineer", "DevOps"] +// ui: ["Frontend Engineer"] +// +// Keys are sorted for stable output (test-friendly + diff-friendly). +func renderCategoryRoutingYAML(routing map[string][]string) (string, error) { + if len(routing) == 0 { + return "", nil + } + // yaml.v3 marshal with sorted keys — Go map iteration is random, so we + // sort and emit by hand for deterministic output. + keys := make([]string, 0, len(routing)) + for k := range routing { + keys = append(keys, k) + } + // simple sort + for i := 1; i < len(keys); i++ { + for j := i; j > 0 && keys[j-1] > keys[j]; j-- { + keys[j-1], keys[j] = keys[j], keys[j-1] + } + } + var b strings.Builder + b.WriteString("category_routing:\n") + for _, k := range keys { + roles := routing[k] + // JSON-marshal each role to handle quoting/escaping safely. + jsonRoles, err := json.Marshal(roles) + if err != nil { + return "", err + } + // Quote the key with json too in case of weird chars. + jsonKey, err := json.Marshal(k) + if err != nil { + return "", err + } + b.WriteString(" ") + b.Write(jsonKey) + b.WriteString(": ") + b.Write(jsonRoles) + b.WriteString("\n") + } + return b.String(), nil +} + // mergePlugins returns the union of defaults and per-workspace plugin lists // (deduplicated, defaults first). A per-workspace entry starting with "!" or // "-" opts that plugin OUT of the union. See issue #68. diff --git a/platform/internal/handlers/org_test.go b/platform/internal/handlers/org_test.go index df9dd295..a57b0f79 100644 --- a/platform/internal/handlers/org_test.go +++ b/platform/internal/handlers/org_test.go @@ -428,6 +428,127 @@ func TestPlugins_OptOutWithDash(t *testing.T) { } } +// ==================== category_routing (issue #51) ==================== + +func TestCategoryRouting_ParsedFromOrgYaml(t *testing.T) { + raw := ` +name: Test Org +defaults: + runtime: claude-code + category_routing: + security: [Backend Engineer, DevOps Engineer] + ui: [Frontend Engineer] + infra: [DevOps Engineer] +workspaces: + - name: PM + role: Project Manager + category_routing: + performance: [Backend Engineer] +` + var tmpl OrgTemplate + if err := yaml.Unmarshal([]byte(raw), &tmpl); err != nil { + t.Fatalf("yaml parse failed: %v", err) + } + if got := tmpl.Defaults.CategoryRouting["security"]; len(got) != 2 || got[0] != "Backend Engineer" { + t.Errorf("defaults.category_routing.security wrong: %v", got) + } + if got := tmpl.Defaults.CategoryRouting["ui"]; len(got) != 1 || got[0] != "Frontend Engineer" { + t.Errorf("defaults.category_routing.ui wrong: %v", got) + } + if len(tmpl.Workspaces) != 1 { + t.Fatalf("expected 1 workspace, got %d", len(tmpl.Workspaces)) + } + if got := tmpl.Workspaces[0].CategoryRouting["performance"]; len(got) != 1 || got[0] != "Backend Engineer" { + t.Errorf("ws.category_routing.performance wrong: %v", got) + } +} + +func TestCategoryRouting_UnionWithDefaults(t *testing.T) { + defaults := map[string][]string{ + "security": {"Backend Engineer", "DevOps"}, + "ui": {"Frontend Engineer"}, + "infra": {"DevOps"}, + } + ws := map[string][]string{ + "performance": {"Backend Engineer"}, // new key, added + "ui": {"Designer"}, // override-replace existing key + "infra": {}, // empty → drop + } + got := mergeCategoryRouting(defaults, ws) + + if v := got["security"]; len(v) != 2 || v[0] != "Backend Engineer" || v[1] != "DevOps" { + t.Errorf("security should be inherited from defaults unchanged, got %v", v) + } + if v := got["ui"]; len(v) != 1 || v[0] != "Designer" { + t.Errorf("ui should be replaced by ws value, got %v", v) + } + if _, ok := got["infra"]; ok { + t.Errorf("infra should be dropped (empty ws list), got %v", got["infra"]) + } + if v := got["performance"]; len(v) != 1 || v[0] != "Backend Engineer" { + t.Errorf("performance should be added from ws, got %v", v) + } +} + +func TestCategoryRouting_RenderedIntoWorkspaceConfig(t *testing.T) { + routing := map[string][]string{ + "security": {"Backend Engineer", "DevOps"}, + "ui": {"Frontend Engineer"}, + } + block, err := renderCategoryRoutingYAML(routing) + if err != nil { + t.Fatalf("render failed: %v", err) + } + if block == "" { + t.Fatal("expected non-empty block") + } + // Must parse as valid YAML when concatenated with a base config + combined := "name: Test\nruntime: claude-code\n" + block + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(combined), &parsed); err != nil { + t.Fatalf("rendered YAML is invalid: %v\n---\n%s", err, combined) + } + cr, ok := parsed["category_routing"].(map[string]interface{}) + if !ok { + t.Fatalf("category_routing not a map in parsed config: %T", parsed["category_routing"]) + } + sec, ok := cr["security"].([]interface{}) + if !ok || len(sec) != 2 { + t.Fatalf("security routing wrong shape: %v", cr["security"]) + } + if sec[0] != "Backend Engineer" || sec[1] != "DevOps" { + t.Errorf("security roles wrong: %v", sec) + } + ui, ok := cr["ui"].([]interface{}) + if !ok || len(ui) != 1 || ui[0] != "Frontend Engineer" { + t.Errorf("ui roles wrong: %v", cr["ui"]) + } + // Output should be deterministic (keys sorted) — security < ui + if !strings.Contains(block, "\"security\":") || !strings.Contains(block, "\"ui\":") { + t.Errorf("expected JSON-quoted keys in block: %s", block) + } + if strings.Index(block, "security") > strings.Index(block, "ui") { + t.Errorf("expected sorted keys (security before ui), got:\n%s", block) + } +} + +func TestCategoryRouting_EmptyRendersNothing(t *testing.T) { + got, err := renderCategoryRoutingYAML(nil) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "" { + t.Errorf("expected empty render for nil routing, got %q", got) + } + got, err = renderCategoryRoutingYAML(map[string][]string{}) + if err != nil { + t.Fatalf("err: %v", err) + } + if got != "" { + t.Errorf("expected empty render for empty map, got %q", got) + } +} + func TestPlugins_BackwardCompat(t *testing.T) { // Re-listing defaults in per-workspace plugins still yields the same list // (dedupe keeps behavior stable for existing org.yaml files). From c47898568c98aa0b2af771c4cd29c42ccc6e9b6e Mon Sep 17 00:00:00 2001 From: Hongming Wang Date: Tue, 14 Apr 2026 14:28:22 -0700 Subject: [PATCH 2/2] fix(org): use yaml.Marshal for category_routing + newline-guard block appends Addresses code-review warnings on PR #75: - renderCategoryRoutingYAML now builds yaml.Node + yaml.Marshal, escaping YAML-reserved chars in role names correctly (was JSON-as-YAML, fragile on unicode line separators). - New appendYAMLBlock helper guarantees a newline boundary when concatenating YAML fragments into config.yaml (category_routing + initial_prompt both used to risk merging into the previous line). - Fixed struct comment (replace-per-key, not UNION). - Added TestCategoryRouting_EscapesYAMLSpecials and TestAppendYAMLBlock_NewlineGuard. Co-Authored-By: Claude Opus 4.6 (1M context) --- platform/internal/handlers/org.go | 82 +++++++++++++++----------- platform/internal/handlers/org_test.go | 50 +++++++++++++++- 2 files changed, 93 insertions(+), 39 deletions(-) diff --git a/platform/internal/handlers/org.go b/platform/internal/handlers/org.go index b9273c49..61322204 100644 --- a/platform/internal/handlers/org.go +++ b/platform/internal/handlers/org.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "regexp" + "sort" "strings" "time" @@ -101,8 +102,10 @@ type OrgWorkspace struct { WorkspaceAccess string `yaml:"workspace_access" json:"workspace_access"` // #65: "none" (default), "read_only", "read_write" Plugins []string `yaml:"plugins" json:"plugins"` InitialPrompt string `yaml:"initial_prompt" json:"initial_prompt"` - // CategoryRouting overrides/extends defaults.category_routing per-workspace. - // UNION semantics on the keys (mirroring plugin merge in #68). + // CategoryRouting extends/overrides defaults.category_routing per-workspace. + // Merge semantics: workspace keys replace defaults' value for the same key + // (empty list drops the category entirely); new keys are added. See + // mergeCategoryRouting. CategoryRouting map[string][]string `yaml:"category_routing" json:"category_routing"` Schedules []OrgSchedule `yaml:"schedules" json:"schedules"` Channels []OrgChannel `yaml:"channels" json:"channels"` @@ -379,7 +382,8 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa // Render category_routing into config.yaml so the agent can read its routing // table at runtime without hardcoded role names in prompts (issue #51). - // Per-workspace block UNIONs + per-key overrides defaults (mirrors plugin merge). + // Per-workspace keys replace defaults per-key (empty list drops the key); + // see mergeCategoryRouting for exact semantics. routing := mergeCategoryRouting(defaults.CategoryRouting, ws.CategoryRouting) if len(routing) > 0 { if configFiles == nil { @@ -389,8 +393,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa if err != nil { log.Printf("Org import: failed to render category_routing for %s: %v", ws.Name, err) } else { - existing := configFiles["config.yaml"] - configFiles["config.yaml"] = append(existing, []byte(block)...) + configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], block) } } @@ -411,8 +414,7 @@ func (h *OrgHandler) createWorkspaceTree(ws OrgWorkspace, parentID *string, defa lines[i] = strings.TrimRight(line, " \t") } indented := strings.Join(lines, "\n ") - existing := configFiles["config.yaml"] - configFiles["config.yaml"] = append(existing, []byte(fmt.Sprintf("initial_prompt: |\n %s\n", indented))...) + configFiles["config.yaml"] = appendYAMLBlock(configFiles["config.yaml"], fmt.Sprintf("initial_prompt: |\n %s\n", indented)) log.Printf("Org import: injected initial_prompt (%d chars) into config.yaml for %s", len(trimmed), ws.Name) } @@ -701,47 +703,55 @@ func mergeCategoryRouting(defaultRouting, wsRouting map[string][]string) map[str // renderCategoryRoutingYAML emits a deterministic YAML block of the form: // // category_routing: -// security: ["Backend Engineer", "DevOps"] -// ui: ["Frontend Engineer"] +// security: [Backend Engineer, DevOps] +// ui: [Frontend Engineer] // -// Keys are sorted for stable output (test-friendly + diff-friendly). +// Keys are sorted for stable, test-friendly output. Uses yaml.Node + yaml.Marshal +// so role names containing YAML-reserved characters (colons, quotes, unicode line +// separators, etc.) are escaped by the YAML library — no ad-hoc quoting. func renderCategoryRoutingYAML(routing map[string][]string) (string, error) { if len(routing) == 0 { return "", nil } - // yaml.v3 marshal with sorted keys — Go map iteration is random, so we - // sort and emit by hand for deterministic output. keys := make([]string, 0, len(routing)) for k := range routing { + if k == "" { + continue + } keys = append(keys, k) } - // simple sort - for i := 1; i < len(keys); i++ { - for j := i; j > 0 && keys[j-1] > keys[j]; j-- { - keys[j-1], keys[j] = keys[j], keys[j-1] - } - } - var b strings.Builder - b.WriteString("category_routing:\n") + sort.Strings(keys) + + inner := &yaml.Node{Kind: yaml.MappingNode} for _, k := range keys { - roles := routing[k] - // JSON-marshal each role to handle quoting/escaping safely. - jsonRoles, err := json.Marshal(roles) - if err != nil { - return "", err + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Value: k} + valNode := &yaml.Node{Kind: yaml.SequenceNode, Style: yaml.FlowStyle} + for _, role := range routing[k] { + valNode.Content = append(valNode.Content, &yaml.Node{Kind: yaml.ScalarNode, Value: role}) } - // Quote the key with json too in case of weird chars. - jsonKey, err := json.Marshal(k) - if err != nil { - return "", err - } - b.WriteString(" ") - b.Write(jsonKey) - b.WriteString(": ") - b.Write(jsonRoles) - b.WriteString("\n") + inner.Content = append(inner.Content, keyNode, valNode) } - return b.String(), nil + doc := &yaml.Node{Kind: yaml.MappingNode} + doc.Content = []*yaml.Node{ + {Kind: yaml.ScalarNode, Value: "category_routing"}, + inner, + } + out, err := yaml.Marshal(doc) + if err != nil { + return "", err + } + return string(out), nil +} + +// appendYAMLBlock concatenates a YAML fragment to an existing buffer, guaranteeing +// a newline boundary between them. Upstream code writes config.yaml in fragments +// (base template → category_routing → initial_prompt) and the base isn't +// guaranteed to end in \n, which would merge the last line into the next block. +func appendYAMLBlock(existing []byte, block string) []byte { + if len(existing) > 0 && existing[len(existing)-1] != '\n' { + existing = append(existing, '\n') + } + return append(existing, []byte(block)...) } // mergePlugins returns the union of defaults and per-workspace plugin lists diff --git a/platform/internal/handlers/org_test.go b/platform/internal/handlers/org_test.go index a57b0f79..a1e133a7 100644 --- a/platform/internal/handlers/org_test.go +++ b/platform/internal/handlers/org_test.go @@ -524,14 +524,58 @@ func TestCategoryRouting_RenderedIntoWorkspaceConfig(t *testing.T) { t.Errorf("ui roles wrong: %v", cr["ui"]) } // Output should be deterministic (keys sorted) — security < ui - if !strings.Contains(block, "\"security\":") || !strings.Contains(block, "\"ui\":") { - t.Errorf("expected JSON-quoted keys in block: %s", block) - } if strings.Index(block, "security") > strings.Index(block, "ui") { t.Errorf("expected sorted keys (security before ui), got:\n%s", block) } } +// YAML-reserved characters in role names must be escaped by the YAML library. +// Regression guard for the earlier hand-rolled JSON-as-YAML implementation. +func TestCategoryRouting_EscapesYAMLSpecials(t *testing.T) { + routing := map[string][]string{ + "security": {"Role: with colon", `Role "with quotes"`, "Role\nwith newline"}, + } + block, err := renderCategoryRoutingYAML(routing) + if err != nil { + t.Fatalf("render failed: %v", err) + } + var parsed map[string]interface{} + if err := yaml.Unmarshal([]byte(block), &parsed); err != nil { + t.Fatalf("rendered YAML is invalid for special chars: %v\n---\n%s", err, block) + } + cr := parsed["category_routing"].(map[string]interface{}) + roles := cr["security"].([]interface{}) + if len(roles) != 3 || roles[0] != "Role: with colon" { + t.Errorf("special-char roles did not round-trip: %v", roles) + } +} + +// appendYAMLBlock must guarantee a newline boundary between existing buffer and +// the new block so downstream parsers see two separate top-level keys. +func TestAppendYAMLBlock_NewlineGuard(t *testing.T) { + cases := []struct { + name string + existing string + block string + }{ + {"existing ends without newline", "name: foo", "category_routing:\n a: [b]\n"}, + {"existing ends with newline", "name: foo\n", "category_routing:\n a: [b]\n"}, + {"empty existing", "", "category_routing:\n a: [b]\n"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := appendYAMLBlock([]byte(tc.existing), tc.block) + var parsed map[string]interface{} + if err := yaml.Unmarshal(got, &parsed); err != nil { + t.Fatalf("appended YAML invalid: %v\n---\n%s", err, string(got)) + } + if _, ok := parsed["category_routing"]; !ok { + t.Errorf("expected top-level category_routing key, got: %v", parsed) + } + }) + } +} + func TestCategoryRouting_EmptyRendersNothing(t *testing.T) { got, err := renderCategoryRoutingYAML(nil) if err != nil {