/** * foreground-result-retrieval.test.ts — issue #174, via the REAL Agent tool + * the REAL get_subagent_result tool. * * Report: a FOREGROUND agent that wraps up at max_turns returns its partial * result inline, but a get_subagent_result for "that agent ID" immediately * afterwards answers `Agent not found ... It may have been cleaned up.` — with * no /new, /resume or session switch in between. * * Tracing says the premise can't hold, and these tests pin both halves of why: * * 1. The record is NOT cleaned up. Foreground completion mutates the record * in place (agent-manager.ts startAgent's .then) — nothing deletes it. So * a lookup with the REAL id succeeds. * 2. The model never HAD the real id. Foreground returns the result text * only; the id travels in `details`, which is renderer metadata and never * reaches the API (only `content` is serialized). The background path is * the one that puts `Agent ID: ...` in the text. * * Together: whatever id the reporter's model passed, it wasn't one we issued, * and "not found" was the correct answer. Test 3 pins the eviction rule that * DOES apply, so the two are not confused again. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../src/agent-runner.js", async () => { const actual = await vi.importActual("../src/agent-runner.js"); return { ...actual, runAgent: vi.fn() }; }); import { runAgent } from "../src/agent-runner.js"; import subagentsExtension from "../src/index.js"; function makePi() { const tools = new Map(); const lifecycle = new Map(); const pi = { registerMessageRenderer: vi.fn(), registerTool: vi.fn((t: any) => tools.set(t.name, t)), registerCommand: vi.fn(), on: vi.fn((event: string, handler: any) => lifecycle.set(event, handler)), events: { emit: vi.fn(), on: vi.fn(() => vi.fn()), }, appendEntry: vi.fn(), sendMessage: vi.fn(), } as any; return { pi, tools, lifecycle }; } function ctx() { return { hasUI: false, ui: { setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn() }, cwd: process.cwd(), model: undefined, modelRegistry: { find: vi.fn(), getAvailable: vi.fn(() => []) }, sessionManager: { getSessionId: vi.fn(() => "s1"), getBranch: vi.fn(() => []) }, getSystemPrompt: vi.fn(() => "parent"), } as any; } const textOf = (r: any): string => r.content[0].text; /** * Run a FOREGROUND agent that wraps up at the turn limit — the exact #174 * shape. `steered: true` is what agent-manager turns into status "steered", * which is what produces the reporter's "(wrapped up at the turn limit — * output may be partial)" note. * * Returns the tool result plus the id read out of `details` — the only place * a foreground id exists, which is the point of test 2. */ async function runForegroundSteeredAgent(tools: Map) { vi.mocked(runAgent).mockResolvedValue({ responseText: "THE-RESULT-PAYLOAD", session: { dispose: vi.fn() } as any, aborted: false, steered: true, }); const res = await tools.get("Agent").execute( "tc-fg", { prompt: "Perform a very thorough read-only codebase exploration.", description: "Locate organization-scope changes", subagent_type: "Explore", max_turns: 20, run_in_background: false, }, undefined, undefined, ctx(), ); const id = (res as any).details?.agentId as string | undefined; expect(id, "foreground spawn should have produced a record id in details").toBeTruthy(); return { res, id: id as string }; } describe("issue #174: foreground agent that hits max_turns", () => { let tmpDir: string; let agentDir: string; let prevCwd: string; let prevAgentDir: string | undefined; let prevHome: string | undefined; beforeEach(() => { // Hermetic cwd + global dir, scheduling off — same isolation as // clear-completed-wiring.test.ts, so session_start doesn't spin a // scheduler or touch the dev's filesystem. tmpDir = mkdtempSync(join(tmpdir(), "pi-174-")); agentDir = mkdtempSync(join(tmpdir(), "pi-174-agentdir-")); prevAgentDir = process.env.PI_CODING_AGENT_DIR; prevHome = process.env.HOME; process.env.PI_CODING_AGENT_DIR = agentDir; process.env.HOME = agentDir; prevCwd = process.cwd(); mkdirSync(join(tmpDir, ".pi"), { recursive: true }); writeFileSync(join(tmpDir, ".pi", "subagents.json"), JSON.stringify({ schedulingEnabled: false })); process.chdir(tmpDir); }); afterEach(() => { process.chdir(prevCwd); if (prevAgentDir == null) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = prevAgentDir; if (prevHome == null) delete process.env.HOME; else process.env.HOME = prevHome; rmSync(tmpDir, { recursive: true, force: true }); rmSync(agentDir, { recursive: true, force: true }); vi.restoreAllMocks(); }); it("is NOT cleaned up — get_subagent_result with the real id still resolves it", async () => { const { pi, tools, lifecycle } = makePi(); subagentsExtension(pi); const { res, id } = await runForegroundSteeredAgent(tools); // The inline result is the turn-limit wrap-up the reporter described. expect(textOf(res)).toContain("wrapped up at the turn limit"); // No /new, no /resume, no session switch — exactly the reporter's sequence. const read = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); const out = textOf(read); expect(out).not.toContain("Agent not found"); expect(out).toContain("THE-RESULT-PAYLOAD"); await lifecycle.get("session_shutdown")?.({}, ctx()); }); it("never hands the model an agent id — the id lives only in renderer details", async () => { const { pi, tools, lifecycle } = makePi(); subagentsExtension(pi); const { res, id } = await runForegroundSteeredAgent(tools); // `content` is the only thing serialized to the API. If the id isn't here, // the model cannot have obtained it — any id it passes is invented. expect(textOf(res)).not.toContain(id); expect(textOf(res)).not.toMatch(/Agent ID:/); // An invented id is correctly rejected — this is the reporter's error, // reproduced WITHOUT any record having been cleaned up. const bogus = await tools.get("get_subagent_result").execute( "tc-bogus", { agent_id: "3f1320a7-74ec-422" }, undefined, undefined, ctx(), ); expect(textOf(bogus)).toContain("Agent not found"); await lifecycle.get("session_shutdown")?.({}, ctx()); }); it("survives a subagent session's OWN activation lifecycle (adversarial: cross-activation eviction)", async () => { // The one mechanism that could produce the reported symptom with no // user-visible session change: a second activation of this extension in the // same process. Child sessions no longer reach it — activation returns early // under `inChildSessionContext()` — but any other in-process activation still // can, and if its session_start / session_shutdown reached the PARENT's // manager, that activation ending would wipe the parent's records. const parent = makePi(); subagentsExtension(parent.pi); await parent.lifecycle.get("session_start")?.({}, ctx()); const { id } = await runForegroundSteeredAgent(parent.tools); // A child activation runs its full lifecycle, as a subagent session does. const child = makePi(); subagentsExtension(child.pi); await child.lifecycle.get("session_start")?.({}, ctx()); await child.lifecycle.get("session_shutdown")?.({}, ctx()); // The parent's record must be untouched — separate manager per activation. const read = await parent.tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); const out = textOf(read); expect(out).not.toContain("Agent not found"); expect(out).toContain("THE-RESULT-PAYLOAD"); await parent.lifecycle.get("session_shutdown")?.({}, ctx()); }); it("IS evicted by a session switch — its result was already delivered inline", async () => { const { pi, tools, lifecycle } = makePi(); subagentsExtension(pi); const { id } = await runForegroundSteeredAgent(tools); // Foreground results count as consumed the moment they're returned inline, // so clearCompleted(true)'s #108 preservation deliberately does not cover // them. This is the ONLY path that makes a foreground id stop resolving. await lifecycle.get("session_before_switch")?.(); const read = await tools.get("get_subagent_result").execute("tc-read", { agent_id: id }, undefined, undefined, ctx()); expect(textOf(read)).toContain("Agent not found"); await lifecycle.get("session_shutdown")?.({}, ctx()); }); });