@juicesharp/rpiv-pi 0.12.3 → 0.12.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,110 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { AGENT_CATALOG, buildAgentEnumDescription } from "./agent-catalog.js";
7
+ import { RPIV_SPECIALISTS } from "./hide-builtin-subagents.js";
8
+
9
+ const AGENTS_DIR = fileURLToPath(new URL("../../agents/", import.meta.url));
10
+
11
+ function readFrontmatterDescription(name: string): string {
12
+ const content = readFileSync(join(AGENTS_DIR, `${name}.md`), "utf8");
13
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
14
+ if (!match) throw new Error(`${name}.md has no frontmatter`);
15
+ const fm = parseYaml(match[1]) as Record<string, unknown>;
16
+ return fm.description as string;
17
+ }
18
+
19
+ describe("AGENT_CATALOG — frontmatter-sourced per-agent descriptions", () => {
20
+ it("contains exactly one entry per RPIV_SPECIALISTS name, in declaration order", () => {
21
+ expect(AGENT_CATALOG.map((e) => e.name)).toEqual([...RPIV_SPECIALISTS]);
22
+ });
23
+
24
+ it("every entry has a non-empty description string", () => {
25
+ for (const entry of AGENT_CATALOG) {
26
+ expect(typeof entry.description).toBe("string");
27
+ expect(entry.description.length).toBeGreaterThan(0);
28
+ }
29
+ });
30
+
31
+ it("each entry's description is a byte-for-byte mirror of the agents/<name>.md frontmatter", () => {
32
+ // Hard invariant: no trimming, no truncation, no transformation — the
33
+ // value the LLM sees is exactly the value authored in the .md file.
34
+ for (const entry of AGENT_CATALOG) {
35
+ expect(entry.description).toBe(readFrontmatterDescription(entry.name));
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("AGENTS_DIR resolution — cross-platform URL walk via fileURLToPath(new URL(...))", () => {
41
+ // WHATWG URL resolution is OS-agnostic: `new URL("../../agents/", base)` uses
42
+ // forward-slash URL semantics regardless of what OS the test runs on. We can
43
+ // therefore simulate POSIX, Windows, and Windows-UNC base URLs from any host
44
+ // platform and assert the resolved URL is correct — no actual file I/O, no
45
+ // need for a Windows CI runner to catch URL-walk bugs.
46
+ const MODULE_REL = "/packages/rpiv-pi/extensions/subagent-widget/agent-catalog.ts";
47
+
48
+ it("POSIX base: resolves to the rpiv-pi/agents/ directory URL", () => {
49
+ const base = `file:///Users/alice/repo${MODULE_REL}`;
50
+ const resolved = new URL("../../agents/", base);
51
+ expect(resolved.href).toBe("file:///Users/alice/repo/packages/rpiv-pi/agents/");
52
+ });
53
+
54
+ it("Windows drive-letter base: resolves preserving the C: drive and backslash-free URL form", () => {
55
+ const base = `file:///C:/Users/alice/repo${MODULE_REL}`;
56
+ const resolved = new URL("../../agents/", base);
57
+ expect(resolved.href).toBe("file:///C:/Users/alice/repo/packages/rpiv-pi/agents/");
58
+ });
59
+
60
+ it("Windows UNC share base: preserves the //server/share/ authority across the relative walk", () => {
61
+ const base = `file:////server/share/repo${MODULE_REL}`;
62
+ const resolved = new URL("../../agents/", base);
63
+ // WHATWG keeps the empty-host + path-with-leading-// form of UNC file URLs;
64
+ // Node's fileURLToPath on Windows later converts this to \\server\share\... .
65
+ expect(resolved.href).toBe("file:////server/share/repo/packages/rpiv-pi/agents/");
66
+ });
67
+
68
+ it("URL-encoded characters in the path (space, %20): round-trip through fileURLToPath correctly", () => {
69
+ // Typical hazard: installing under a path with a space (e.g. "Program Files").
70
+ // import.meta.url encodes the space as %20; fileURLToPath decodes it back.
71
+ const base = `file:///Users/alice/Program%20Files/repo${MODULE_REL}`;
72
+ const resolved = new URL("../../agents/", base);
73
+ expect(resolved.href).toBe("file:///Users/alice/Program%20Files/repo/packages/rpiv-pi/agents/");
74
+ // fileURLToPath decodes %20 back to a literal space in the filesystem path.
75
+ const fsPath = fileURLToPath(resolved);
76
+ expect(fsPath).toContain("Program Files");
77
+ expect(fsPath).not.toContain("%20");
78
+ });
79
+
80
+ it("current-platform sanity: AGENTS_DIR points at a real directory containing the specialists", () => {
81
+ // Closes the loop — the URL walk is correct on this runner AND the
82
+ // resulting path is a real dir that readFileSync can subsequently read.
83
+ const stat = statSync(AGENTS_DIR);
84
+ expect(stat.isDirectory()).toBe(true);
85
+ for (const name of RPIV_SPECIALISTS) {
86
+ const entryStat = statSync(join(AGENTS_DIR, `${name}.md`));
87
+ expect(entryStat.isFile()).toBe(true);
88
+ }
89
+ });
90
+ });
91
+
92
+ describe("buildAgentEnumDescription — flattens catalog into an LLM-facing bullet list", () => {
93
+ const text = buildAgentEnumDescription();
94
+
95
+ it("starts with a contextual header line before the bullet list", () => {
96
+ expect(text.split("\n")[0]).toBe("Agent name (SINGLE mode) or target for management get/update/delete. Options:");
97
+ });
98
+
99
+ it("emits one '- <name>: <description>' bullet per specialist", () => {
100
+ for (const entry of AGENT_CATALOG) {
101
+ expect(text).toContain(`- ${entry.name}: ${entry.description}`);
102
+ }
103
+ });
104
+
105
+ it("orders bullets by RPIV_SPECIALISTS declaration order", () => {
106
+ const bullets = text.split("\n").slice(1);
107
+ const names = bullets.map((line) => line.replace(/^- /, "").split(":")[0]);
108
+ expect(names).toEqual([...RPIV_SPECIALISTS]);
109
+ });
110
+ });
@@ -0,0 +1,58 @@
1
+ // Loads per-agent descriptions from packages/rpiv-pi/agents/<name>.md YAML
2
+ // frontmatter so the `agent` tool parameter can carry a rich enum-with-table
3
+ // description instead of duplicating copy across the monorepo. Runs once at
4
+ // module init; crashes loudly if an expected agent file is missing.
5
+
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { parse as parseYaml } from "yaml";
10
+ import { RPIV_SPECIALISTS } from "./hide-builtin-subagents.js";
11
+
12
+ // extensions/subagent-widget/agent-catalog.ts → packages/rpiv-pi/agents/
13
+ // URL form matches the monorepo idiom (see root guidance: prompts loaded via
14
+ // `fileURLToPath(new URL("./prompts/…", import.meta.url))`) and cross-platform
15
+ // safely handles both POSIX and Windows file URLs.
16
+ const AGENTS_DIR = fileURLToPath(new URL("../../agents/", import.meta.url));
17
+
18
+ // Splits a markdown file on its leading `---` frontmatter fences and parses
19
+ // the YAML block. Returns undefined when no frontmatter is present.
20
+ function extractFrontmatter(content: string): Record<string, unknown> | undefined {
21
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
22
+ if (!match) return undefined;
23
+ const parsed = parseYaml(match[1]) as unknown;
24
+ if (typeof parsed !== "object" || parsed === null) return undefined;
25
+ return parsed as Record<string, unknown>;
26
+ }
27
+
28
+ export interface AgentCatalogEntry {
29
+ readonly name: string;
30
+ readonly description: string;
31
+ }
32
+
33
+ function loadAgentEntry(name: string): AgentCatalogEntry {
34
+ const path = join(AGENTS_DIR, `${name}.md`);
35
+ const content = readFileSync(path, "utf8");
36
+ const fm = extractFrontmatter(content);
37
+ if (!fm) throw new Error(`agent-catalog: ${name}.md has no YAML frontmatter`);
38
+ const description = fm.description;
39
+ if (typeof description !== "string" || description.length === 0) {
40
+ throw new Error(`agent-catalog: ${name}.md frontmatter is missing a string 'description'`);
41
+ }
42
+ return { name, description };
43
+ }
44
+
45
+ // Eager load at module init: one readFileSync per specialist. Failure modes
46
+ // (missing file, missing description) fail Pi's boot rather than surfacing as
47
+ // a runtime tool-registration error — consistent with rpiv-todo / rpiv-advisor
48
+ // loading their prompts at module init.
49
+ export const AGENT_CATALOG: ReadonlyArray<AgentCatalogEntry> = RPIV_SPECIALISTS.map(loadAgentEntry);
50
+
51
+ // Flattens the catalog into the LLM-facing string used as the `agent` param's
52
+ // `description`. Format: header line + "- <name>: <desc>" bullets. Keeps the
53
+ // list parameter-adjacent so the LLM sees agent capabilities inline with the
54
+ // enum choice instead of having to cross-reference a separate doc.
55
+ export function buildAgentEnumDescription(): string {
56
+ const bullets = AGENT_CATALOG.map((entry) => `- ${entry.name}: ${entry.description}`).join("\n");
57
+ return `Agent name (SINGLE mode) or target for management get/update/delete. Options:\n${bullets}`;
58
+ }
@@ -0,0 +1,21 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { describe, expect, it } from "vitest";
4
+ import { RPIV_SPECIALISTS } from "./hide-builtin-subagents.js";
5
+
6
+ const AGENTS_DIR = fileURLToPath(new URL("../../agents/", import.meta.url));
7
+
8
+ function discoverAgentStems(): string[] {
9
+ return readdirSync(AGENTS_DIR)
10
+ .filter((name) => name.endsWith(".md"))
11
+ .map((name) => name.slice(0, -".md".length))
12
+ .sort();
13
+ }
14
+
15
+ describe("RPIV_SPECIALISTS catalog drift guard", () => {
16
+ it("matches the bundled packages/rpiv-pi/agents/*.md filenames", () => {
17
+ const onDisk = discoverAgentStems();
18
+ const declared = [...RPIV_SPECIALISTS].sort();
19
+ expect(declared).toEqual(onDisk);
20
+ });
21
+ });
@@ -0,0 +1,218 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ filterDisabledFromListResult,
5
+ getCuratedSubagentDescription,
6
+ LIST_FILTER_SNAPSHOT_FRAGMENT,
7
+ PI_SUBAGENTS_BUILTINS,
8
+ RPIV_SPECIALISTS,
9
+ rewriteSubagentParameters,
10
+ } from "./hide-builtin-subagents.js";
11
+
12
+ // Mirrors pi-subagents@0.17.5/index.ts:311-336 verbatim. This fixture is no
13
+ // longer used to exercise a rewriter (we replace upstream wholesale), but the
14
+ // upstream-snapshot drift guard below fails when these literals diverge — a
15
+ // signal to re-review prompts/subagent-description.txt against upstream.
16
+ const PI_SUBAGENTS_UPSTREAM_DESCRIPTION = `Delegate to subagents or manage agent definitions.
17
+
18
+ EXECUTION (use exactly ONE mode):
19
+ • SINGLE: { agent, task } - one task
20
+ • CHAIN: { chain: [{agent:"scout"}, {parallel:[{agent:"worker",count:3}]}] } - sequential pipeline with optional parallel fan-out
21
+ • PARALLEL: { tasks: [{agent,task,count?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)
22
+ • Optional context: { context: "fresh" | "fork" } (default: "fresh")
23
+
24
+ CHAIN TEMPLATE VARIABLES (use in task strings):
25
+ • {task} - The original task/request from the user
26
+ • {previous} - Text response from the previous step (empty for first step)
27
+ • {chain_dir} - Shared directory for chain files (e.g., <tmpdir>/pi-subagents-<scope>/chain-runs/abc123/)
28
+
29
+ Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }
30
+
31
+ MANAGEMENT (use action field, omit agent/task/chain/tasks):
32
+ • { action: "list" } - discover agents/chains
33
+ • { action: "get", agent: "name" } - full detail
34
+ • { action: "create", config: { name, systemPrompt, systemPromptMode, inheritProjectContext, inheritSkills, ... } }
35
+ • { action: "update", agent: "name", config: { ... } } - merge
36
+ • { action: "delete", agent: "name" }
37
+ • Use chainName for chain operations
38
+
39
+ CONTROL:
40
+ • { action: "status", id: "..." } - inspect an async/background run by id or prefix
41
+ • { action: "interrupt", id?: "..." } - soft-interrupt the current child turn and leave the run paused`;
42
+
43
+ describe("getCuratedSubagentDescription — file-loaded replacement for upstream's literal", () => {
44
+ const curated = getCuratedSubagentDescription();
45
+
46
+ it("contains no builtin agent names — leak invariant over prompts/subagent-description.txt", () => {
47
+ for (const name of PI_SUBAGENTS_BUILTINS) {
48
+ expect(curated).not.toContain(`"${name}"`);
49
+ expect(curated).not.toContain(`:"${name}"`);
50
+ }
51
+ });
52
+
53
+ it("preserves the mode and action section headers the LLM anchors on", () => {
54
+ expect(curated).toContain("EXECUTION");
55
+ expect(curated).toContain("CHAIN TEMPLATE VARIABLES");
56
+ expect(curated).toContain("MANAGEMENT");
57
+ expect(curated).toContain("CONTROL");
58
+ expect(curated).toContain("• SINGLE: { agent, task }");
59
+ expect(curated).toContain("• PARALLEL:");
60
+ expect(curated).toContain(`{ action: "list" }`);
61
+ expect(curated).toContain(`{ action: "status", id: "..." }`);
62
+ });
63
+
64
+ it("is non-empty and trimmed (no trailing whitespace from readFileSync)", () => {
65
+ expect(curated.length).toBeGreaterThan(0);
66
+ expect(curated).toBe(curated.trimEnd());
67
+ });
68
+
69
+ it("upstream drift guard: fails when pi-subagents' literal diverges from the pinned snapshot", () => {
70
+ // The snapshot is pinned to pi-subagents@0.17.5/index.ts:311-336. If
71
+ // upstream adds/edits sections, this fails FIRST — prompting a human
72
+ // review of prompts/subagent-description.txt. We deliberately do NOT
73
+ // compare curated to upstream (they're allowed to diverge); we only
74
+ // verify the snapshot remains a faithful record of what we forked from.
75
+ expect(PI_SUBAGENTS_UPSTREAM_DESCRIPTION).toContain("Delegate to subagents or manage agent definitions.");
76
+ expect(PI_SUBAGENTS_UPSTREAM_DESCRIPTION).toContain(
77
+ `{ chain: [{agent:"scout"}, {parallel:[{agent:"worker",count:3}]}] }`,
78
+ );
79
+ expect(PI_SUBAGENTS_UPSTREAM_DESCRIPTION).toContain(
80
+ `Example: { chain: [{agent:"scout", task:"Analyze {task}"}, {agent:"planner", task:"Plan based on {previous}"}] }`,
81
+ );
82
+ });
83
+ });
84
+
85
+ describe("rewriteSubagentParameters — pin top-level agent to RPIV_SPECIALISTS enum + injected description", () => {
86
+ const original = Type.Object({
87
+ agent: Type.Optional(Type.String({ description: "orig agent description" })),
88
+ task: Type.Optional(Type.String({ description: "task" })),
89
+ tasks: Type.Optional(Type.Array(Type.Object({ agent: Type.String(), task: Type.String() }))),
90
+ });
91
+
92
+ const stubAgentDescription = "stub agent description (injected)";
93
+
94
+ it("re-types the agent field to an optional string enum of RPIV_SPECIALISTS", () => {
95
+ const rewritten = rewriteSubagentParameters(original, stubAgentDescription) as unknown as {
96
+ properties: { agent: { type: string; enum: string[]; description: string } };
97
+ };
98
+ const agent = rewritten.properties.agent;
99
+ expect(agent.type).toBe("string");
100
+ expect(agent.enum).toEqual([...RPIV_SPECIALISTS]);
101
+ });
102
+
103
+ it("carries the injected description verbatim onto the agent field", () => {
104
+ const rewritten = rewriteSubagentParameters(original, stubAgentDescription) as unknown as {
105
+ properties: { agent: { description: string } };
106
+ };
107
+ expect(rewritten.properties.agent.description).toBe(stubAgentDescription);
108
+ });
109
+
110
+ it("preserves all other top-level properties unchanged by reference", () => {
111
+ const rewritten = rewriteSubagentParameters(original, stubAgentDescription) as unknown as {
112
+ properties: { task: unknown; tasks: unknown };
113
+ };
114
+ expect(rewritten.properties.task).toBe(original.properties.task);
115
+ expect(rewritten.properties.tasks).toBe(original.properties.tasks);
116
+ });
117
+
118
+ it("preserves Type.Optional modifier on sibling fields (TypeBox symbol carried via reference spread)", () => {
119
+ const schema = Type.Object({
120
+ agent: Type.Optional(Type.String()),
121
+ task: Type.Optional(Type.String()),
122
+ });
123
+ const rewritten = rewriteSubagentParameters(schema, stubAgentDescription);
124
+ const optionalKey = Object.getOwnPropertySymbols(schema.properties.task).find(
125
+ (s) => s.description === "TypeBox.Optional",
126
+ );
127
+ expect(optionalKey).toBeDefined();
128
+ const preservedFlag = (rewritten as typeof schema).properties.task[
129
+ optionalKey as keyof typeof schema.properties.task
130
+ ];
131
+ expect(preservedFlag).toBe("Optional");
132
+ });
133
+
134
+ it("does not mutate the input schema", () => {
135
+ const snapshot = JSON.stringify(original);
136
+ rewriteSubagentParameters(original, stubAgentDescription);
137
+ expect(JSON.stringify(original)).toBe(snapshot);
138
+ });
139
+
140
+ it("returns input unchanged when it is not a TypeBox object schema (defensive fallback)", () => {
141
+ const notASchema = { foo: "bar" } as unknown;
142
+ expect(rewriteSubagentParameters(notASchema, stubAgentDescription)).toBe(notASchema);
143
+ expect(rewriteSubagentParameters(undefined, stubAgentDescription)).toBe(undefined);
144
+ });
145
+ });
146
+
147
+ // Mirrors the `- <name> (<source>, disabled): <desc>` row format emitted by
148
+ // pi-subagents@0.17.5/agent-management.ts:375 (`handleList`). If upstream
149
+ // shifts the literal, the drift guard below fails first — pointing at
150
+ // LIST_FILTER_SNAPSHOT_FRAGMENT in hide-builtin-subagents.ts.
151
+ const PI_SUBAGENTS_LIST_OUTPUT = `Agents:
152
+ - claim-verifier (project): Adversarial finding verifier. …
153
+ - codebase-analyzer (project): Analyzes codebase implementation details. …
154
+ - context-builder (builtin, disabled): Analyzes requirements and codebase, generates context and meta-prompt
155
+ - delegate (builtin, disabled): Lightweight subagent that inherits the parent model with no default reads
156
+ - general-purpose (project): General-purpose agent for researching complex questions …
157
+ - oracle (builtin, disabled): High-context decision-consistency oracle that protects inherited state and prevents drift
158
+ - oracle-executor (builtin, disabled): High-context implementation agent that executes only after main-agent approval
159
+ - planner (builtin, disabled): Creates implementation plans from context and requirements
160
+ - researcher (builtin, disabled): Autonomous web researcher …
161
+ - reviewer (builtin, disabled): Code review specialist that validates implementation and fixes issues
162
+ - scout (builtin, disabled): Fast codebase recon that returns compressed context for handoff
163
+ - thoughts-locator (project): Discovers relevant documents in thoughts/ directory …
164
+ - worker (builtin, disabled): General-purpose subagent with full capabilities
165
+
166
+ Chains:
167
+ - (none)`;
168
+
169
+ describe("filterDisabledFromListResult — strip disabled builtin rows from handleList output", () => {
170
+ it("drift guard: handleList still emits the '(builtin, disabled)' suffix somewhere in the snapshot", () => {
171
+ expect(PI_SUBAGENTS_LIST_OUTPUT).toContain(LIST_FILTER_SNAPSHOT_FRAGMENT);
172
+ });
173
+
174
+ it("removes every row tagged '(builtin, disabled)' — leak invariant over all 9 builtins", () => {
175
+ const filtered = filterDisabledFromListResult(PI_SUBAGENTS_LIST_OUTPUT) as string;
176
+ for (const name of PI_SUBAGENTS_BUILTINS) {
177
+ expect(filtered).not.toContain(`- ${name} (builtin, disabled)`);
178
+ }
179
+ expect(filtered).not.toMatch(/, disabled\)/);
180
+ });
181
+
182
+ it("preserves non-disabled project rows and the Chains section verbatim", () => {
183
+ const filtered = filterDisabledFromListResult(PI_SUBAGENTS_LIST_OUTPUT) as string;
184
+ expect(filtered).toContain("- claim-verifier (project):");
185
+ expect(filtered).toContain("- codebase-analyzer (project):");
186
+ expect(filtered).toContain("- general-purpose (project):");
187
+ expect(filtered).toContain("- thoughts-locator (project):");
188
+ expect(filtered).toContain("Chains:");
189
+ expect(filtered).toContain("- (none)");
190
+ });
191
+
192
+ it("keeps the Agents: header and blank-line-before-Chains even when every builtin is removed", () => {
193
+ const filtered = filterDisabledFromListResult(PI_SUBAGENTS_LIST_OUTPUT) as string;
194
+ expect(filtered.startsWith("Agents:\n")).toBe(true);
195
+ expect(filtered).toMatch(/\n\nChains:/);
196
+ });
197
+
198
+ it("consumes trailing newline on each removed row (no accumulated blank lines)", () => {
199
+ const minimal = "Agents:\n- scout (builtin, disabled): a\n- foo (project): b\n\nChains:\n- (none)";
200
+ const filtered = filterDisabledFromListResult(minimal) as string;
201
+ expect(filtered).toBe("Agents:\n- foo (project): b\n\nChains:\n- (none)");
202
+ });
203
+
204
+ it("handles a disabled row at EOF (no trailing newline) without leaving it behind", () => {
205
+ const endEdge = "Agents:\n- foo (project): b\n- scout (builtin, disabled): a";
206
+ const filtered = filterDisabledFromListResult(endEdge) as string;
207
+ expect(filtered).toBe("Agents:\n- foo (project): b\n");
208
+ });
209
+
210
+ it("is a no-op on inputs that contain no disabled-tagged rows", () => {
211
+ const userOnly = "Agents:\n- claim-verifier (project): x\n\nChains:\n- (none)";
212
+ expect(filterDisabledFromListResult(userOnly)).toBe(userOnly);
213
+ });
214
+
215
+ it("passes undefined through unchanged", () => {
216
+ expect(filterDisabledFromListResult(undefined)).toBe(undefined);
217
+ });
218
+ });
@@ -0,0 +1,87 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { fileURLToPath } from "node:url";
3
+ import { type TSchema, Type } from "@sinclair/typebox";
4
+
5
+ export const RPIV_SPECIALISTS = [
6
+ "claim-verifier",
7
+ "codebase-analyzer",
8
+ "codebase-locator",
9
+ "codebase-pattern-finder",
10
+ "diff-auditor",
11
+ "general-purpose",
12
+ "integration-scanner",
13
+ "peer-comparator",
14
+ "precedent-locator",
15
+ "test-case-locator",
16
+ "thoughts-analyzer",
17
+ "thoughts-locator",
18
+ "web-search-researcher",
19
+ ] as const;
20
+
21
+ export const PI_SUBAGENTS_BUILTINS = [
22
+ "scout",
23
+ "planner",
24
+ "worker",
25
+ "reviewer",
26
+ "context-builder",
27
+ "researcher",
28
+ "delegate",
29
+ "oracle",
30
+ "oracle-executor",
31
+ ] as const;
32
+
33
+ const AGENT_ENUM: string[] = [...RPIV_SPECIALISTS];
34
+
35
+ // Curated tool description loaded once at module init — replaces upstream's
36
+ // literal wholesale instead of regex-editing it. Avoids the drift-fragility of
37
+ // matching template-literal fragments; we own every byte the LLM sees.
38
+ const CURATED_TOOL_DESCRIPTION = readFileSync(
39
+ fileURLToPath(new URL("./prompts/subagent-description.txt", import.meta.url)),
40
+ "utf8",
41
+ ).trimEnd();
42
+
43
+ export function getCuratedSubagentDescription(): string {
44
+ return CURATED_TOOL_DESCRIPTION;
45
+ }
46
+
47
+ interface TypeBoxObject {
48
+ properties: Record<string, TSchema>;
49
+ }
50
+
51
+ function isTypeBoxObject(schema: unknown): schema is TypeBoxObject {
52
+ return typeof schema === "object" && schema !== null && "properties" in schema;
53
+ }
54
+
55
+ // Rebuild via Type.Object (not shallow spread) so TypeBox regenerates the
56
+ // Symbol-keyed kind markers + compiler hooks that a plain clone would drop.
57
+ // agentEnumDescription is injected (not imported directly) to keep this module
58
+ // free of the agent-catalog's fs side-effect at load time — test-only callers
59
+ // can substitute a stub.
60
+ export function rewriteSubagentParameters<T>(original: T, agentEnumDescription: string): T {
61
+ if (!isTypeBoxObject(original)) return original;
62
+ const rebuilt = Type.Object({
63
+ ...original.properties,
64
+ agent: Type.Optional(Type.String({ enum: AGENT_ENUM, description: agentEnumDescription })),
65
+ });
66
+ return rebuilt as unknown as T;
67
+ }
68
+
69
+ // Matches a single "- <name> (<source>, disabled): <desc>" row as produced by
70
+ // handleList in pi-subagents@0.17.5/agent-management.ts:375. The `(builtin,
71
+ // disabled)` suffix is the load-bearing literal — source="user" / "project"
72
+ // rows never carry the ", disabled" tag, so this regex is safe to apply to the
73
+ // full text block without parsing sections. The `.*(?:\r?\n|$)` trailing group
74
+ // consumes the row's newline so removed rows don't leave blank lines behind,
75
+ // with an alternation for the EOF case (no trailing newline).
76
+ const DISABLED_ROW_REGEX = /^- .+ \([^)]+, disabled\): .*(?:\r?\n|$)/gm;
77
+
78
+ // Drift guard anchor — the exact literal suffix handleList interpolates when
79
+ // an agent is disabled. If upstream renames the tag (e.g. "(builtin, off)"),
80
+ // the test fixture fails BEFORE the filter's behavioural test, pinpointing
81
+ // the stale literal instead of reporting an empty match.
82
+ export const LIST_FILTER_SNAPSHOT_FRAGMENT = ", disabled): ";
83
+
84
+ export function filterDisabledFromListResult(text: string | undefined): string | undefined {
85
+ if (text === undefined) return undefined;
86
+ return text.replace(DISABLED_ROW_REGEX, "");
87
+ }
@@ -0,0 +1,149 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { Container, Text } from "@mariozechner/pi-tui";
3
+ import { describe, expect, it, vi } from "vitest";
4
+
5
+ // Stub the full renderer so the terminal-state branch returns a recognisable sentinel
6
+ // instead of executing nicobailon's real renderer (which needs a full Theme).
7
+ // vi.hoisted is required because vi.mock factories are top-hoisted and can't close
8
+ // over file-level consts.
9
+ const { renderSubagentResultMock } = vi.hoisted(() => ({
10
+ renderSubagentResultMock: vi.fn(() => ({ __sentinel: "full-render" }) as unknown),
11
+ }));
12
+ vi.mock("pi-subagents/render", () => ({ renderSubagentResult: renderSubagentResultMock }));
13
+
14
+ import { buildQuietRenderCall, buildQuietRenderResult } from "./overlay.js";
15
+
16
+ function makeTheme(): Theme {
17
+ return {
18
+ fg: (_c: string, t: string) => t,
19
+ bold: (t: string) => t,
20
+ } as unknown as Theme;
21
+ }
22
+
23
+ describe("buildQuietRenderCall — layout-stable status trailer from first frame", () => {
24
+ it("composes original call + status trailer when no original is provided", () => {
25
+ const render = buildQuietRenderCall(undefined);
26
+ // No original renderCall → just the trailer.
27
+ const out = render({}, makeTheme(), { executionStarted: false, state: {} });
28
+ expect(out).toBeInstanceOf(Text);
29
+ });
30
+
31
+ it("emits pending glyph when !executionStarted (before markExecutionStarted)", () => {
32
+ const render = buildQuietRenderCall(undefined);
33
+ const out = render({}, makeTheme(), { executionStarted: false, state: {} }) as Text;
34
+ // Our stubbed theme.fg is identity, so Text content includes the literal glyph + label.
35
+ const text = (out as unknown as { text?: string }).text ?? "";
36
+ expect(text).toContain("○");
37
+ expect(text).toContain("pending");
38
+ });
39
+
40
+ it("emits running glyph when executionStarted === true", () => {
41
+ const render = buildQuietRenderCall(undefined);
42
+ const out = render({}, makeTheme(), { executionStarted: true, state: {} }) as Text;
43
+ const text = (out as unknown as { text?: string }).text ?? "";
44
+ expect(text).toContain("◐");
45
+ expect(text).toContain("running");
46
+ });
47
+
48
+ it("wraps original call + trailer in a Container when both are present", () => {
49
+ const originalCall = vi.fn(() => new Text("subagent peer-comparator", 0, 0));
50
+ const render = buildQuietRenderCall(originalCall);
51
+ const out = render({}, makeTheme(), { executionStarted: true, state: {} });
52
+ expect(out).toBeInstanceOf(Container);
53
+ expect(originalCall).toHaveBeenCalledOnce();
54
+ });
55
+
56
+ it("suppresses trailer once state.subagentTerminal is set (final frame)", () => {
57
+ const originalCall = vi.fn(() => new Text("subagent peer-comparator", 0, 0));
58
+ const render = buildQuietRenderCall(originalCall);
59
+ const out = render({}, makeTheme(), { executionStarted: true, state: { subagentTerminal: true } });
60
+ // Should return the original call as-is, no Container wrapping with trailer.
61
+ expect(out).toBeInstanceOf(Text);
62
+ expect(originalCall).toHaveBeenCalledOnce();
63
+ });
64
+ });
65
+
66
+ describe("buildQuietRenderResult — non-terminal stub + terminal delegation", () => {
67
+ it("returns zero-height stub while progress.status === 'running' (renderCall owns trailer)", () => {
68
+ renderSubagentResultMock.mockClear();
69
+ const render = buildQuietRenderResult();
70
+ const out = render(
71
+ { details: { results: [{ agent: "x", progress: { status: "running" } }] } },
72
+ { expanded: false, isPartial: true },
73
+ makeTheme(),
74
+ { state: {} },
75
+ );
76
+ expect(out).toBeInstanceOf(Text);
77
+ expect(renderSubagentResultMock).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("returns zero-height stub when progress is MISSING (pre-progress first frame)", () => {
81
+ renderSubagentResultMock.mockClear();
82
+ const render = buildQuietRenderResult();
83
+ const out = render({ details: { results: [{ agent: "x" }] } }, { expanded: false }, makeTheme(), { state: {} });
84
+ expect(out).toBeInstanceOf(Text);
85
+ expect(renderSubagentResultMock).not.toHaveBeenCalled();
86
+ });
87
+
88
+ it("returns zero-height stub when result.details is missing entirely", () => {
89
+ renderSubagentResultMock.mockClear();
90
+ const render = buildQuietRenderResult();
91
+ const out = render({}, { expanded: false }, makeTheme(), { state: {} });
92
+ expect(out).toBeInstanceOf(Text);
93
+ });
94
+
95
+ it("delegates to full renderer once terminal AND isPartial === false", () => {
96
+ renderSubagentResultMock.mockClear();
97
+ const state: { subagentTerminal?: boolean } = {};
98
+ const render = buildQuietRenderResult();
99
+ const out = render(
100
+ { details: { results: [{ agent: "x", exitCode: 0, progress: { status: "complete" } }] } },
101
+ { expanded: false, isPartial: false },
102
+ makeTheme(),
103
+ { state },
104
+ );
105
+ expect(renderSubagentResultMock).toHaveBeenCalledOnce();
106
+ expect((out as { __sentinel?: string }).__sentinel).toBe("full-render");
107
+ // State flag is set so renderCall suppresses its trailer next frame.
108
+ expect(state.subagentTerminal).toBe(true);
109
+ });
110
+
111
+ it("keeps stub when terminal but isPartial === true (don't commit until final)", () => {
112
+ renderSubagentResultMock.mockClear();
113
+ const render = buildQuietRenderResult();
114
+ const out = render(
115
+ { details: { results: [{ agent: "x", exitCode: 0 }] } },
116
+ { expanded: false, isPartial: true },
117
+ makeTheme(),
118
+ { state: {} },
119
+ );
120
+ expect(out).toBeInstanceOf(Text);
121
+ expect(renderSubagentResultMock).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("delegates to full renderer on error stopReason (terminal)", () => {
125
+ renderSubagentResultMock.mockClear();
126
+ const render = buildQuietRenderResult();
127
+ const out = render(
128
+ { details: { results: [{ agent: "x", stopReason: "error" }] } },
129
+ { expanded: false, isPartial: false },
130
+ makeTheme(),
131
+ { state: {} },
132
+ );
133
+ expect(renderSubagentResultMock).toHaveBeenCalledOnce();
134
+ expect((out as { __sentinel?: string }).__sentinel).toBe("full-render");
135
+ });
136
+
137
+ it("treats exitCode present + status=running as NON-terminal (streaming finalisation window)", () => {
138
+ renderSubagentResultMock.mockClear();
139
+ const render = buildQuietRenderResult();
140
+ const out = render(
141
+ { details: { results: [{ agent: "x", exitCode: 0, progress: { status: "running" } }] } },
142
+ { expanded: false, isPartial: true },
143
+ makeTheme(),
144
+ { state: {} },
145
+ );
146
+ expect(out).toBeInstanceOf(Text);
147
+ expect(renderSubagentResultMock).not.toHaveBeenCalled();
148
+ });
149
+ });