/**
 * agent-mention-wiring.test.ts — the `input` hook that routes `@handle message`
 * to a subagent.
 *
 * This handler sits in front of every prompt the user types, and returning
 * `handled` discards the text. So the two failures that matter are opposite:
 * claiming input that was meant for the main model (silently eating it), and
 * failing to claim a real mention (sending "@explore fix it" to the main model
 * as if it were prose). Each case below pins one side.
 *
 * Booted through the real extension so the assertions cover the actual wiring —
 * handle assignment in AgentManager, resolution, and the steer/resume split.
 */
import { unlinkSync, writeFileSync } from "node:fs";
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<typeof import("../src/agent-runner.js")>("../src/agent-runner.js");
  return { ...actual, runAgent: vi.fn(), resumeAgent: vi.fn() };
});

// The clone forks a real pi session and runs a real model turn. What this file
// pins is the wiring around it — when it is called, with what, and what happens
// when it comes back empty. mention-clone.test.ts covers the clone itself.
vi.mock("../src/mention-clone.js", () => ({ runMentionClone: vi.fn() }));

import { getDefaultMaxTurns, resumeAgent, runAgent, setDefaultMaxTurns } from "../src/agent-runner.js";
import subagentsExtension from "../src/index.js";
import { runMentionClone } from "../src/mention-clone.js";
import { ctx, flush, type Hermetic, hermeticDir, makePi, textOf } from "./helpers/boot-extension.js";

let hermetic: Hermetic | undefined;
/** The most recently booted extension, so teardown runs even when a test throws. */
let booted: Map<string, any> | undefined;

beforeEach(() => {
  vi.mocked(runAgent).mockReset();
  vi.mocked(resumeAgent).mockReset();
  vi.mocked(runMentionClone).mockReset();
  vi.mocked(runMentionClone).mockResolvedValue({ spawned: true });
});

afterEach(async () => {
  // The manager registry is a globalThis symbol claimed by the first activation
  // that finds it free and released only on shutdown. A test that throws before
  // its own shutdown would otherwise leave it pointing at a dead manager, and
  // the NEXT test's `managerRegistry()` would silently read that one instead.
  await booted?.get("session_shutdown")?.();
  delete (globalThis as any)[Symbol.for("pi-subagents:manager")];
  booted = undefined;
  hermetic?.restore();
  hermetic = undefined;
});

/** Enough of an AgentSession for the manager's and index's hooks. */
function fakeSession(overrides: Record<string, unknown> = {}) {
  return {
    steer: vi.fn().mockResolvedValue(undefined),
    dispose: vi.fn(),
    subscribe: vi.fn(() => () => {}),
    messages: [],
    getActiveToolNames: vi.fn(() => []),
    ...overrides,
  } as any;
}

/** A runAgent that never settles, so the agent stays "running". */
function heldRun(session: any) {
  vi.mocked(runAgent).mockImplementation(
    (_ctx: any, _type: any, _prompt: any, opts: any) =>
      new Promise(() => {
        opts.onSessionCreated?.(session);
      }) as any,
  );
}

/** A runAgent that finishes immediately, leaving a resumable record behind. */
function finishedRun(session: any) {
  vi.mocked(runAgent).mockResolvedValue({
    responseText: "first answer",
    session,
    aborted: false,
    steered: false,
    failure: undefined,
  } as any);
}

/** Boot the real extension. `outputTranscript: false` keeps the run off disk. */
function boot(settings: Record<string, unknown> = {}) {
  hermetic = hermeticDir({ settings: { outputTranscript: false, ...settings } });
  const b = makePi();
  subagentsExtension(b.pi);
  booted = b.lifecycle;
  return b;
}

/**
 * Boot with a mention that names a not-yet-running agent starting it HERE
 * rather than through a clone of the conversation. Messaging and resuming are
 * direct in either mode, so only the tests that START an agent by mention need
 * this — every other `boot()` below is on the default `model` mode.
 */
function bootDirect(settings: Record<string, unknown> = {}) {
  return boot({ agentMentions: "direct", ...settings });
}

async function spawnBackground(tools: Map<string, any>, subagent_type = "Explore"): Promise<string> {
  const r = await tools.get("Agent").execute(
    "tc-spawn",
    { prompt: "go", description: "find flaky tests", subagent_type, run_in_background: true },
    undefined,
    undefined,
    ctx(),
  );
  return /Agent ID: (\S+)/.exec(textOf(r))![1];
}

const send = (lifecycle: Map<string, any>, text: string, source = "interactive") =>
  lifecycle.get("input")({ type: "input", text, source }, ctx());

describe("messaging a running agent", () => {
  it("steers it, announces it, and spends no main-model turn", async () => {
    const { pi, tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    const uiCtx = ctx();
    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore also check the RPC path", source: "interactive" },
      uiCtx,
    );

    expect(result).toEqual({ action: "handled" });
    expect(session.steer).toHaveBeenCalledWith("also check the RPC path");
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Sent to @explore", "info");
    expect(pi.sendMessage).not.toHaveBeenCalled();

  });

  it("un-consumes the result so the agent's reply is still relayed", async () => {
    // get_subagent_result may already have read this agent's last answer, which
    // suppresses its completion notification. Without the reset, the reply to
    // the message just sent would never reach the main loop.
    const { tools, lifecycle } = boot();
    heldRun(fakeSession());

    const id = await spawnBackground(tools);
    await flush();
    const record = (globalThis as any)[Symbol.for("pi-subagents:manager")].getRecord(id);
    record.resultConsumed = true;

    await send(lifecycle, "@explore keep going");

    expect(record.resultConsumed).toBe(false);

  });

  it("addresses same-type siblings by their numbered handles", async () => {
    const { tools, lifecycle } = boot();
    const first = fakeSession();
    const second = fakeSession();
    vi.mocked(runAgent)
      .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(first)) as any)
      .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(second)) as any);

    await spawnBackground(tools);
    await spawnBackground(tools);
    await flush();

    await send(lifecycle, "@explore-2 you take the second half");

    expect(second.steer).toHaveBeenCalledWith("you take the second half");
    expect(first.steer).not.toHaveBeenCalled();

  });
});

describe("messaging a finished agent", () => {
  it("resumes it from its session in the background", async () => {
    const { lifecycle, tools } = boot();
    const session = fakeSession();
    finishedRun(session);
    vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any);

    await spawnBackground(tools);
    await flush();

    const uiCtx = ctx();
    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore anything else?", source: "interactive" },
      uiCtx,
    );

    expect(result).toEqual({ action: "handled" });
    expect(resumeAgent).toHaveBeenCalledWith(session, "anything else?", expect.anything());
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Resuming @explore", "info");

  });

  it("honours the agent's output_transcript: false when resuming", async () => {
    // The frontmatter flag overrides the project default (README, Persistent
    // settings), and record.outputFile is the sole gate every downstream
    // consumer keys off — so a resume must not be the path that re-enables a
    // transcript the agent's author switched off.
    hermetic = hermeticDir({
      settings: { outputTranscript: true },
      agentFiles: { quiet: "---\ndescription: writes no transcript\noutput_transcript: false\n---\nbody" },
    });
    const b = makePi();
    subagentsExtension(b.pi);
    booted = b.lifecycle;
    finishedRun(fakeSession());
    vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any);

    const id = await spawnBackground(b.tools, "quiet");
    await flush();
    const record = b.pi.__manager?.getRecord?.(id)
      ?? (globalThis as any)[Symbol.for("pi-subagents:manager")].getRecord(id);
    expect(record.outputFile).toBeUndefined(); // spawn honoured it

    await send(b.lifecycle, "@quiet anything else?");

    expect(record.outputFile).toBeUndefined();

  });

  it("does not attribute the new answer to the tool call that spawned it", async () => {
    // The completion notification carries `<tool-use-id>`. A mention-resume has
    // no tool call behind it, so leaving the spawning call's id on the record
    // would point the orchestrator's new result at a call answered runs ago.
    const { pi, lifecycle, tools } = boot();
    finishedRun(fakeSession());
    vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any);

    await spawnBackground(tools);
    await flush();
    vi.mocked(pi.sendMessage).mockClear();

    await send(lifecycle, "@explore anything else?");
    await new Promise(r => setTimeout(r, 400));

    const [message] = vi.mocked(pi.sendMessage).mock.calls[0];
    expect(message.content).toContain("second answer");
    expect(message.content).not.toContain("<tool-use-id>");

  });

  it("relays the resumed answer through the ordinary completion notification", async () => {
    // The whole point of resuming in the background rather than inline: the
    // main model has to be told the answer came back, or the reply is stranded
    // in the agent's transcript.
    const { pi, lifecycle, tools } = boot();
    finishedRun(fakeSession());
    vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any);

    await spawnBackground(tools);
    await flush();
    vi.mocked(pi.sendMessage).mockClear();

    await send(lifecycle, "@explore anything else?");
    await new Promise(r => setTimeout(r, 400));

    expect(pi.sendMessage).toHaveBeenCalledWith(
      expect.objectContaining({ customType: "subagent-notification", content: expect.stringContaining("second answer") }),
      expect.objectContaining({ triggerTurn: true }),
    );

  });
});

describe("stacking the suggestion provider on pi's", () => {
  /** A session_start ctx with the UI surface the registration path touches. */
  const uiCtx = (mode: string) =>
    ctx({
      mode,
      hasUI: mode !== "print",
      ui: {
        setStatus: vi.fn(),
        setWidget: vi.fn(),
        notify: vi.fn(),
        onTerminalInput: vi.fn(() => vi.fn()),
        addAutocompleteProvider: vi.fn(),
      },
    });

  it("registers exactly once, however often session_start fires", async () => {
    // pi appends wrappers to a list it never prunes, so a second registration
    // would layer a duplicate provider on top of the first.
    const { lifecycle } = boot();
    const first = uiCtx("tui");
    const second = uiCtx("tui");

    await lifecycle.get("session_start")({ type: "session_start" }, first);
    await lifecycle.get("session_start")({ type: "session_start" }, second);

    expect(first.ui.addAutocompleteProvider).toHaveBeenCalledTimes(1);
    expect(second.ui.addAutocompleteProvider).not.toHaveBeenCalled();

  });

  it("stays out of non-TUI modes, which have no editor to complete into", async () => {
    const { lifecycle } = boot();
    const rpc = uiCtx("rpc");

    await lifecycle.get("session_start")({ type: "session_start" }, rpc);

    expect(rpc.ui.addAutocompleteProvider).not.toHaveBeenCalled();

  });

  it("hands pi a provider that answers a live handle", async () => {
    const { tools, lifecycle } = boot();
    heldRun(fakeSession());
    const tui = uiCtx("tui");

    await lifecycle.get("session_start")({ type: "session_start" }, tui);
    await spawnBackground(tools);
    await flush();

    const factory = vi.mocked(tui.ui.addAutocompleteProvider).mock.calls[0][0] as any;
    const provider = factory({ getSuggestions: vi.fn().mockResolvedValue(null), applyCompletion: vi.fn() });
    const result = await provider.getSuggestions(["@ex"], 0, 3, { signal: new AbortController().signal });

    expect(result.items.map((i: any) => i.value)).toEqual(["@explore"]);

  });
});

describe("resolving which agent a handle means", () => {
  const managerRegistry = () => (globalThis as any)[Symbol.for("pi-subagents:manager")];

  it("matches the handle case-insensitively", async () => {
    // The popup lowercases as you type, but nothing stops you typing it out.
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@EXPLORE shout")).toEqual({ action: "handled" });
    expect(session.steer).toHaveBeenCalledWith("shout");

  });

  it("accepts the raw agent id, which the README offers as a fallback", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    const id = await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, `@${id} by id`)).toEqual({ action: "handled" });
    expect(session.steer).toHaveBeenCalledWith("by id");

  });

  it("queues the message for an agent still waiting on a concurrency slot", async () => {
    // A queued agent has no session yet, so the manager parks the message and
    // flushes it on session creation. Reporting "sent" without that would be a
    // lie the user only discovers when the agent ignores them.
    const { tools, lifecycle } = boot({ maxConcurrent: 1 });
    heldRun(fakeSession());

    await spawnBackground(tools);
    const queuedId = await spawnBackground(tools);
    await flush();

    const queued = managerRegistry().getRecord(queuedId);
    expect(queued.status).toBe("queued");

    expect(await send(lifecycle, "@explore-2 wait for me")).toEqual({ action: "handled" });
    expect(queued.pendingSteers).toEqual(["wait for me"]);

  });

  it("never reaches into a nested child, and starts a top-level agent instead", async () => {
    // Nested agents are hidden from every top-level surface and only their
    // owner may steer them. The handle still resolves — to a NEW top-level
    // Explore — rather than punching through the ownership boundary.
    const { tools, lifecycle } = bootDirect();
    const child = fakeSession();
    heldRun(child);

    const id = await spawnBackground(tools);
    await flush();
    managerRegistry().getRecord(id).parentAgentId = "some-parent";
    vi.mocked(runAgent).mockClear();

    expect(await send(lifecycle, "@explore reach into a child")).toEqual({ action: "handled" });
    expect(child.steer).not.toHaveBeenCalled();
    expect(runAgent).toHaveBeenCalledWith(
      expect.anything(), "Explore", "reach into a child", expect.anything(),
    );
  });

  it("starts a fresh agent once the old record has been evicted", async () => {
    // README: handles live as long as their record, and after that the same
    // mention starts a new agent rather than resurrecting anything.
    const { tools, lifecycle } = bootDirect();
    finishedRun(fakeSession());

    const id = await spawnBackground(tools);
    await flush();
    managerRegistry().getRecord(id).resultConsumed = true;
    await lifecycle.get("session_before_switch")();
    expect(managerRegistry().getRecord(id)).toBeUndefined();

    vi.mocked(resumeAgent).mockClear();
    vi.mocked(runAgent).mockClear();
    heldRun(fakeSession());

    await send(lifecycle, "@explore start over");

    expect(resumeAgent).not.toHaveBeenCalled();
    expect(runAgent).toHaveBeenCalledWith(expect.anything(), "Explore", "start over", expect.anything());

  });
});

describe("mentioning an agent that has never run", () => {
  it("starts one, using the message as its prompt", async () => {
    const { lifecycle } = bootDirect();
    heldRun(fakeSession());

    const uiCtx = ctx();
    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore find every retry marker", source: "interactive" },
      uiCtx,
    );

    expect(result).toEqual({ action: "handled" });
    expect(runAgent).toHaveBeenCalledWith(
      expect.anything(),
      "Explore",
      "find every retry marker",
      expect.anything(),
    );
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Started @explore", "info");

  });

  it("leaves model, thinking and max turns to the agent's own config", async () => {
    // runAgent resolves all three from the config when the spawn omits them,
    // so passing anything here would override frontmatter the user wrote.
    const { lifecycle } = bootDirect();
    heldRun(fakeSession());

    await send(lifecycle, "@explore go");

    const opts = vi.mocked(runAgent).mock.calls[0][3] as any;
    expect(opts.model).toBeUndefined();
    expect(opts.thinkingLevel).toBeUndefined();
    expect(opts.maxTurns).toBeUndefined();

  });

  it("shows the turn limit it will actually be held to (#181)", async () => {
    // The spawn passes no maxTurns on purpose (see the test above), so the
    // tracker has to resolve the same limit runAgent will enforce — otherwise
    // the row reads `↻1` where the Agent tool would read `↻1≤9`.
    const prevMax = getDefaultMaxTurns();
    try {
      const { lifecycle } = bootDirect({ defaultMaxTurns: 9 });
      heldRun(fakeSession());
      let factory: any;
      const uiCtx = ctx({
        hasUI: true,
        ui: {
          setStatus: vi.fn(), notify: vi.fn(), addAutocompleteProvider: vi.fn(),
          onTerminalInput: vi.fn(() => vi.fn()), getEditorText: vi.fn(() => ""), custom: vi.fn(),
          setWidget: vi.fn((key: string, content: any) => { if (key === "agents" && content) factory = content; }),
        },
      });
      await lifecycle.get("session_start")({}, uiCtx);

      await lifecycle.get("input")({ type: "input", text: "@explore go", source: "interactive" }, uiCtx);
      await flush();

      const theme = { fg: (_c: string, t: string) => t, bold: (t: string) => t };
      const lines = factory({ terminal: { columns: 200 }, requestRender: vi.fn() }, theme).render().join("\n");
      expect(lines).toContain("≤9");
    } finally {
      setDefaultMaxTurns(prevMax);
    }
  });

  it("tracks its tool activity, so the widget shows what it is doing (#181)", async () => {
    // A mention spawn never passes through the Agent tool, which is where the
    // activity tracker is normally created. Without one the widget and
    // FleetView have no tool name and no turn count for the agent, so its row
    // reads `thinking…` from start to finish.
    const { lifecycle } = bootDirect();
    heldRun(fakeSession());

    await send(lifecycle, "@explore go");

    const opts = vi.mocked(runAgent).mock.calls[0][3] as any;
    expect(opts.onToolActivity).toBeTypeOf("function");
    expect(opts.onTurnEnd).toBeTypeOf("function");
    expect(opts.onSessionCreated).toBeTypeOf("function");
  });

  it("runs it in the background so the prompt is not blocked", async () => {
    const { lifecycle } = bootDirect();
    heldRun(fakeSession());

    await send(lifecycle, "@explore go");
    const record = (globalThis as any)[Symbol.for("pi-subagents:manager")]
      .getRecord(vi.mocked(runAgent).mock.calls[0][3].agentId);

    expect(record.isBackground).toBe(true);
    expect(record.description).toBe("go");

  });

  it("messages the running agent rather than starting a second one", async () => {
    const { lifecycle } = bootDirect();
    const session = fakeSession();
    heldRun(session);

    await send(lifecycle, "@explore first task");
    await flush();
    vi.mocked(runAgent).mockClear();

    await send(lifecycle, "@explore actually do this instead");

    expect(runAgent).not.toHaveBeenCalled();
    expect(session.steer).toHaveBeenCalledWith("actually do this instead");

  });

  it("reports a failed start instead of silently doing nothing", async () => {
    const { lifecycle } = bootDirect();
    vi.mocked(runAgent).mockImplementation(() => {
      throw new Error("worktree unavailable");
    });

    const uiCtx = ctx();
    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore go", source: "interactive" },
      uiCtx,
    );

    expect(result).toEqual({ action: "handled" });
    expect(uiCtx.ui.notify).toHaveBeenCalledWith(
      expect.stringContaining("Could not start @explore"),
      "error",
    );

  });
});

describe("letting a clone of the conversation start the agent", () => {
  /** The clone stands in for a real forked session; mention-clone.test.ts covers the real one. */
  function cloneReturns(result: { spawned: boolean; error?: string }) {
    vi.mocked(runMentionClone).mockResolvedValue(result);
  }

  it("claims the turn, so nothing about the mention reaches the chat", async () => {
    // The whole point of the clone: the model still decides how to invoke the
    // agent, but it does so somewhere the user is not reading.
    const { pi, lifecycle } = boot();
    cloneReturns({ spawned: true });

    const result = await send(lifecycle, "@explore find the flaky test");
    await flush();

    expect(result).toEqual({ action: "handled" });
    expect(pi.sendMessage).not.toHaveBeenCalled();
    expect(runAgent).not.toHaveBeenCalled();
  });

  it("hands the clone the resolved type, the message and the real Agent tool", async () => {
    // The type, not the handle as typed — `subagent_type` is not lowercased.
    // The tool must be the registered one, or the clone's spawn would be a
    // second implementation rather than an ordinary top-level call.
    const { tools, lifecycle } = boot();
    cloneReturns({ spawned: true });

    await send(lifecycle, "@plan sketch the migration");
    await flush();

    expect(runMentionClone).toHaveBeenCalledWith(
      expect.objectContaining({
        type: "Plan",
        message: "sketch the migration",
        agentTool: tools.get("Agent"),
      }),
    );
  });

  it("does not block the prompt on the clone's turn", async () => {
    // prompt() is suspended until this hook returns, so awaiting a full model
    // turn here would freeze the editor for its duration.
    const { lifecycle } = boot();
    let release: (() => void) | undefined;
    vi.mocked(runMentionClone).mockReturnValue(
      new Promise((resolve) => {
        release = () => resolve({ spawned: true });
      }),
    );

    const result = await send(lifecycle, "@explore find the flaky test");

    expect(result).toEqual({ action: "handled" });
    release?.();
  });

  it("says the agent is being prompted, not started — nothing runs yet", async () => {
    const { lifecycle } = boot();
    cloneReturns({ spawned: true });

    const uiCtx = ctx();
    await lifecycle.get("input")(
      { type: "input", text: "@explore find the flaky test", source: "interactive" },
      uiCtx,
    );

    // The clone's turn happens first, so the agent does not exist yet. `direct`
    // mode's "Started @explore" is the contrast: there, it does.
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Prompting @explore…", "info");
  });

  it("starts the agent directly when the clone cannot", async () => {
    // A mention that produced a toast and no agent would be the worst outcome:
    // silent, and indistinguishable from success.
    const { lifecycle } = boot();
    heldRun(fakeSession());
    cloneReturns({ spawned: false, error: "no session file" });

    const uiCtx = ctx();
    await lifecycle.get("input")(
      { type: "input", text: "@explore find the flaky test", source: "interactive" },
      uiCtx,
    );
    await flush();

    expect(runAgent).toHaveBeenCalled();
    expect(uiCtx.ui.notify).toHaveBeenCalledWith(
      "Started @explore directly — no session file",
      "warning",
    );
  });

  it("reports a fallback that also fails rather than going quiet", async () => {
    const { lifecycle } = boot();
    vi.mocked(runAgent).mockImplementation(() => {
      throw new Error("worktree unavailable");
    });
    cloneReturns({ spawned: false, error: "no session file" });

    const uiCtx = ctx();
    await lifecycle.get("input")(
      { type: "input", text: "@explore find the flaky test", source: "interactive" },
      uiCtx,
    );
    await flush();

    expect(uiCtx.ui.notify).toHaveBeenCalledWith(
      expect.stringContaining("Could not start @explore"),
      "error",
    );
  });

  it("still steers a running agent directly, without cloning anything", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@explore also check the RPC path")).toEqual({ action: "handled" });
    expect(session.steer).toHaveBeenCalledWith("also check the RPC path");
    expect(runMentionClone).not.toHaveBeenCalled();
  });

  it("still resumes a finished agent directly, without cloning anything", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    finishedRun(session);
    vi.mocked(resumeAgent).mockResolvedValue({ text: "second answer", failure: undefined } as any);

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@explore anything else?")).toEqual({ action: "handled" });
    expect(resumeAgent).toHaveBeenCalledWith(session, "anything else?", expect.anything());
    expect(runMentionClone).not.toHaveBeenCalled();
  });

  it("does not clone for a handle that names no agent", async () => {
    // Otherwise `@nosuchagent hello` would spin up a conversation copy to
    // invoke an agent that does not exist, and eat the prompt doing it.
    const { lifecycle } = boot();

    expect(await send(lifecycle, "@nosuchagent hello")).toEqual({ action: "continue" });
    expect(runMentionClone).not.toHaveBeenCalled();
  });

  it("works headlessly, where the visible-turn version never could", async () => {
    // The TUI-only guard exists because a claimed turn answers `pi -p` with
    // silence. The clone starts a real agent whose completion still reports.
    const { lifecycle } = boot();
    cloneReturns({ spawned: true });

    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore find the flaky test", source: "interactive" },
      ctx({ mode: "print" }),
    );
    await flush();

    expect(result).toEqual({ action: "handled" });
    expect(runMentionClone).toHaveBeenCalled();
  });

  it("leaves a running agent alone headlessly rather than starting a rival", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore also check the RPC path", source: "interactive" },
      ctx({ mode: "print" }),
    );

    expect(result).toEqual({ action: "continue" });
    expect(session.steer).not.toHaveBeenCalled();
    expect(runMentionClone).not.toHaveBeenCalled();
  });

  it("clones nothing when mentions are off", async () => {
    const { lifecycle } = boot({ agentMentions: "off" });

    expect(await send(lifecycle, "@explore find the flaky test")).toEqual({ action: "continue" });
    expect(runMentionClone).not.toHaveBeenCalled();
  });

  it("starts the agent here instead when the mode is direct", async () => {
    const { lifecycle } = bootDirect();
    heldRun(fakeSession());

    expect(await send(lifecycle, "@explore find the flaky test")).toEqual({ action: "handled" });
    expect(runAgent).toHaveBeenCalled();
    expect(runMentionClone).not.toHaveBeenCalled();
  });
});

describe("input that is not a mention", () => {
  it("passes an unknown handle to the main model rather than eating it", async () => {
    const { lifecycle } = boot();

    expect(await send(lifecycle, "@nosuchagent hello")).toEqual({ action: "continue" });
  });

  it("passes a bare handle to the main model", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@explore")).toEqual({ action: "continue" });
    expect(session.steer).not.toHaveBeenCalled();

  });

  it("leaves a leading file attachment alone", async () => {
    const { tools, lifecycle } = boot();
    heldRun(fakeSession());

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@src/index.ts summarize this")).toEqual({ action: "continue" });

  });

  it("ignores input the extension layer submitted", async () => {
    // pi.sendMessage text arrives through the same hook; a notification that
    // happened to start with @something must not be re-routed at an agent.
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@explore relayed text", "extension")).toEqual({ action: "continue" });
    expect(session.steer).not.toHaveBeenCalled();

  });

  it("leaves a headless prompt to the main model", async () => {
    // Pi defaults session.prompt() to source "interactive", so `pi -p "@explore
    // …"` lands in this hook too. Claiming it there would answer with silence:
    // the agent detaches, notify is a no-op outside the TUI, and print mode
    // exits having printed nothing. Only `direct` has that problem — the model
    // mode's headless behaviour is pinned separately below.
    const { lifecycle } = bootDirect();
    heldRun(fakeSession());

    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore go", source: "interactive" },
      ctx({ mode: "print" }),
    );

    expect(result).toEqual({ action: "continue" });
    expect(runAgent).not.toHaveBeenCalled();

  });

  it("leaves an RPC-driven prompt to the main model", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore also check this", source: "rpc" },
      ctx({ mode: "rpc" }),
    );

    expect(result).toEqual({ action: "continue" });
    expect(session.steer).not.toHaveBeenCalled();

  });

  it("falls through entirely when mentions are disabled", async () => {
    const { tools, lifecycle } = boot({ agentMentions: false });
    const session = fakeSession();
    heldRun(session);

    await spawnBackground(tools);
    await flush();

    expect(await send(lifecycle, "@explore do this")).toEqual({ action: "continue" });
    expect(session.steer).not.toHaveBeenCalled();

  });

  it("disabled also blocks starting an agent, not just messaging one", async () => {
    // The guard sits ahead of the parse, so every action is covered — but the
    // start branch is the one that would otherwise spawn work nobody asked for.
    const { lifecycle } = boot({ agentMentions: false });
    heldRun(fakeSession());

    expect(await send(lifecycle, "@explore go")).toEqual({ action: "continue" });
    expect(runAgent).not.toHaveBeenCalled();

  });

  it("disabled also blocks resuming a finished agent", async () => {
    const { tools, lifecycle } = boot({ agentMentions: false });
    finishedRun(fakeSession());

    await spawnBackground(tools);
    await flush();
    vi.mocked(resumeAgent).mockClear();

    expect(await send(lifecycle, "@explore anything else?")).toEqual({ action: "continue" });
    expect(resumeAgent).not.toHaveBeenCalled();

  });

  it("the suggestion popup goes quiet too, so @ means only 'attach a file'", async () => {
    const { tools, lifecycle } = boot({ agentMentions: false });
    heldRun(fakeSession());
    const tui = ctx({
      mode: "tui",
      hasUI: true,
      ui: {
        setStatus: vi.fn(), setWidget: vi.fn(), notify: vi.fn(),
        onTerminalInput: vi.fn(() => vi.fn()), addAutocompleteProvider: vi.fn(),
      },
    });

    await lifecycle.get("session_start")({ type: "session_start" }, tui);
    await spawnBackground(tools);
    await flush();

    const factory = vi.mocked(tui.ui.addAutocompleteProvider).mock.calls[0][0] as any;
    const files = { items: [{ value: "@src/x.ts", label: "src/x.ts" }], prefix: "@ex" };
    const provider = factory({ getSuggestions: vi.fn().mockResolvedValue(files), applyCompletion: vi.fn() });

    expect(await provider.getSuggestions(["@ex"], 0, 3, { signal: new AbortController().signal })).toBe(files);

  });
});

describe("@main — the escape hatch", () => {
  it("strips the prefix and sends the rest to the main model", async () => {
    // Without this there is no way to type text that merely *looks* like a
    // mention. `transform`, not `handled`: the model must still get the turn.
    const { lifecycle } = boot();

    const result = await send(lifecycle, "@main @explore is not a mention");

    expect(result).toEqual({ action: "transform", text: "@explore is not a mention" });
  });

  it("carries attachments through with the text", async () => {
    const { lifecycle } = boot();
    const images = [{ type: "image", data: "x" }] as any;

    const result = await lifecycle.get("input")(
      { type: "input", text: "@main look at this", source: "interactive", images },
      ctx(),
    );

    expect(result).toEqual({ action: "transform", text: "look at this", images });
  });

  it("never starts an agent, even when a type would slug to main", async () => {
    const { pi, lifecycle } = boot();

    const result = await send(lifecycle, "@main do the thing");

    expect(result).toMatchObject({ action: "transform" });
    expect(vi.mocked(runAgent)).not.toHaveBeenCalled();
    expect(pi.sendMessage).not.toHaveBeenCalled();
  });

  it("leaves a bare @main to the main model untouched", async () => {
    // No message body is not a mention at all, reserved handle or not.
    const { lifecycle } = boot();

    expect(await send(lifecycle, "@main")).toEqual({ action: "continue" });
  });
});

describe("@agent-<type> — Claude Code's manual spelling", () => {
  it("starts the agent the unprefixed handle would have", async () => {
    const { lifecycle } = bootDirect();
    finishedRun(fakeSession());

    const result = await send(lifecycle, "@agent-explore find the flaky test");
    await flush();

    expect(result).toEqual({ action: "handled" });
    expect(vi.mocked(runAgent)).toHaveBeenCalledWith(
      expect.anything(), "Explore", "find the flaky test", expect.anything(),
    );
  });

  it("reaches a running agent, not a second copy of it", async () => {
    const { tools, lifecycle } = boot();
    const session = fakeSession();
    heldRun(session);
    await spawnBackground(tools);
    await flush();

    await send(lifecycle, "@agent-explore also check the RPC path");

    expect(session.steer).toHaveBeenCalledWith("also check the RPC path");
  });

  it("prefers an agent literally named agent-<x> over the unwrapped spelling", async () => {
    // Both could answer `@agent-explore`. The literal name has to win, or an
    // agent the model deliberately called `agent-explore` is unreachable.
    const { tools, lifecycle } = boot();
    const literal = fakeSession();
    const plain = fakeSession();
    vi.mocked(runAgent)
      .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(plain)) as any)
      .mockImplementationOnce((_c: any, _t: any, _p: any, o: any) => new Promise(() => o.onSessionCreated?.(literal)) as any);

    await spawnBackground(tools); // plain Explore → @explore
    await tools.get("Agent").execute(
      "tc-named",
      { prompt: "go", description: "named", subagent_type: "Plan", name: "agent-explore", run_in_background: true },
      undefined, undefined, ctx(),
    );
    await flush();

    await send(lifecycle, "@agent-explore over here");

    expect(literal.steer).toHaveBeenCalledWith("over here");
    expect(plain.steer).not.toHaveBeenCalled();
  });

  it("still falls through when nothing answers either spelling", async () => {
    const { lifecycle } = boot();

    expect(await send(lifecycle, "@agent-nosuchtype hello")).toEqual({ action: "continue" });
  });
});

describe("resuming an evicted agent by name", () => {
  // Only the GC interval and Date are faked: `flush()` runs on setImmediate,
  // and the manager's cleanup timer is started in its constructor, so the
  // fake clock has to be installed before boot().
  beforeEach(() => vi.useFakeTimers({ toFake: ["setInterval", "Date"] }));
  afterEach(() => vi.useRealTimers());

  /**
   * Age the record past the cutoff and let the real GC evict it. The session
   * file has to exist on disk: the dispatcher refuses to resume one it cannot
   * find, so a fictional path would take the "session is gone" branch and
   * every resume assertion below would pass for the wrong reason.
   */
  async function evict(id: string) {
    const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")];
    const record = manager.getRecord(id);
    record.sessionFile = sessionPath();
    writeFileSync(record.sessionFile, "");
    record.completedAt = Date.now() - 11 * 60_000;
    await vi.advanceTimersByTimeAsync(60_000);
    return manager;
  }

  /** Inside the hermetic cwd, so teardown takes it with the rest. */
  const sessionPath = () => join(process.cwd(), "explore-session.jsonl");

  it("reopens the conversation instead of starting a fresh agent", async () => {
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    const id = await spawnBackground(tools);
    await flush();
    const manager = await evict(id);
    expect(manager.getRecord(id)).toBeUndefined();
    vi.mocked(runAgent).mockClear();

    const uiCtx = ctx();
    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore anything else?", source: "interactive" },
      uiCtx,
    );
    await flush();

    expect(result).toEqual({ action: "handled" });
    expect(vi.mocked(runAgent)).toHaveBeenCalledWith(
      expect.anything(),
      "Explore",
      "anything else?",
      expect.objectContaining({ resumeSessionFile: sessionPath() }),
    );
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Resuming @explore", "info");
  });

  it("hands the resumed agent the handle back instead of numbering it", async () => {
    // Otherwise the resume lands on `@explore-2` and the tombstone keeps
    // `@explore`, so the name the user just typed still points at the corpse.
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();
    vi.mocked(runAgent).mockClear();
    heldRun(fakeSession());

    await send(lifecycle, "@explore anything else?");
    await flush();

    const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")];
    const resumedId = (vi.mocked(runAgent).mock.calls[0][3] as any).agentId;
    expect(manager.getRecord(resumedId).handle).toBe("explore");
  });

  it("stops resolving to the tombstone once the resume has taken the name", async () => {
    // The fork this prevents: every later `@explore` reopening the SAME stale
    // transcript, discarding whatever the resumed agent did in between.
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();
    vi.mocked(runAgent).mockClear();
    const resumed = fakeSession();
    heldRun(resumed);

    await send(lifecycle, "@explore anything else?");
    await flush();
    const uiCtx = ctx();
    await lifecycle.get("input")(
      { type: "input", text: "@explore and one more thing", source: "interactive" },
      uiCtx,
    );

    // Steered, not resumed again — and runAgent was called exactly once.
    expect(resumed.steer).toHaveBeenCalledWith("and one more thing");
    expect(vi.mocked(runAgent)).toHaveBeenCalledTimes(1);
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Sent to @explore", "info");
  });

  it("gives a named agent its alias back too", async () => {
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    const spawned = await tools.get("Agent").execute(
      "tc-named",
      { prompt: "audit", description: "audit the auth flow", subagent_type: "Explore", name: "auth-audit", run_in_background: true },
      undefined, undefined, ctx(),
    );
    await flush();
    await evict(/Agent ID: (\S+)/.exec(textOf(spawned))![1]);
    await flush();
    vi.mocked(runAgent).mockClear();
    heldRun(fakeSession());

    await send(lifecycle, "@auth-audit anything else?");
    await flush();

    const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")];
    const resumedId = (vi.mocked(runAgent).mock.calls[0][3] as any).agentId;
    expect(manager.getRecord(resumedId)).toMatchObject({ handle: "explore", alias: "auth-audit" });
  });

  it("refuses to reopen a conversation under a substitute agent", async () => {
    // resolveSpawnType falls back to general-purpose for a type it cannot
    // resolve (#183) — and "cannot resolve" includes merely disabled, which
    // `/agents → Disable` does at any time. Inheriting that here would reopen
    // an Explore transcript under general-purpose's prompt and tools while
    // announcing "Resuming @scout", then re-tombstone under the substitute so
    // the handle never finds its way back.
    hermetic = hermeticDir({
      settings: { outputTranscript: false },
      agentFiles: { scout: "---\ndescription: scouts\n---\nbody" },
    });
    const b = makePi();
    subagentsExtension(b.pi);
    booted = b.lifecycle;
    finishedRun(fakeSession());
    await evict(await spawnBackground(b.tools, "scout"));
    await flush();
    vi.mocked(runAgent).mockClear();
    writeFileSync(
      join(process.cwd(), ".pi", "agents", "scout.md"),
      "---\ndescription: scouts\nenabled: false\n---\nbody",
    );

    const uiCtx = ctx();
    await b.lifecycle.get("input")(
      { type: "input", text: "@scout anything else?", source: "interactive" },
      uiCtx,
    );

    expect(vi.mocked(runAgent)).not.toHaveBeenCalled();
    expect(uiCtx.ui.notify).toHaveBeenCalledWith(
      "Could not resume @scout — the scout agent is no longer available.", "warning",
    );
  });

  it("resumes again once the agent is re-enabled", async () => {
    // So the refusal keeps the tombstone: dropping it would make a temporary
    // `/agents → Disable` permanently lose the conversation.
    hermetic = hermeticDir({
      settings: { outputTranscript: false },
      agentFiles: { scout: "---\ndescription: scouts\nenabled: false\n---\nbody" },
    });
    const b = makePi();
    subagentsExtension(b.pi);
    booted = b.lifecycle;
    // Spawn while it is still enabled, then disable, mention, re-enable.
    const file = join(process.cwd(), ".pi", "agents", "scout.md");
    writeFileSync(file, "---\ndescription: scouts\n---\nbody");
    finishedRun(fakeSession());
    await evict(await spawnBackground(b.tools, "scout"));
    await flush();
    writeFileSync(file, "---\ndescription: scouts\nenabled: false\n---\nbody");
    await b.lifecycle.get("input")({ type: "input", text: "@scout hi", source: "interactive" }, ctx());

    writeFileSync(file, "---\ndescription: scouts\n---\nbody");
    vi.mocked(runAgent).mockClear();
    heldRun(fakeSession());
    const uiCtx = ctx();
    await b.lifecycle.get("input")({ type: "input", text: "@scout hi again", source: "interactive" }, uiCtx);
    await flush();

    expect(vi.mocked(runAgent)).toHaveBeenCalledWith(
      expect.anything(), "scout", "hi again",
      expect.objectContaining({ resumeSessionFile: sessionPath() }),
    );
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Resuming @scout", "info");
  });

  it("keeps the agent resolvable when the resume itself fails", async () => {
    // The type can have been deleted or disabled since the original run, and
    // resolveSpawnType then refuses it. Dropping the tombstone on that path
    // would lose the conversation for good, so the drop happens only after a
    // spawn that actually succeeded.
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();
    vi.mocked(runAgent).mockClear();
    // Stands in for any spawn-time throw: startAgent calls runAgent inline, so
    // a synchronous throw propagates back out of spawn().
    vi.mocked(runAgent).mockImplementationOnce(() => { throw new Error("Unknown agent type"); });

    const uiCtx = ctx();
    await lifecycle.get("input")(
      { type: "input", text: "@explore anything else?", source: "interactive" },
      uiCtx,
    );
    expect(uiCtx.ui.notify).toHaveBeenCalledWith(
      expect.stringContaining("Could not resume @explore"),
      "warning",
    );

    // Still reachable: try again and it reopens the same conversation.
    heldRun(fakeSession());
    await send(lifecycle, "@explore try again");
    await flush();

    expect(vi.mocked(runAgent)).toHaveBeenLastCalledWith(
      expect.anything(), "Explore", "try again",
      expect.objectContaining({ resumeSessionFile: sessionPath() }),
    );
  });

  it("keeps the original description rather than relabelling from the message", async () => {
    // The row already says what this agent is; a resume is the same work
    // continuing, not a new task.
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();
    vi.mocked(runAgent).mockClear();

    await send(lifecycle, "@explore anything else?");
    await flush();

    // runAgent receives the new record's id, which is the only handle a test
    // has on an agent the dispatcher spawned without returning anything.
    const resumedId = (vi.mocked(runAgent).mock.calls[0][3] as any).agentId;
    const manager = (globalThis as any)[Symbol.for("pi-subagents:manager")];
    expect(manager.getRecord(resumedId).description).toBe("find flaky tests");
  });

  it("does not let the tools steer or read an agent that is gone", async () => {
    // A mention can resurrect it, but there is no live record to interrupt and
    // no result to return — so the tools must say so rather than resolve to a
    // tombstone and act on stale data.
    const { tools } = boot();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();

    const steered = await tools.get("steer_subagent").execute(
      "tc", { agent_id: "explore", message: "hi" }, undefined, undefined, ctx(),
    );
    const read = await tools.get("get_subagent_result").execute(
      "tc", { agent_id: "explore" }, undefined, undefined, ctx(),
    );

    expect(textOf(steered)).toContain("Agent not found");
    expect(textOf(read)).toContain("Agent not found");
  });

  it("reports a session that is gone, rather than starting something else", async () => {
    // SessionManager.open runs inside runAgent, so a missing file rejects that
    // promise as an ordinary agent error — the dispatcher never sees it. The
    // check has to happen before the spawn, or this reports nothing at all.
    const { lifecycle, tools } = boot();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();
    vi.mocked(runAgent).mockClear();
    unlinkSync(sessionPath());

    const uiCtx = ctx();
    const result = await lifecycle.get("input")(
      { type: "input", text: "@explore anything else?", source: "interactive" },
      uiCtx,
    );

    expect(result).toEqual({ action: "handled" });
    expect(vi.mocked(runAgent)).not.toHaveBeenCalled();
    expect(uiCtx.ui.notify).toHaveBeenCalledWith(
      "Could not resume @explore — its session is gone.", "warning",
    );
  });

  it("forgets an unopenable session so the next mention starts fresh", async () => {
    // A row that can only ever fail is worse than no row: it holds the handle
    // and refuses every message sent to it.
    const { lifecycle, tools } = bootDirect();
    finishedRun(fakeSession());
    await evict(await spawnBackground(tools));
    await flush();
    unlinkSync(sessionPath());
    await send(lifecycle, "@explore anything else?");
    vi.mocked(runAgent).mockClear();
    heldRun(fakeSession());

    const uiCtx = ctx();
    await lifecycle.get("input")(
      { type: "input", text: "@explore start over", source: "interactive" },
      uiCtx,
    );
    await flush();

    expect(vi.mocked(runAgent)).toHaveBeenCalledWith(
      expect.anything(), "Explore", "start over",
      expect.not.objectContaining({ resumeSessionFile: expect.anything() }),
    );
    expect(uiCtx.ui.notify).toHaveBeenCalledWith("Started @explore", "info");
  });
});

// Claude Code's names work for the model as well as the human, because its
// `@` is backed by the same SendMessage the LLM calls. These give ours the
// same property: one name space, whoever is doing the addressing.
describe("handles as tool arguments", () => {
  it("steers by handle, not just by raw id", async () => {
    const { tools, lifecycle: _l } = boot();
    const session = fakeSession();
    heldRun(session);
    await spawnBackground(tools);
    await flush();

    const r = await tools.get("steer_subagent").execute(
      "tc", { agent_id: "explore", message: "look at the RPC path" }, undefined, undefined, ctx(),
    );

    expect(session.steer).toHaveBeenCalledWith("look at the RPC path");
    expect(textOf(r)).not.toContain("Agent not found");
  });

  it("steers by the name the model gave the agent", async () => {
    const { tools } = boot();
    const session = fakeSession();
    heldRun(session);
    await tools.get("Agent").execute(
      "tc-named",
      { prompt: "go", description: "audit", subagent_type: "Explore", name: "auth-audit", run_in_background: true },
      undefined, undefined, ctx(),
    );
    await flush();

    await tools.get("steer_subagent").execute(
      "tc", { agent_id: "auth-audit", message: "keep going" }, undefined, undefined, ctx(),
    );

    expect(session.steer).toHaveBeenCalledWith("keep going");
  });

  it("reads a result by handle", async () => {
    const { tools } = boot();
    finishedRun(fakeSession());
    await spawnBackground(tools);
    await flush();

    const r = await tools.get("get_subagent_result").execute(
      "tc", { agent_id: "explore" }, undefined, undefined, ctx(),
    );

    expect(textOf(r)).toContain("first answer");
  });

  it("still reports an unknown reference as not found", async () => {
    const { tools } = boot();

    const r = await tools.get("steer_subagent").execute(
      "tc", { agent_id: "nosuchagent", message: "hi" }, undefined, undefined, ctx(),
    );

    expect(textOf(r)).toContain("Agent not found");
  });
});
