@juicesharp/rpiv-pi 0.12.2 → 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.
- package/extensions/subagent-widget/agent-catalog.test.ts +110 -0
- package/extensions/subagent-widget/agent-catalog.ts +58 -0
- package/extensions/subagent-widget/catalog-drift.test.ts +21 -0
- package/extensions/subagent-widget/constants.ts +11 -3
- package/extensions/subagent-widget/hide-builtin-subagents.test.ts +218 -0
- package/extensions/subagent-widget/hide-builtin-subagents.ts +87 -0
- package/extensions/subagent-widget/index.test.ts +48 -2
- package/extensions/subagent-widget/index.ts +19 -7
- package/extensions/subagent-widget/overlay.test.ts +149 -0
- package/extensions/subagent-widget/overlay.ts +96 -0
- package/extensions/subagent-widget/prompts/subagent-description.txt +24 -0
- package/extensions/subagent-widget/renderer-override.test.ts +172 -72
- package/extensions/subagent-widget/renderer-override.ts +73 -83
- package/extensions/subagent-widget/run-tracker.test.ts +13 -6
- package/extensions/subagent-widget/run-tracker.ts +16 -0
- package/extensions/subagent-widget/widget.render.test.ts +70 -7
- package/extensions/subagent-widget/widget.ts +22 -20
- package/package.json +3 -2
|
@@ -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
|
+
});
|
|
@@ -16,11 +16,19 @@ export const ERROR_STATUSES: ReadonlySet<ErrorStatus> = new Set<ErrorStatus>([
|
|
|
16
16
|
"stopped",
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
/** How many turns a completed run lingers before it drops from the tree.
|
|
20
|
-
|
|
19
|
+
/** How many turns a completed run lingers before it drops from the tree.
|
|
20
|
+
* Advanced by both user-input boundaries (`pi.on("input")`) and orchestrator
|
|
21
|
+
* agent-loop iterations (`pi.on("turn_start")`), so completed runs stay visible
|
|
22
|
+
* for ~3 orchestrator turns after the last agent finishes, then auto-evict. */
|
|
23
|
+
export const COMPLETED_LINGER_TURNS = 3;
|
|
21
24
|
|
|
22
25
|
/** How many turns an error/aborted/steered/stopped run lingers. */
|
|
23
|
-
export const ERROR_LINGER_TURNS =
|
|
26
|
+
export const ERROR_LINGER_TURNS = 5;
|
|
24
27
|
|
|
25
28
|
/** Spinner animation tick in ms. TUI's 16 ms render coalescing absorbs this. */
|
|
26
29
|
export const TICK_MS = 80;
|
|
30
|
+
|
|
31
|
+
/** Max visible characters of the descriptor column (task text). Applied
|
|
32
|
+
* identically to running + finished rows so the stats tail is never
|
|
33
|
+
* truncation-clipped off the right edge regardless of terminal width. */
|
|
34
|
+
export const MAX_DESCRIPTOR_CHARS = 40;
|
|
@@ -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
|
+
}
|
|
@@ -213,12 +213,13 @@ describe("subagent-widget extension factory", () => {
|
|
|
213
213
|
expect(listRuns()).toHaveLength(0);
|
|
214
214
|
});
|
|
215
215
|
|
|
216
|
-
it("input
|
|
216
|
+
it("input + turn_start both advance linger ages; evicts after COMPLETED_LINGER_TURNS boundaries", async () => {
|
|
217
217
|
const pi = makePi();
|
|
218
218
|
await initExtension(pi);
|
|
219
219
|
const startHandler = pi.handlers.get("tool_execution_start")!;
|
|
220
220
|
const endHandler = pi.handlers.get("tool_execution_end")!;
|
|
221
221
|
const inputHandler = pi.handlers.get("input")!;
|
|
222
|
+
const turnStartHandler = pi.handlers.get("turn_start")!;
|
|
222
223
|
await startHandler(
|
|
223
224
|
{
|
|
224
225
|
type: "tool_execution_start",
|
|
@@ -239,7 +240,52 @@ describe("subagent-widget extension factory", () => {
|
|
|
239
240
|
makeCtx(true),
|
|
240
241
|
);
|
|
241
242
|
expect(listRuns()).toHaveLength(1);
|
|
242
|
-
|
|
243
|
+
// COMPLETED_LINGER_TURNS = 3 — takes 3 turn boundaries to evict.
|
|
244
|
+
await turnStartHandler({ type: "turn_start" } as any, makeCtx(true)); // age 1
|
|
245
|
+
await turnStartHandler({ type: "turn_start" } as any, makeCtx(true)); // age 2
|
|
246
|
+
expect(listRuns()).toHaveLength(1);
|
|
247
|
+
await inputHandler({ type: "input", text: "next", source: "interactive" } as any, makeCtx(true)); // age 3 → evicted
|
|
243
248
|
expect(listRuns()).toHaveLength(0);
|
|
244
249
|
});
|
|
250
|
+
|
|
251
|
+
it("purges finished runs when a new wave starts (tool_execution_start with no active runs)", async () => {
|
|
252
|
+
const pi = makePi();
|
|
253
|
+
await initExtension(pi);
|
|
254
|
+
const startHandler = pi.handlers.get("tool_execution_start")!;
|
|
255
|
+
const endHandler = pi.handlers.get("tool_execution_end")!;
|
|
256
|
+
// Wave 1: dispatch + complete.
|
|
257
|
+
await startHandler(
|
|
258
|
+
{
|
|
259
|
+
type: "tool_execution_start",
|
|
260
|
+
toolCallId: "t1",
|
|
261
|
+
toolName: "subagent",
|
|
262
|
+
args: { agent: "scout", task: "x" },
|
|
263
|
+
},
|
|
264
|
+
makeCtx(true),
|
|
265
|
+
);
|
|
266
|
+
await endHandler(
|
|
267
|
+
{
|
|
268
|
+
type: "tool_execution_end",
|
|
269
|
+
toolCallId: "t1",
|
|
270
|
+
toolName: "subagent",
|
|
271
|
+
result: { details: { mode: "single", agentScope: "user", projectAgentsDir: null, results: [] } },
|
|
272
|
+
isError: false,
|
|
273
|
+
},
|
|
274
|
+
makeCtx(true),
|
|
275
|
+
);
|
|
276
|
+
expect(listRuns()).toHaveLength(1);
|
|
277
|
+
// Wave 2: new dispatch while wave 1 is still lingering → wave 1 purged first.
|
|
278
|
+
await startHandler(
|
|
279
|
+
{
|
|
280
|
+
type: "tool_execution_start",
|
|
281
|
+
toolCallId: "t2",
|
|
282
|
+
toolName: "subagent",
|
|
283
|
+
args: { agent: "worker", task: "y" },
|
|
284
|
+
},
|
|
285
|
+
makeCtx(true),
|
|
286
|
+
);
|
|
287
|
+
const runs = listRuns();
|
|
288
|
+
expect(runs).toHaveLength(1);
|
|
289
|
+
expect(runs[0].toolCallId).toBe("t2");
|
|
290
|
+
});
|
|
245
291
|
});
|
|
@@ -49,15 +49,20 @@ export default async function (pi: ExtensionAPI) {
|
|
|
49
49
|
tracker.__resetState();
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
// before the
|
|
56
|
-
//
|
|
57
|
-
|
|
52
|
+
// Turn-boundary eviction: advance linger ages on BOTH user input and
|
|
53
|
+
// orchestrator agent-loop iterations. `input` fires on user-originated
|
|
54
|
+
// messages; `turn_start` fires per agent-loop iteration (after each tool
|
|
55
|
+
// result, before the next assistant call). Together with the bumped
|
|
56
|
+
// `COMPLETED_LINGER_TURNS=3` budget in constants.ts, completed rows stay
|
|
57
|
+
// visible long enough for the user to see, then auto-evict across
|
|
58
|
+
// ~3 orchestrator turns — no more "overlay sticks around forever" when
|
|
59
|
+
// the user doesn't immediately type back.
|
|
60
|
+
const advanceTurn = () => {
|
|
58
61
|
const evicted = tracker.onTurnStart();
|
|
59
62
|
if (evicted) widget?.update();
|
|
60
|
-
}
|
|
63
|
+
};
|
|
64
|
+
pi.on("input", async () => advanceTurn());
|
|
65
|
+
pi.on("turn_start", async () => advanceTurn());
|
|
61
66
|
|
|
62
67
|
// Background dispatches (args.async === true per pi-subagents@0.17.5 schema)
|
|
63
68
|
// return a job handle in ~100ms. Tracking them produces a misleading
|
|
@@ -70,6 +75,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
70
75
|
pi.on("tool_execution_start", async (event, ctx) => {
|
|
71
76
|
if (event.toolName !== SUBAGENT_TOOL) return;
|
|
72
77
|
if (isAsyncDispatch(event.args)) return;
|
|
78
|
+
// Wave-boundary purge: if no runs are currently active and we still
|
|
79
|
+
// have finished rows lingering from a prior wave, drop them before
|
|
80
|
+
// the new run appears. Prevents "new wave appends under yesterday's
|
|
81
|
+
// ✓ lines" when waves dispatch back-to-back without a user turn.
|
|
82
|
+
if (tracker.runningCount() === 0 && tracker.hasAnyVisible()) {
|
|
83
|
+
tracker.purgeFinished();
|
|
84
|
+
}
|
|
73
85
|
tracker.onStart(event.toolCallId, event.args);
|
|
74
86
|
if (ctx.hasUI) {
|
|
75
87
|
widget ??= new SubagentWidget();
|