295 lines
12 KiB
TypeScript
295 lines
12 KiB
TypeScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
|
|
import { patchPiSubagentsSource, stripPiSubagentBuiltinModelSource } from "../scripts/lib/pi-subagents-patch.mjs";
|
|
|
|
const CASES = [
|
|
{
|
|
name: "index.ts config path",
|
|
file: "index.ts",
|
|
input: [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const configPath = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent", "config.json");',
|
|
"",
|
|
].join("\n"),
|
|
original: 'const configPath = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent", "config.json");',
|
|
expected: 'const configPath = path.join(resolvePiAgentDir(), "extensions", "subagent", "config.json");',
|
|
},
|
|
{
|
|
name: "agents.ts user agents dir",
|
|
file: "agents.ts",
|
|
input: [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const userDir = path.join(os.homedir(), ".pi", "agent", "agents");',
|
|
"",
|
|
].join("\n"),
|
|
original: 'const userDir = path.join(os.homedir(), ".pi", "agent", "agents");',
|
|
expected: 'const userDir = path.join(resolvePiAgentDir(), "agents");',
|
|
},
|
|
{
|
|
name: "artifacts.ts sessions dir",
|
|
file: "artifacts.ts",
|
|
input: [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const sessionsBase = path.join(os.homedir(), ".pi", "agent", "sessions");',
|
|
"",
|
|
].join("\n"),
|
|
original: 'const sessionsBase = path.join(os.homedir(), ".pi", "agent", "sessions");',
|
|
expected: 'const sessionsBase = path.join(resolvePiAgentDir(), "sessions");',
|
|
},
|
|
{
|
|
name: "run-history.ts history file",
|
|
file: "run-history.ts",
|
|
input: [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const HISTORY_PATH = path.join(os.homedir(), ".pi", "agent", "run-history.jsonl");',
|
|
"",
|
|
].join("\n"),
|
|
original: 'const HISTORY_PATH = path.join(os.homedir(), ".pi", "agent", "run-history.jsonl");',
|
|
expected: 'const HISTORY_PATH = path.join(resolvePiAgentDir(), "run-history.jsonl");',
|
|
},
|
|
{
|
|
name: "skills.ts agent dir",
|
|
file: "skills.ts",
|
|
input: [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");',
|
|
"",
|
|
].join("\n"),
|
|
original: 'const AGENT_DIR = path.join(os.homedir(), ".pi", "agent");',
|
|
expected: "const AGENT_DIR = resolvePiAgentDir();",
|
|
},
|
|
{
|
|
name: "chain-clarify.ts chain save dir",
|
|
file: "chain-clarify.ts",
|
|
input: [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const dir = path.join(os.homedir(), ".pi", "agent", "agents");',
|
|
"",
|
|
].join("\n"),
|
|
original: 'const dir = path.join(os.homedir(), ".pi", "agent", "agents");',
|
|
expected: 'const dir = path.join(resolvePiAgentDir(), "agents");',
|
|
},
|
|
];
|
|
|
|
for (const scenario of CASES) {
|
|
test(`patchPiSubagentsSource rewrites ${scenario.name}`, () => {
|
|
const patched = patchPiSubagentsSource(scenario.file, scenario.input);
|
|
|
|
assert.match(patched, /function resolvePiAgentDir\(\): string \{/);
|
|
assert.match(patched, /process\.env\.FEYNMAN_CODING_AGENT_DIR\?\.trim\(\) \|\| process\.env\.PI_CODING_AGENT_DIR\?\.trim\(\)/);
|
|
assert.ok(patched.includes(scenario.expected));
|
|
assert.ok(!patched.includes(scenario.original));
|
|
});
|
|
}
|
|
|
|
test("patchPiSubagentsSource is idempotent", () => {
|
|
const input = [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'const configPath = path.join(os.homedir(), ".pi", "agent", "extensions", "subagent", "config.json");',
|
|
"",
|
|
].join("\n");
|
|
|
|
const once = patchPiSubagentsSource("index.ts", input);
|
|
const twice = patchPiSubagentsSource("index.ts", once);
|
|
|
|
assert.equal(twice, once);
|
|
});
|
|
|
|
test("patchPiSubagentsSource rewrites modern agents.ts discovery paths", () => {
|
|
const input = [
|
|
'import * as fs from "node:fs";',
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
'export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {',
|
|
'\tconst userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");',
|
|
'\tconst userDirNew = path.join(os.homedir(), ".agents");',
|
|
'\tconst userAgentsOld = scope === "project" ? [] : loadAgentsFromDir(userDirOld, "user");',
|
|
'\tconst userAgentsNew = scope === "project" ? [] : loadAgentsFromDir(userDirNew, "user");',
|
|
'\tconst userAgents = [...userAgentsOld, ...userAgentsNew];',
|
|
'}',
|
|
'export function discoverAgentsAll(cwd: string) {',
|
|
'\tconst userDirOld = path.join(os.homedir(), ".pi", "agent", "agents");',
|
|
'\tconst userDirNew = path.join(os.homedir(), ".agents");',
|
|
'\tconst user = [',
|
|
'\t\t...loadAgentsFromDir(userDirOld, "user"),',
|
|
'\t\t...loadAgentsFromDir(userDirNew, "user"),',
|
|
'\t];',
|
|
'\tconst chains = [',
|
|
'\t\t...loadChainsFromDir(userDirOld, "user"),',
|
|
'\t\t...loadChainsFromDir(userDirNew, "user"),',
|
|
'\t\t...(projectDir ? loadChainsFromDir(projectDir, "project") : []),',
|
|
'\t];',
|
|
'\tconst userDir = fs.existsSync(userDirNew) ? userDirNew : userDirOld;',
|
|
'}',
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("agents.ts", input);
|
|
|
|
assert.match(patched, /function resolvePiAgentDir\(\): string \{/);
|
|
assert.match(patched, /const userDir = path\.join\(resolvePiAgentDir\(\), "agents"\);/);
|
|
assert.match(patched, /const userAgents = scope === "project" \? \[\] : loadAgentsFromDir\(userDir, "user"\);/);
|
|
assert.ok(!patched.includes('loadAgentsFromDir(userDirOld, "user")'));
|
|
assert.ok(!patched.includes('loadChainsFromDir(userDirNew, "user")'));
|
|
assert.ok(!patched.includes('fs.existsSync(userDirNew) ? userDirNew : userDirOld'));
|
|
});
|
|
|
|
test("patchPiSubagentsSource preserves output on top-level parallel tasks", () => {
|
|
const input = [
|
|
"interface TaskParam {",
|
|
"\tagent: string;",
|
|
"\ttask: string;",
|
|
"\tcwd?: string;",
|
|
"\tcount?: number;",
|
|
"\tmodel?: string;",
|
|
"\tskill?: string | string[] | boolean;",
|
|
"}",
|
|
"function run(params: { tasks: TaskParam[] }) {",
|
|
"\tconst modelOverrides = params.tasks.map(() => undefined);",
|
|
"\tconst skillOverrides = params.tasks.map(() => undefined);",
|
|
"\tconst parallelTasks = params.tasks.map((task, index) => ({",
|
|
"\t\tagent: task.agent,",
|
|
"\t\ttask: params.context === \"fork\" ? wrapForkTask(task.task) : task.task,",
|
|
"\t\tcwd: task.cwd,",
|
|
"\t\t...(modelOverrides[index] ? { model: modelOverrides[index] } : {}),",
|
|
"\t\t...(skillOverrides[index] !== undefined ? { skill: skillOverrides[index] } : {}),",
|
|
"\t}));",
|
|
"}",
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("subagent-executor.ts", input);
|
|
|
|
assert.match(patched, /output\?: string \| false;/);
|
|
assert.match(patched, /\n\t\toutput: task\.output,/);
|
|
assert.doesNotMatch(patched, /resolvePiAgentDir/);
|
|
});
|
|
|
|
test("patchPiSubagentsSource preserves output in async parallel task handoff", () => {
|
|
const input = [
|
|
"function run(tasks: TaskParam[]) {",
|
|
"\tconst modelOverrides = tasks.map(() => undefined);",
|
|
"\tconst skillOverrides = tasks.map(() => undefined);",
|
|
"\tconst parallelTasks = tasks.map((t, i) => ({",
|
|
"\t\tagent: t.agent,",
|
|
"\t\ttask: params.context === \"fork\" ? wrapForkTask(taskTexts[i]!) : taskTexts[i]!,",
|
|
"\t\tcwd: t.cwd,",
|
|
"\t\t...(modelOverrides[i] ? { model: modelOverrides[i] } : {}),",
|
|
"\t\t...(skillOverrides[i] !== undefined ? { skill: skillOverrides[i] } : {}),",
|
|
"\t}));",
|
|
"}",
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("subagent-executor.ts", input);
|
|
|
|
assert.match(patched, /\n\t\toutput: t\.output,/);
|
|
});
|
|
|
|
test("patchPiSubagentsSource uses task output when resolving foreground parallel behavior", () => {
|
|
const input = [
|
|
"async function run(tasks: TaskParam[]) {",
|
|
"\tconst skillOverrides = tasks.map((t) => normalizeSkillInput(t.skill));",
|
|
"\tif (params.clarify === true && ctx.hasUI) {",
|
|
"\t\tconst behaviors = agentConfigs.map((c, i) =>",
|
|
"\t\t\tresolveStepBehavior(c, { skills: skillOverrides[i] }),",
|
|
"\t\t);",
|
|
"\t}",
|
|
"\tconst behaviors = agentConfigs.map((config) => resolveStepBehavior(config, {}));",
|
|
"}",
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("subagent-executor.ts", input);
|
|
|
|
assert.match(patched, /resolveStepBehavior\(c, \{ output: tasks\[i\]\?\.output, skills: skillOverrides\[i\] \}\)/);
|
|
assert.match(patched, /resolveStepBehavior\(config, \{ output: tasks\[i\]\?\.output, skills: skillOverrides\[i\] \}\)/);
|
|
assert.doesNotMatch(patched, /resolveStepBehavior\(config, \{\}\)/);
|
|
});
|
|
|
|
test("patchPiSubagentsSource passes foreground parallel output paths into runSync", () => {
|
|
const input = [
|
|
"async function runForegroundParallelTasks(input: ForegroundParallelRunInput): Promise<SingleResult[]> {",
|
|
"\treturn mapConcurrent(input.tasks, input.concurrencyLimit, async (task, index) => {",
|
|
"\t\tconst overrideSkills = input.skillOverrides[index];",
|
|
"\t\tconst effectiveSkills = overrideSkills === undefined ? input.behaviors[index]?.skills : overrideSkills;",
|
|
"\t\tconst taskCwd = resolveParallelTaskCwd(task, input.paramsCwd, input.worktreeSetup, index);",
|
|
"\t\treturn runSync(input.ctx.cwd, input.agents, task.agent, input.taskTexts[index]!, {",
|
|
"\t\t\tcwd: taskCwd,",
|
|
"\t\t\tsignal: input.signal,",
|
|
"\t\t\tmaxOutput: input.maxOutput,",
|
|
"\t\t\tmaxSubagentDepth: input.maxSubagentDepths[index],",
|
|
"\t\t});",
|
|
"\t});",
|
|
"}",
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("subagent-executor.ts", input);
|
|
|
|
assert.match(patched, /const outputPath = typeof input\.behaviors\[index\]\?\.output === "string"/);
|
|
assert.match(patched, /const taskText = injectSingleOutputInstruction\(input\.taskTexts\[index\]!, outputPath\)/);
|
|
assert.match(patched, /runSync\(input\.ctx\.cwd, input\.agents, task\.agent, taskText, \{/);
|
|
assert.match(patched, /\n\t\t\toutputPath,/);
|
|
});
|
|
|
|
test("patchPiSubagentsSource documents output in top-level task schema", () => {
|
|
const input = [
|
|
"export const TaskItem = Type.Object({ ",
|
|
"\tagent: Type.String(), ",
|
|
"\ttask: Type.String(), ",
|
|
"\tcwd: Type.Optional(Type.String()),",
|
|
"\tcount: Type.Optional(Type.Integer({ minimum: 1, description: \"Repeat this parallel task N times with the same settings.\" })),",
|
|
"\tmodel: Type.Optional(Type.String({ description: \"Override model for this task (e.g. 'google/gemini-3-pro')\" })),",
|
|
"\tskill: Type.Optional(SkillOverride),",
|
|
"});",
|
|
"export const SubagentParams = Type.Object({",
|
|
"\ttasks: Type.Optional(Type.Array(TaskItem, { description: \"PARALLEL mode: [{agent, task, count?}, ...]\" })),",
|
|
"});",
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("schemas.ts", input);
|
|
|
|
assert.match(patched, /output: Type\.Optional\(Type\.Any/);
|
|
assert.match(patched, /count\?, output\?/);
|
|
assert.doesNotMatch(patched, /resolvePiAgentDir/);
|
|
});
|
|
|
|
test("patchPiSubagentsSource documents output in top-level parallel help", () => {
|
|
const input = [
|
|
'import * as os from "node:os";',
|
|
'import * as path from "node:path";',
|
|
"const help = `",
|
|
"• PARALLEL: { tasks: [{agent,task,count?}, ...], concurrency?: number, worktree?: true } - concurrent execution (worktree: isolate each task in a git worktree)",
|
|
"`;",
|
|
].join("\n");
|
|
|
|
const patched = patchPiSubagentsSource("index.ts", input);
|
|
|
|
assert.match(patched, /output\?/);
|
|
assert.match(patched, /per-task file target/);
|
|
assert.doesNotMatch(patched, /function resolvePiAgentDir/);
|
|
});
|
|
|
|
test("stripPiSubagentBuiltinModelSource removes built-in model pins", () => {
|
|
const input = [
|
|
"---",
|
|
"name: researcher",
|
|
"description: Web researcher",
|
|
"model: anthropic/claude-sonnet-4-6",
|
|
"tools: read, web_search",
|
|
"---",
|
|
"",
|
|
"Body",
|
|
].join("\n");
|
|
|
|
const patched = stripPiSubagentBuiltinModelSource(input);
|
|
|
|
assert.ok(!patched.includes("model: anthropic/claude-sonnet-4-6"));
|
|
assert.match(patched, /name: researcher/);
|
|
assert.match(patched, /tools: read, web_search/);
|
|
});
|