import test from "node:test"; import assert from "node:assert/strict"; import { chmod, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import { spawn } from "node:child_process"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; function waitForExit(child: ReturnType, timeoutMs = 1500): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { child.kill("SIGKILL"); reject(new Error(`wrapper did not exit within ${timeoutMs}ms`)); }, timeoutMs); child.on("error", (error) => { clearTimeout(timeout); reject(error); }); child.on("close", (code) => { clearTimeout(timeout); resolve(code ?? 0); }); }); } async function runWrapperWithFakePi(requestedModel: string, resolvedModel?: string) { const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-")); const metaPath = join(dir, "meta.json"); const resultPath = join(dir, "result.json"); const capturePath = join(dir, "capture.json"); const piPath = join(dir, "pi"); // The fake `pi` is a small Node script that writes a JSON capture file // including relevant PI_* environment variables and the argv it received. const resolved = typeof resolvedModel === "string" ? resolvedModel : requestedModel; await writeFile( piPath, [ `#!${process.execPath}`, "const fs = require('fs');", `const capturePath = ${JSON.stringify(capturePath)};`, "const obj = {", " PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR: process.env.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR || '',", " PI_SUBAGENTS_CHILD: process.env.PI_SUBAGENTS_CHILD || '',", " argv: process.argv.slice(2)", "};", "fs.writeFileSync(capturePath, JSON.stringify(obj), 'utf8');", "console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'done'}],model:'github-copilot/gpt-4o',stopReason:'stop'}}));", ].join("\n"), "utf8", ); await chmod(piPath, 0o755); await writeFile( metaPath, JSON.stringify( { runId: "run-1", mode: "single", agent: "scout", agentSource: "builtin", task: "inspect auth", cwd: dir, requestedModel, resolvedModel: resolved, startedAt: "2026-04-09T00:00:00.000Z", sessionPath: join(dir, "child-session.jsonl"), eventsPath: join(dir, "events.jsonl"), resultPath, stdoutPath: join(dir, "stdout.log"), stderrPath: join(dir, "stderr.log"), transcriptPath: join(dir, "transcript.log"), systemPromptPath: join(dir, "system-prompt.md"), }, null, 2, ), "utf8", ); const wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "cli.mjs"); const child = spawn(process.execPath, [wrapperPath, metaPath], { env: { ...process.env, PATH: dir, }, stdio: ["ignore", "pipe", "pipe"], }); const exitCode = await waitForExit(child); assert.equal(exitCode, 0); const captureJson = JSON.parse(await readFile(capturePath, "utf8")); return { flags: captureJson }; } // Dedicated tests: every child run must have PI_SUBAGENTS_CHILD=1 test("wrapper marks github-copilot child run as a subagent child", async () => { const captured = await runWrapperWithFakePi("github-copilot/gpt-4o"); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); test("wrapper marks anthropic child run as a subagent child", async () => { const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5"); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); test("wrapper marks github-copilot child runs as agent-initiated", async () => { const captured = await runWrapperWithFakePi("github-copilot/gpt-4o"); assert.equal(captured.flags.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR, "agent"); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); test("wrapper leaves non-copilot child runs unchanged", async () => { const captured = await runWrapperWithFakePi("anthropic/claude-sonnet-4-5"); // The wrapper should not inject the copilot initiator for non-copilot models. assert.equal(captured.flags.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR, ""); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); }); // Regression test: ensure when requestedModel and resolvedModel differ, the // wrapper uses the same effective model for the child --model arg and the // copilot initiator env flag. test("wrapper uses effective model for both argv and env when requested/resolved differ", async () => { const requested = "anthropic/claude-sonnet-4-5"; const resolved = "github-copilot/gpt-4o"; const captured = await runWrapperWithFakePi(requested, resolved); // The effective model should be the resolved model in this case. assert.equal(captured.flags.PI_SUBAGENTS_GITHUB_COPILOT_INITIATOR, "agent"); assert.equal(captured.flags.PI_SUBAGENTS_CHILD, "1"); // Verify the child argv contains the effective model after a --model flag. const argv = captured.flags.argv; const modelIndex = argv.indexOf("--model"); assert.ok(modelIndex >= 0, "expected --model in argv"); assert.equal(argv[modelIndex + 1], resolved); }); test("wrapper exits and writes result.json when the pi child cannot be spawned", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-")); const metaPath = join(dir, "meta.json"); const resultPath = join(dir, "result.json"); await writeFile( metaPath, JSON.stringify( { runId: "run-1", mode: "single", agent: "scout", agentSource: "builtin", task: "inspect auth", cwd: dir, requestedModel: "anthropic/claude-sonnet-4-5", resolvedModel: "anthropic/claude-sonnet-4-5", startedAt: "2026-04-09T00:00:00.000Z", sessionPath: join(dir, "child-session.jsonl"), eventsPath: join(dir, "events.jsonl"), resultPath, stdoutPath: join(dir, "stdout.log"), stderrPath: join(dir, "stderr.log"), transcriptPath: join(dir, "transcript.log"), systemPromptPath: join(dir, "system-prompt.md"), }, null, 2, ), "utf8", ); const wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "cli.mjs"); const child = spawn(process.execPath, [wrapperPath, metaPath], { env: { ...process.env, PATH: dir, }, stdio: ["ignore", "pipe", "pipe"], }); const exitCode = await waitForExit(child); assert.equal(exitCode, 1); const result = JSON.parse(await readFile(resultPath, "utf8")); assert.equal(result.runId, "run-1"); assert.equal(result.agent, "scout"); assert.equal(result.exitCode, 1); assert.match(result.errorMessage ?? "", /ENOENT|not found|spawn pi/i); }); test("wrapper still writes result.json when transcript/stdout artifact writes fail", async () => { const dir = await mkdtemp(join(tmpdir(), "pi-subagents-wrapper-")); const metaPath = join(dir, "meta.json"); const resultPath = join(dir, "result.json"); const piPath = join(dir, "pi"); const brokenArtifactPath = join(dir, "broken-artifact"); await mkdir(brokenArtifactPath); await writeFile( piPath, [ `#!${process.execPath}`, "console.log(JSON.stringify({type:'message_end',message:{role:'assistant',content:[{type:'text',text:'done'}],model:'openai/gpt-5',stopReason:'stop'}}));", ].join("\n"), "utf8", ); await chmod(piPath, 0o755); await writeFile( metaPath, JSON.stringify( { runId: "run-1", mode: "single", agent: "scout", agentSource: "builtin", task: "inspect auth", cwd: dir, requestedModel: "openai/gpt-5", resolvedModel: "openai/gpt-5", sessionPath: join(dir, "child-session.jsonl"), eventsPath: join(dir, "events.jsonl"), resultPath, stdoutPath: brokenArtifactPath, stderrPath: join(dir, "stderr.log"), transcriptPath: brokenArtifactPath, systemPromptPath: join(dir, "system-prompt.md"), }, null, 2, ), "utf8", ); const wrapperPath = join(dirname(fileURLToPath(import.meta.url)), "cli.mjs"); const child = spawn(process.execPath, [wrapperPath, metaPath], { env: { ...process.env, PATH: dir }, stdio: ["ignore", "pipe", "pipe"], }); const exitCode = await waitForExit(child); assert.equal(exitCode, 0); const result = JSON.parse(await readFile(resultPath, "utf8")); assert.equal(result.exitCode, 0); assert.equal(result.finalText, "done"); });