diff --git a/designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md b/designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md
new file mode 100644
index 00000000..e3cc21de
--- /dev/null
+++ b/designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md
@@ -0,0 +1,91 @@
+# Multi-Agent Per-Turn QA (Fan-Out) Architecture
+
+How one `@agent`-mentioned chat turn fans out to several coding agents in
+parallel, gets summarized by the session's own agent, renders as a single
+switchable assistant message, persists losslessly, and is gated to paid users.
+Landed in PR #2628 (`v4-preview`).
+
+## What it does
+
+```mermaid
+flowchart TD
+ U["@-mention turn (e.g. '@opencode @codex compare X')"] --> G{paid?}
+ G -- no --> P[upgrade prompt, no fan-out]
+ G -- yes --> A["answerers = @-mentioned installed set
(main agent only if explicitly @-ed)"]
+ A --> AN["each answerer runs in a FRESH read-only
sub-session, in parallel, with a timeout"]
+ AN --> S["main agent ALWAYS summarizes over the answers"]
+ S --> M["one assistant message holds the live FanoutTurn
→ Summary-first segmented dropdown"]
+ M --> SAVE["persist the composite as the message body
(markdown + HTML-comment markers); no new on-disk field"]
+```
+
+Each mentioned agent answers the same question independently in an ephemeral
+read-only sub-session; the session's own agent then writes a narrative summary
+over those answers. The turn renders as one assistant message with a
+Summary-first tab row switching between the summary and each agent's full answer.
+
+## Modules
+
+| Stage | Module | Responsibility |
+| ------------------ | --------------------------------------------------------- | --------------------------------------------------------------------- |
+| Routing | `fanout/answerers.ts` | `resolveAnswerers` / `isFanout`: `@`-mentions → answerer list. Pure. |
+| Entitlement | `plusUtils.ts` | `canUseMultiAgent` (sync) + `ensureMultiAgentEntitlement` (re-check). |
+| Orchestration | `fanout/FanoutOrchestrator.ts` | Per-agent sub-sessions, timeouts, cancel, then the summary pass. |
+| Turn data + format | `fanout/fanoutTypes.ts` | Types, serialize/parse/render composite, history budgeting. |
+| Live state | `AgentMessageStore` (`setFanout`) | Holds `message.fanout`; bumps a version per streamed tick. |
+| Render | `FanoutMessageCard` + `FanoutTurnView` + `fanoutDropdown` | Segmented tabs, Summary-first, per-tab live status, 2-tier copy. |
+| Persistence | `AgentChatPersistenceManager` + serializer | The composite markdown body IS the saved form; no schema change. |
+
+## Key decisions
+
+- **Main agent summarizes, does not also answer** unless it is itself
+ `@`-mentioned. A lone `@main` mention collapses to the normal single-agent path.
+- **One message object** holds the live `FanoutTurn` (`message.fanout`, in-memory
+ only); the dropdown, whole-response copy/insert, and reload all read it.
+- **Backward-compatible persistence.** The finished turn is serialized AS the
+ message body: plain markdown with HTML-comment section markers (invisible in any
+ renderer; a marker-less body parses to `null` and renders unchanged). Literal
+ `");
+ expect(placeholder?.message).toContain("the narrative summary");
+ expect(placeholder?.message).toContain("OPENCODE_ANSWER");
+ expect(placeholder?.message).toContain("CLAUDE_ANSWER");
+ // The live turn rides on the message itself for the UI.
+ expect(placeholder?.fanout?.summary.text).toBe("the narrative summary");
+ });
+});
+
+describe("AgentSession fan-out paywall (send-boundary entitlement)", () => {
+ const mockedEnsure = ensureMultiAgentEntitlement as jest.MockedFunction<
+ typeof ensureMultiAgentEntitlement
+ >;
+ const mockedPrompt = showMultiAgentUpgradePrompt as jest.MockedFunction<
+ typeof showMultiAgentUpgradePrompt
+ >;
+
+ const fanoutRunner = () =>
+ jest.fn(async (input: FanoutRunInput): Promise => {
+ const turn: FanoutTurn = {
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "a" },
+ claude: { backendId: "claude", status: "done", text: "b" },
+ },
+ summary: { status: "done", text: "summary" },
+ };
+ input.onChange(turn);
+ return turn;
+ });
+
+ beforeEach(() => {
+ // Default state: entitled. Individual tests override as needed.
+ mockedEnsure.mockReset();
+ mockedEnsure.mockResolvedValue(true);
+ mockedPrompt.mockReset();
+ });
+
+ it("allows the fan-out for an entitled user (gate returns true) and runs the runner", async () => {
+ const mock = makeMockBackend();
+ const runFanoutTurn = fanoutRunner();
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+
+ const stopReason = await session.sendPrompt("review", undefined, undefined, [
+ "opencode",
+ "claude",
+ ]).turn;
+
+ expect(mockedEnsure).toHaveBeenCalledTimes(1);
+ expect(mockedPrompt).not.toHaveBeenCalled();
+ expect(runFanoutTurn).toHaveBeenCalledTimes(1);
+ expect(stopReason).toBe("end_turn");
+ });
+
+ it("BLOCKS the fan-out for a non-entitled user: no runner, upgrade prompt shown, turn refused", async () => {
+ mockedEnsure.mockResolvedValue(false);
+ const mock = makeMockBackend();
+ const runFanoutTurn = fanoutRunner();
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+
+ const stopReason = await session.sendPrompt("review", undefined, undefined, [
+ "opencode",
+ "claude",
+ ]).turn;
+
+ expect(mockedEnsure).toHaveBeenCalledTimes(1);
+ // Hard stop: the fan-out runner never ran, and there was NO silent
+ // single-agent fallback to backend.prompt.
+ expect(runFanoutTurn).not.toHaveBeenCalled();
+ expect(mock.prompt).not.toHaveBeenCalled();
+ // The upgrade prompt surfaced.
+ expect(mockedPrompt).toHaveBeenCalledTimes(1);
+ // The turn settled as a refusal and the session is usable again (idle), with
+ // no dangling streaming placeholder.
+ expect(stopReason).toBe("refusal");
+ expect(session.getStatus()).toBe("idle");
+
+ const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER);
+ expect(placeholder?.isErrorMessage).toBe(true);
+ expect(placeholder?.message).toContain("Copilot Plus");
+ expect(placeholder?.fanout).toBeUndefined();
+ });
+
+ it("does NOT trigger the gate for a non-fan-out (single-agent) turn", async () => {
+ const mock = makeMockBackend();
+ const runFanoutTurn = jest.fn();
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+
+ // No mentioned agents -> single-agent path; the paywall must never run.
+ await session.sendPrompt("hi").turn;
+ // Only the main agent @-ed -> collapses to single-agent; also no gate.
+ await session.sendPrompt("hi again", undefined, undefined, ["opencode"]).turn;
+
+ expect(mockedEnsure).not.toHaveBeenCalled();
+ expect(mockedPrompt).not.toHaveBeenCalled();
+ expect(runFanoutTurn).not.toHaveBeenCalled();
+ expect(mock.prompt).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("ensureMultiAgentEntitlement (paywall helper)", () => {
+ // These exercise the REAL helper against mocked isPlusEnabled/BrevilabsClient,
+ // verifying the fast path takes no network call and the slow path re-verifies.
+ const validateLicenseKey = jest.fn();
+
+ beforeEach(() => {
+ jest.resetModules();
+ validateLicenseKey.mockReset();
+ });
+
+ async function loadHelper(
+ isPlus: boolean
+ ): Promise<(app?: unknown, ctx?: Record) => Promise> {
+ jest.doMock("@/plusUtils", () => jest.requireActual("@/plusUtils"));
+ jest.doMock("@/logger", () => ({
+ logInfo: jest.fn(),
+ logWarn: jest.fn(),
+ logError: jest.fn(),
+ }));
+ jest.doMock("@/settings/model", () => ({
+ getSettings: jest.fn().mockReturnValue({ isPlusUser: isPlus, enableSelfHostMode: false }),
+ setSettings: jest.fn(),
+ updateSetting: jest.fn(),
+ useSettingsValue: jest.fn(),
+ }));
+ jest.doMock("@/LLMProviders/brevilabsClient", () => ({
+ BrevilabsClient: { getInstance: () => ({ validateLicenseKey }) },
+ }));
+ const mod = await import("@/plusUtils");
+ return mod.ensureMultiAgentEntitlement;
+ }
+
+ it("fast path: a cached Plus user is allowed with NO network call", async () => {
+ const ensure = await loadHelper(true);
+ await expect(ensure()).resolves.toBe(true);
+ expect(validateLicenseKey).not.toHaveBeenCalled();
+ });
+
+ it("slow path: a stale-false cache that the backend confirms paid is allowed", async () => {
+ validateLicenseKey.mockResolvedValue({ isValid: true });
+ const ensure = await loadHelper(false);
+ await expect(ensure()).resolves.toBe(true);
+ expect(validateLicenseKey).toHaveBeenCalledTimes(1);
+ // The feature context is forwarded for backend telemetry/upsell.
+ expect(validateLicenseKey.mock.calls[0][1]).toMatchObject({ feature: "multi_agent_per_turn" });
+ });
+
+ it("slow path: a genuinely free user is blocked (isValid false)", async () => {
+ validateLicenseKey.mockResolvedValue({ isValid: false });
+ const ensure = await loadHelper(false);
+ await expect(ensure()).resolves.toBe(false);
+ });
+});
+
+describe("AgentSession fan-out conversation history", () => {
+ /** A fan-out runner returning the given summary; captures its input prompt. */
+ const fanoutWithSummary = (summary: string) =>
+ jest.fn(async (input: FanoutRunInput): Promise => {
+ const turn: FanoutTurn = {
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "a" },
+ claude: { backendId: "claude", status: "done", text: "b" },
+ },
+ summary: { status: "done", text: summary, complete: true },
+ };
+ input.onChange(turn);
+ return turn;
+ });
+
+ const fanoutPromptText = (runFanoutTurn: jest.Mock): { type: "text"; text: string } =>
+ runFanoutTurn.mock.calls[0][0].prompt[0] as { type: "text"; text: string };
+
+ it("includes the prior transcript as a conversation_history block on a fan-out follow-up", async () => {
+ const mock = makeMockBackend();
+ // First a single-agent turn so a prior transcript exists.
+ const runFanoutTurn = fanoutWithSummary("summary");
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+
+ await session.sendPrompt("what is the master plan").turn;
+ // Simulate the assistant's prior reply landing in the transcript.
+ session.store.appendAgentText(
+ session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER)!.id,
+ "the master plan is X"
+ );
+
+ await session.sendPrompt("expand on that plan", undefined, undefined, ["opencode", "claude"])
+ .turn;
+
+ const text = fanoutPromptText(runFanoutTurn).text;
+ expect(text).toContain("");
+ expect(text).toContain("what is the master plan");
+ expect(text).toContain("the master plan is X");
+ // The current question follows the history, inside the user-message block.
+ expect(text).toContain("\nexpand on that plan\n");
+ // The current in-flight user message is NOT duplicated inside history.
+ expect((text.match(/expand on that plan/g) ?? []).length).toBe(1);
+ });
+});
+
+describe("AgentSession fan-out follow-up continuity", () => {
+ /** A fan-out runner that returns a turn with the given summary text. */
+ const fanoutWithSummary = (summary: string) =>
+ jest.fn(async (input: FanoutRunInput): Promise => {
+ const turn: FanoutTurn = {
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "a" },
+ claude: { backendId: "claude", status: "done", text: "b" },
+ },
+ summary: { status: "done", text: summary, complete: true },
+ };
+ input.onChange(turn);
+ return turn;
+ });
+
+ /**
+ * A fan-out runner whose agents answered successfully but whose summary never
+ * produced text (summary generation threw / ended empty). The persisted body
+ * must fall back to a note, never a blank bubble.
+ */
+ const fanoutAnswersNoSummary = () =>
+ jest.fn(async (input: FanoutRunInput): Promise => {
+ const turn: FanoutTurn = {
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "answer a" },
+ claude: { backendId: "claude", status: "done", text: "answer b" },
+ },
+ summary: { status: "done", text: "" },
+ };
+ input.onChange(turn);
+ return turn;
+ });
+
+ const lastPromptText = (mock: ReturnType): string => {
+ const calls = mock.prompt.mock.calls;
+ const last = calls[calls.length - 1][0] as { prompt: Array<{ text: string }> };
+ return last.prompt[0].text;
+ };
+
+ it("injects the buffered question + summary on the next single-agent turn, then clears", async () => {
+ const mock = makeMockBackend();
+ const runFanoutTurn = fanoutWithSummary("the fan-out summary");
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+
+ await session.sendPrompt("compare X and Y", undefined, undefined, ["opencode", "claude"]).turn;
+ expect(mock.prompt).not.toHaveBeenCalled();
+
+ await session.sendPrompt("now expand on that").turn;
+
+ const text = lastPromptText(mock);
+ expect(text).toContain("");
+ expect(text).toContain("compare X and Y");
+ expect(text).toContain("the fan-out summary");
+ expect(text).toContain("\nnow expand on that\n");
+
+ // Buffer cleared: a second single-agent turn carries no prior-turn block.
+ await session.sendPrompt("and again").turn;
+ expect(lastPromptText(mock)).not.toContain("");
+ });
+
+ it("replays the agents' answers when they answered but no summary was generated", async () => {
+ const mock = makeMockBackend();
+ const runFanoutTurn = fanoutAnswersNoSummary();
+ const session = new AgentSession({
+ backend: mock.asBackend,
+ backendSessionId: "acp-1",
+ internalId: "internal-1",
+ backendId: "opencode",
+ runFanoutTurn,
+ });
+
+ await session.sendPrompt("multi question", undefined, undefined, ["opencode", "claude"]).turn;
+ await session.sendPrompt("follow-up").turn;
+
+ // No summary was generated, but agents answered — so the follow-up replays
+ // the readable answers themselves (not a generic 'unavailable' note), so a
+ // question like "what did they say?" still has the content the user saw.
+ const text = lastPromptText(mock);
+ expect(text).toContain("");
+ expect(text).toContain("multi question");
+ expect(text).toContain("answer a");
+ expect(text).toContain("answer b");
+ expect(text).not.toContain("a combined summary could not be generated");
+ });
+});
+
describe("AgentSession.create (via start)", () => {
it("captures `state.model` from newSession and exposes via getState", async () => {
const mock = makeMockBackend();
diff --git a/src/agentMode/session/AgentSession.ts b/src/agentMode/session/AgentSession.ts
index 3c8e7d06..b6b91969 100644
--- a/src/agentMode/session/AgentSession.ts
+++ b/src/agentMode/session/AgentSession.ts
@@ -35,12 +35,32 @@ import {
MessageContext,
} from "@/types/message";
import { err2String, formatDateTime } from "@/utils";
+import { ensureMultiAgentEntitlement, showMultiAgentUpgradePrompt } from "@/plusUtils";
+import type { App } from "obsidian";
import { MethodUnsupportedError } from "@/agentMode/session/errors";
import { resolveMcpServers } from "@/agentMode/session/mcpResolver";
import { deriveChatTitleFromMessages } from "@/agentMode/session/chatHistoryMerge";
import { getSettings } from "@/settings/model";
import { ContextProcessor } from "@/contextProcessor";
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
+import type { FanoutRunInput } from "@/agentMode/session/fanout/FanoutOrchestrator";
+import { isFanout } from "@/agentMode/session/fanout/answerers";
+import {
+ buildConversationHistoryBlock,
+ buildPriorFanoutContextBlock,
+ FANOUT_HISTORY_MAX_CHARS,
+ FANOUT_READONLY_PREAMBLE,
+ renderFanoutComposite,
+ serializeFanoutComposite,
+ type FanoutTurn,
+ type PendingFanoutContext,
+} from "@/agentMode/session/fanout/fanoutTypes";
+
+/**
+ * Seam the session calls to dispatch a multi-agent read-only QA turn. Supplied
+ * by `AgentSessionManager`; omitted in tests and on the single-agent path.
+ */
+export type RunFanoutTurn = (input: FanoutRunInput) => Promise;
/**
* Prefix opencode uses for placeholder titles before its title-summarizer
@@ -68,6 +88,8 @@ const EMPTY_QUESTIONS: AskUserQuestionPrompt[] = [];
// Canonical "no answers" map. Resolving an in-flight question with this on
// cancel/dispose makes the bridge treat it as a user cancellation.
const EMPTY_ANSWERS: AgentQuestionAnswers = Object.freeze({});
+// Canonical "no fan-out" selection — referential stability on the single-agent path.
+const EMPTY_BACKEND_IDS: ReadonlyArray = Object.freeze([]);
/**
* Optimistically swap `state.model.current.baseModelId` for the persisted
@@ -171,6 +193,23 @@ export interface AgentSessionStartOptions {
* without coupling to specific backends. Manager-supplied; tests omit it.
*/
getDescriptor?: () => BackendDescriptor | undefined;
+ /**
+ * Optional fan-out dispatcher. When supplied, a turn with more than one agent
+ * runs the multi-agent read-only QA path instead of `backend.prompt()`.
+ * Manager-supplied; tests and the single-agent path omit it.
+ */
+ runFanoutTurn?: RunFanoutTurn;
+ /**
+ * Resolve any `BackendId` to its display name for the persisted composite
+ * headings. Manager-supplied; without it the serializer falls back to the id.
+ */
+ getDisplayName?: (backendId: BackendId) => string;
+ /**
+ * Resolve the Obsidian `App` for send-boundary side effects (the entitlement
+ * re-check passes it to `validateLicenseKey`). Threaded via DI so the session
+ * never reaches for the global `app`. Manager-supplied; tests omit it.
+ */
+ getApp?: () => App;
}
/**
@@ -192,6 +231,9 @@ export interface AgentSessionStateOptions {
defaultModelSelection?: ModelSelection;
cwd?: string | null;
getDescriptor?: () => BackendDescriptor | undefined;
+ runFanoutTurn?: RunFanoutTurn;
+ getDisplayName?: (backendId: BackendId) => string;
+ getApp?: () => App;
}
/**
@@ -216,6 +258,9 @@ export class AgentSession {
private readonly backend: BackendProcess;
private readonly cwd: string | null;
private readonly getDescriptor: (() => BackendDescriptor | undefined) | null;
+ private readonly runFanoutTurn: RunFanoutTurn | null;
+ private readonly getDisplayName: ((backendId: BackendId) => string) | null;
+ private readonly getApp: (() => App) | null;
// `status` is derived from the primitives below — see `getStatus()`.
// `cachedStatus` is a memo of the last value we fired through
// `onStatusChanged`, used purely for change detection. It is not the
@@ -230,6 +275,13 @@ export class AgentSession {
// preconditions pass. Yields the per-turn `"error"` status while the
// session sits idle between a failed turn and the next prompt.
private lastTurnError = false;
+ // The resolved answerer selection for the most recent turn (deduped
+ // `@`-mentioned installed agents). Empty on the single-agent path.
+ private lastMentionedAgents: ReadonlyArray = EMPTY_BACKEND_IDS;
+ // Fan-out turns the visible backend never processed. The next single-agent turn
+ // injects them in order as a labeled prior-turn block, then clears, so follow-ups
+ // keep the QA context. LIVE-ONLY — never persisted.
+ private pendingFanoutContext: PendingFanoutContext[] = [];
private placeholderId: string | null = null;
// ACP `messageId`s seen on this turn's content chunks. Used to re-route
// trailing chunks that a backend flushes *after* the `session/prompt` result
@@ -316,6 +368,9 @@ export class AgentSession {
this.backendId = opts.backendId;
this.cwd = opts.cwd ?? null;
this.getDescriptor = opts.getDescriptor ?? null;
+ this.runFanoutTurn = opts.runFanoutTurn ?? null;
+ this.getDisplayName = opts.getDisplayName ?? null;
+ this.getApp = opts.getApp ?? null;
if ("backendSessionId" in opts) {
this.backendSessionId = opts.backendSessionId;
const originalState = opts.initialState ?? null;
@@ -731,7 +786,8 @@ export class AgentSession {
sendPrompt(
displayText: string,
context?: MessageContext,
- promptContent?: PromptContent[]
+ promptContent?: PromptContent[],
+ mentionedAgents?: ReadonlyArray
): { userMessageId: string; turn: Promise } {
const status = this.getStatus();
if (status === "starting") {
@@ -774,6 +830,10 @@ export class AgentSession {
this.applyAgentLabel(deriveChatTitleFromMessages(this.store.getDisplayMessages()));
}
+ // Record the fan-out selection for this turn (empty = single-agent path).
+ this.lastMentionedAgents =
+ mentionedAgents && mentionedAgents.length > 0 ? mentionedAgents : EMPTY_BACKEND_IDS;
+
this.abortController = new AbortController();
// Clear any prior terminal error before the new turn starts so the
// derived status reflects the fresh `"running"` state. Both flips
@@ -781,12 +841,18 @@ export class AgentSession {
this.lastTurnError = false;
this.recomputeStatusIfChanged();
- const turn = this.runTurn(displayText, context, promptContent);
+ const turn = this.runTurn(displayText, userMessageId, context, promptContent);
return { userMessageId, turn };
}
+ /** The resolved answerer selection for the most recent `sendPrompt`; empty on the single-agent path. */
+ getLastMentionedAgents(): ReadonlyArray {
+ return this.lastMentionedAgents;
+ }
+
private async runTurn(
displayText: string,
+ userMessageId: string,
context: MessageContext | undefined,
promptContent?: PromptContent[]
): Promise {
@@ -801,12 +867,67 @@ export class AgentSession {
// synchronously within this turn (callers rely on that timing).
const hasWebTabs = (context?.webTabs?.length ?? 0) > 0;
const webTabBlock = hasWebTabs ? await serializeWebTabContext(context) : "";
- const promptBlocks = buildPromptBlocks(displayText, context, promptContent, webTabBlock);
+
+ // Fan-out path: the `@`-mentioned answerers dispatch the identical prompt in
+ // parallel ephemeral read-only sub-sessions and the main agent summarizes.
+ // It never talks to the visible backend, so it doesn't inject/flush the
+ // pending fan-out buffer (only appends on completion). `isFanout` collapses
+ // the degenerate `[main]` case so the main agent never both answers and
+ // summarizes — shared with the composer's `mentionedAgents` gate.
+ if (
+ this.runFanoutTurn &&
+ isFanout(this.lastMentionedAgents, this.backendId) &&
+ placeholderId
+ ) {
+ // Authoritative paywall: a fan-out turn is Plus-only, and the typeahead UI
+ // gate can be bypassed (pasting a pill), so re-check entitlement here at
+ // the session boundary. Paying users short-circuit; everyone else is hard-blocked.
+ if (!(await this.ensureMultiAgentEntitlement())) {
+ return this.blockFanoutForEntitlement(placeholderId, turnStartedAt);
+ }
+
+ // Give every fan-out agent the PRIOR visible transcript as a read-only
+ // `` block so follow-ups ("the answer above") work.
+ // "Prior" excludes this turn's own user message + placeholder. Null → unchanged prompt.
+ const historyBlock = buildConversationHistoryBlock(
+ this.priorDisplayMessages(userMessageId, placeholderId),
+ FANOUT_HISTORY_MAX_CHARS
+ );
+ const promptBlocks = buildPromptBlocks(
+ displayText,
+ context,
+ promptContent,
+ webTabBlock,
+ historyBlock
+ );
+ return await this.runFanoutPath(placeholderId, displayText, promptBlocks, turnStartedAt);
+ }
+
+ // Single-agent path: prepend any buffered fan-out turns as one labeled
+ // prior-turn block so the backend regains continuity. Empty buffer → `null`
+ // → unchanged prompt. Cleared only after `backend.prompt()` resolves below,
+ // so a thrown prompt preserves the buffer for the next turn.
+ const leadingContextBlock = buildPriorFanoutContextBlock(this.pendingFanoutContext);
+ const promptBlocks = buildPromptBlocks(
+ displayText,
+ context,
+ promptContent,
+ webTabBlock,
+ leadingContextBlock
+ );
+
const req: PromptInput = {
sessionId,
prompt: promptBlocks,
};
const resp = await this.backend.prompt(req);
+ // Flush the buffer only on a non-cancelled completion — only then has the
+ // backend durably ingested the `` block. A cancelled prompt may
+ // have stopped before ingesting it, so keep and re-inject (a duplicate is
+ // harmless; losing the multi-agent context is the bug).
+ if (leadingContextBlock !== null && resp.stopReason !== "cancelled") {
+ this.pendingFanoutContext = [];
+ }
if (
placeholderId &&
resp.stopReason !== "cancelled" &&
@@ -857,6 +978,121 @@ export class AgentSession {
}
}
+ /**
+ * Authoritative entitlement gate for the fan-out path, at the session send
+ * boundary so a UI bypass can't evade it. Delegates to the shared helper:
+ * paying users allow sync with no network call; otherwise it re-verifies
+ * against `/license`.
+ */
+ private ensureMultiAgentEntitlement(): Promise {
+ return ensureMultiAgentEntitlement(this.getApp?.(), { feature: "multi_agent_per_turn" });
+ }
+
+ /**
+ * Clean up a paywall-blocked fan-out turn: surface the upgrade prompt and
+ * finalize the placeholder as an error so no dangling bubble remains.
+ */
+ private blockFanoutForEntitlement(placeholderId: string, turnStartedAt: number): StopReason {
+ showMultiAgentUpgradePrompt();
+ this.store.markMessageError(
+ placeholderId,
+ "Multi-agent QA is a Copilot Plus feature. Upgrade to mention more than one agent in a turn."
+ );
+ this.store.markTurnComplete(placeholderId, "refusal", Date.now() - turnStartedAt);
+ this.currentMessageIds = new Set();
+ if (this.placeholderId === placeholderId) this.placeholderId = null;
+ this.notifyMessages();
+ return "refusal";
+ }
+
+ /**
+ * Dispatch a fan-out turn. Every ANSWERER runs the identical `promptBlocks` in
+ * a parallel ephemeral read-only sub-session, answers stream into per-agent
+ * slots of one live {@link FanoutTurn}, and the main agent fills the summary
+ * once they settle. Returns a `StopReason` so the surrounding `runTurn`
+ * lifecycle matches the single-agent path.
+ */
+ private async runFanoutPath(
+ placeholderId: string,
+ originalPromptText: string,
+ promptBlocks: PromptContent[],
+ turnStartedAt: number
+ ): Promise {
+ const signal = this.abortController?.signal ?? new AbortController().signal;
+ const input: FanoutRunInput = {
+ agents: this.lastMentionedAgents,
+ // The summarizer is ALWAYS the session's main agent, separate from the answerers.
+ mainAgent: this.backendId,
+ prompt: withReadOnlyPreamble(promptBlocks),
+ // The raw question fed to the summary. Distinct from `prompt`, which carries
+ // the read-only preamble + context.
+ originalPromptText,
+ signal,
+ onChange: (turn) => {
+ // Live state rides on the placeholder's `message.fanout`; `setFanout` bumps
+ // the message version so the dropdown re-renders per streamed slot.
+ this.store.setFanout(placeholderId, turn);
+ this.scheduleNotifyMessages();
+ },
+ };
+ const turn = await this.runFanoutTurn!(input);
+ this.store.setFanout(placeholderId, turn);
+
+ const stopReason: StopReason = signal.aborted ? "cancelled" : "end_turn";
+ // Persist the FULL composite as the message body so the dropdown reconstructs
+ // on reload. Any non-empty slot text counts (including a terminal slot's
+ // partial text), so a turn cancelled mid-stream is saved, not dropped blank;
+ // a turn with no content at all persists/buffers nothing.
+ const hasAnswerText = Object.values(turn.answers).some((a) => a.text.trim().length > 0);
+ const hasContent = turn.summary.text.trim().length > 0 || hasAnswerText;
+ if (hasContent) {
+ const composite = serializeFanoutComposite(turn, (id) => this.displayNameFor(id));
+ this.store.appendAgentText(placeholderId, composite);
+ // Buffer this turn so the next single-agent prompt can replay it (the visible
+ // backend never saw it). Prefer the summary ONLY when it generated
+ // successfully; an incomplete (cancelled/errored) summary falls back to
+ // replaying the agents' answers so a follow-up keeps the content the user saw.
+ const summaryText = turn.summary.text.trim();
+ const replay =
+ turn.summary.complete && summaryText.length > 0
+ ? summaryText
+ : hasAnswerText
+ ? renderFanoutComposite(turn, (id) => this.displayNameFor(id))
+ : "";
+ if (replay) {
+ this.pendingFanoutContext.push({ question: originalPromptText, summary: replay });
+ }
+ }
+ if (this.store.markTurnComplete(placeholderId, stopReason, Date.now() - turnStartedAt)) {
+ this.notifyMessages();
+ }
+ if (this.placeholderId === placeholderId) this.placeholderId = null;
+ return stopReason;
+ }
+
+ /**
+ * The visible transcript EXCLUDING the current turn's user message and
+ * placeholder, by exact id. Used to render the fan-out conversation-history
+ * block from PRIOR conversation only. Excluding by identity (not position)
+ * stays correct if anything is inserted between or after those messages.
+ */
+ private priorDisplayMessages(
+ userMessageId: string,
+ placeholderId: string
+ ): readonly AgentChatMessage[] {
+ return this.store
+ .getDisplayMessages()
+ .filter((m) => m.id !== userMessageId && m.id !== placeholderId);
+ }
+
+ /**
+ * Resolve a `BackendId` to its display name via the injected resolver (id
+ * fallback). Mirrors the `fanoutDropdown` resolver so heading and tab agree.
+ */
+ private displayNameFor(backendId: BackendId): string {
+ return this.getDisplayName?.(backendId) ?? backendId;
+ }
+
/**
* Cancel any in-flight turn. The backend may still emit a few trailing
* session events before the prompt promise resolves with
@@ -1549,13 +1785,15 @@ export function buildPromptBlocks(
displayText: string,
context?: MessageContext,
content?: PromptContent[],
- webTabBlock?: string
+ webTabBlock?: string,
+ leadingContextBlock?: string | null
): PromptContent[] {
- // Context sections precede the user message: the vault envelope (notes +
- // note excerpts), web-selection excerpts, then live web-tab content. Web
- // tab/selection blocks reuse the legacy `` tags so the model reads
- // the same shapes it does in the non-agent chat.
+ // Context sections precede the user message: an optional prior-turn block
+ // (buffered fan-out turns the backend never saw), the vault envelope, web-
+ // selection excerpts, then live web-tab content. Web blocks reuse the legacy
+ // `` tags. `leadingContextBlock` is absent on the common path.
const sections = [
+ leadingContextBlock?.trim() || null,
buildContextEnvelope(context),
buildWebSelectionBlocks(context),
webTabBlock?.trim() || null,
@@ -1569,6 +1807,20 @@ export function buildPromptBlocks(
return [{ type: "text", text: headText }, ...extras];
}
+/**
+ * Prepend the read-only QA instruction to the shared prompt blocks for a fan-out
+ * turn. Leads the first text block (or a new one) so every agent reads the same
+ * read-only framing before the context. Identical for every agent.
+ */
+export function withReadOnlyPreamble(blocks: PromptContent[]): PromptContent[] {
+ const i = blocks.findIndex((b) => b.type === "text");
+ if (i === -1) return [{ type: "text", text: FANOUT_READONLY_PREAMBLE }, ...blocks];
+ const block = blocks[i] as Extract;
+ const out = blocks.slice();
+ out[i] = { type: "text", text: `${FANOUT_READONLY_PREAMBLE}\n\n${block.text}` };
+ return out;
+}
+
/**
* Extract live Web Viewer content for the attached web tabs, reusing the
* non-agent chat's processor (reader-mode markdown, YouTube transcripts,
diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts
index 2c561b3d..89f1abd6 100644
--- a/src/agentMode/session/AgentSessionManager.ts
+++ b/src/agentMode/session/AgentSessionManager.ts
@@ -21,6 +21,12 @@ import {
import { MethodUnsupportedError } from "./errors";
import { resolveMcpServers } from "./mcpResolver";
import { replayPersistedMode } from "./replayPersistedMode";
+import {
+ FanoutOrchestrator,
+ type FanoutHost,
+ type FanoutRunInput,
+} from "./fanout/FanoutOrchestrator";
+import type { FanoutTurn } from "./fanout/fanoutTypes";
import type {
AgentQuestionAnswers,
AskUserQuestionPrompt,
@@ -30,6 +36,7 @@ import type {
BackendState,
CopilotMode,
EffortOption,
+ McpServerSpec,
ModeApplySpec,
ModelSelection,
PermissionDecision,
@@ -173,6 +180,11 @@ export class AgentSessionManager {
}
>();
+ // Session ids of ephemeral read-only fan-out sub-sessions; the shared permission
+ // prompter consults this to hard-deny write/exec tools for them.
+ private readonly readOnlyFanoutSessions = new Set();
+ private readonly fanoutOrchestrator: FanoutOrchestrator;
+
private getSessionState(internalId: string) {
let entry = this.sessionState.get(internalId);
if (!entry) {
@@ -191,6 +203,43 @@ export class AgentSessionManager {
throw new Error("AgentSessionManager is desktop only");
}
this.preloader = opts.modelPreloader;
+ this.fanoutOrchestrator = new FanoutOrchestrator(this.createFanoutHost());
+ }
+
+ /** Whether `backendSessionId` is an ephemeral read-only fan-out sub-session. */
+ isReadOnlyFanoutSession(backendSessionId: SessionId): boolean {
+ return this.readOnlyFanoutSessions.has(backendSessionId);
+ }
+
+ /** Run a multi-agent read-only QA turn. Called by `AgentSession.runTurn` when the turn fans out. */
+ runFanoutTurn(input: FanoutRunInput): Promise {
+ return this.fanoutOrchestrator.run(input);
+ }
+
+ /** Narrow backend seam the {@link FanoutOrchestrator} drives. */
+ private createFanoutHost(): FanoutHost {
+ return {
+ ensureBackendForFanout: async (backendId) => {
+ const descriptor = this.resolveDescriptor(backendId);
+ const { proc } = await this.ensureBackend(backendId, descriptor);
+ return { proc, descriptor };
+ },
+ getDefaultSelection: (backendId) => this.getDefaultSelection(backendId),
+ getDisplayName: (backendId) => this.resolveDescriptor(backendId).displayName,
+ getCwd: () => {
+ const adapter = this.app.vault.adapter;
+ return adapter instanceof FileSystemAdapter ? adapter.getBasePath() : null;
+ },
+ getMcpServers: (proc): McpServerSpec[] =>
+ resolveMcpServers(proc, getSettings().agentMode?.mcpServers),
+ registerReadOnlySession: (sessionId) => {
+ this.readOnlyFanoutSessions.add(sessionId);
+ return () => this.readOnlyFanoutSessions.delete(sessionId);
+ },
+ excludeSubSessionFromHistory: (backendId, sessionId) => {
+ void this.opts.sessionIndex?.deleteSession(backendId, sessionId);
+ },
+ };
}
/**
@@ -405,6 +454,10 @@ export class AgentSessionManager {
for (const s of sessions) {
if (!isSameCwd(s.cwd, vaultBasePath)) continue;
if (probeSessionId && s.sessionId === probeSessionId) continue;
+ // A live fan-out sub-session is ephemeral: skip it even before its
+ // async tombstone lands, so a sweep racing the in-flight turn can't
+ // surface it as a phantom chat.
+ if (this.readOnlyFanoutSessions.has(s.sessionId)) continue;
const title = s.title?.trim();
if (!title || title.startsWith(DEFAULT_TITLE_PREFIX)) continue;
const updatedAtMs = s.updatedAt ? Date.parse(s.updatedAt) : NaN;
@@ -509,6 +562,9 @@ export class AgentSessionManager {
defaultModelSelection: seedSelection,
initialCachedState: warm?.state ?? this.preloader.getCachedBackendState(resolvedId),
getDescriptor: () => this.opts.resolveDescriptor(resolvedId),
+ runFanoutTurn: (input) => this.runFanoutTurn(input),
+ getDisplayName: (backendId) => this.resolveDescriptor(backendId).displayName,
+ getApp: () => this.app,
});
if (warm) {
logInfo(
@@ -1353,6 +1409,9 @@ export class AgentSessionManager {
initialState: resumeResult.state,
cwd: vaultBasePath,
getDescriptor: () => this.opts.resolveDescriptor(backendId),
+ runFanoutTurn: (input) => this.runFanoutTurn(input),
+ getDisplayName: (id) => this.resolveDescriptor(id).displayName,
+ getApp: () => this.app,
});
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
@@ -1482,9 +1541,18 @@ export class AgentSessionManager {
// the backend session id in the signature so the first save after the
// session finishes starting (when sessionId flips from null → real)
// always writes through, even if the message list hasn't changed yet.
+ // For an in-flight fan-out turn `message` stays empty until completion, so
+ // fold in a fingerprint of the live slots — otherwise mid-stream autosaves
+ // de-dupe to the first partial snapshot and a crash loses later progress.
+ const last = messages[messages.length - 1];
+ const fanoutSig = last?.fanout
+ ? Object.values(last.fanout.answers)
+ .map((a) => `${a.status}:${a.text.length}`)
+ .join(",") + `|${last.fanout.summary.status}:${last.fanout.summary.text.length}`
+ : "";
const signature = `${label ?? ""}-${sessionId ?? ""}-${messages.length}-${
- messages[messages.length - 1]?.message ?? ""
- }`;
+ last?.message ?? ""
+ }-${fanoutSig}`;
const state = this.getSessionState(session.internalId);
if (state.signature === signature) {
return state.path ? { path: state.path } : null;
@@ -1613,6 +1681,9 @@ export class AgentSessionManager {
if (this.opts.askUserQuestionPrompter) {
proc.setAskUserQuestionPrompter?.(this.opts.askUserQuestionPrompter);
}
+ // Lets a backend with its own permission gate (Claude SDK) hard-deny write/exec
+ // tools for read-only fan-out sub-sessions — see `permissionBridge`.
+ proc.setReadOnlySessionPredicate?.((sessionId) => this.isReadOnlyFanoutSession(sessionId));
}
private async ensureBackend(
diff --git a/src/agentMode/session/fanout/FanoutOrchestrator.test.ts b/src/agentMode/session/fanout/FanoutOrchestrator.test.ts
new file mode 100644
index 00000000..ad2231a4
--- /dev/null
+++ b/src/agentMode/session/fanout/FanoutOrchestrator.test.ts
@@ -0,0 +1,377 @@
+import type {
+ BackendDescriptor,
+ BackendId,
+ BackendProcess,
+ BackendState,
+ ModelSelection,
+ SessionEvent,
+ SessionUpdateHandler,
+} from "@/agentMode/session/types";
+import { createFanoutTurn, FanoutOrchestrator, type FanoutHost } from "./FanoutOrchestrator";
+import { FANOUT_ALL_FAILED_SUMMARY, FANOUT_TRAILING_CHUNK_GRACE_MS } from "./fanoutTypes";
+
+jest.mock("@/logger", () => ({
+ logInfo: jest.fn(),
+ logWarn: jest.fn(),
+ logError: jest.fn(),
+}));
+
+interface MockProc {
+ proc: BackendProcess;
+ emit: (event: SessionEvent) => void;
+ setSessionMode: jest.Mock;
+ cancel: jest.Mock;
+ resolvePrompt: () => void;
+ rejectPrompt: (err: unknown) => void;
+ promptCount: () => number;
+}
+
+/**
+ * Mock backend process whose `prompt` stays pending until the test resolves it,
+ * so streamed events can land before the turn settles. `sessionId` is fixed per
+ * backend so the orchestrator's per-session handler routing is exercised. Each
+ * `prompt` call pushes its own resolver, so the answer turn and the later
+ * summary turn (a second sub-session on the main backend) resolve independently;
+ * `resolvePrompt`/`rejectPrompt` settle the oldest still-pending prompt.
+ */
+function makeMockProc(sessionId: string): MockProc {
+ let handler: SessionUpdateHandler | null = null;
+ const emptyState = (): BackendState => ({ model: null, mode: null });
+ const pending: Array<{
+ resolve: () => void;
+ reject: (err: unknown) => void;
+ }> = [];
+ const promptPromise = () =>
+ new Promise<{ stopReason: "end_turn" }>((resolve, reject) => {
+ pending.push({ resolve: () => resolve({ stopReason: "end_turn" }), reject });
+ });
+ const settleOldest = (
+ apply: (p: { resolve: () => void; reject: (e: unknown) => void }) => void
+ ) => {
+ const next = pending.shift();
+ if (next) apply(next);
+ };
+ const setSessionMode = jest.fn(async () => ({ model: null, mode: null }));
+ const cancel = jest.fn(async () => undefined);
+ const proc = {
+ isRunning: () => true,
+ onExit: () => () => {},
+ setPermissionPrompter: () => {},
+ registerSessionHandler: (_id: string, h: SessionUpdateHandler) => {
+ handler = h;
+ return () => {
+ handler = null;
+ };
+ },
+ newSession: jest.fn(() => Promise.resolve({ sessionId, state: emptyState() })),
+ prompt: jest.fn(() => promptPromise()),
+ cancel,
+ setSessionModel: jest.fn(async () => ({ model: null, mode: null })),
+ isSetSessionModelSupported: () => true,
+ setSessionMode,
+ isSetSessionModeSupported: () => true,
+ setSessionConfigOption: jest.fn(async () => ({ model: null, mode: null })),
+ isSetSessionConfigOptionSupported: () => true,
+ listSessions: jest.fn(async () => ({ sessions: [] })),
+ resumeSession: jest.fn(),
+ loadSession: jest.fn(),
+ supportsMcpTransport: () => false,
+ shutdown: async () => {},
+ } as unknown as BackendProcess;
+ return {
+ proc,
+ emit: (event) => handler?.(event),
+ setSessionMode,
+ cancel,
+ resolvePrompt: () => settleOldest((p) => p.resolve()),
+ rejectPrompt: (err) => settleOldest((p) => p.reject(err)),
+ promptCount: () => (proc.prompt as jest.Mock).mock.calls.length,
+ };
+}
+
+function descriptorFor(id: BackendId, readOnlyModeId?: string): BackendDescriptor {
+ return {
+ id,
+ wire: { encode: (s: ModelSelection) => `${s.baseModelId}/${s.effort ?? "default"}` },
+ getModeMapping: readOnlyModeId
+ ? () => ({
+ kind: "setMode" as const,
+ // `plan` deliberately diverges from `readOnlyModeId` so the test
+ // proves the orchestrator applies the read-only sandbox id, NOT plan.
+ canonical: { plan: "plan", default: "auto" },
+ readOnlyModeId,
+ })
+ : undefined,
+ } as unknown as BackendDescriptor;
+}
+
+function textChunk(sessionId: string, text: string): SessionEvent {
+ return {
+ sessionId,
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } },
+ };
+}
+
+interface HostHarness {
+ host: FanoutHost;
+ procs: Map;
+ readOnlyRegistered: string[];
+ readOnlyUnregistered: string[];
+ excludedFromHistory: Array<{ backendId: BackendId; sessionId: string }>;
+}
+
+function makeHost(
+ config: Record
+): HostHarness {
+ const procs = new Map();
+ const descriptors = new Map();
+ for (const [id, { sessionId, readOnlyModeId }] of Object.entries(config)) {
+ procs.set(id, makeMockProc(sessionId));
+ descriptors.set(id, descriptorFor(id, readOnlyModeId));
+ }
+ const readOnlyRegistered: string[] = [];
+ const readOnlyUnregistered: string[] = [];
+ const excludedFromHistory: Array<{ backendId: BackendId; sessionId: string }> = [];
+ const host: FanoutHost = {
+ ensureBackendForFanout: async (backendId) => ({
+ proc: procs.get(backendId)!.proc,
+ descriptor: descriptors.get(backendId)!,
+ }),
+ getDefaultSelection: () => null,
+ getDisplayName: (backendId) => backendId.toUpperCase(),
+ getCwd: () => "/vault",
+ getMcpServers: () => [],
+ registerReadOnlySession: (sessionId) => {
+ readOnlyRegistered.push(sessionId);
+ return () => readOnlyUnregistered.push(sessionId);
+ },
+ excludeSubSessionFromHistory: (backendId, sessionId) => {
+ excludedFromHistory.push({ backendId, sessionId });
+ },
+ };
+ return { host, procs, readOnlyRegistered, readOnlyUnregistered, excludedFromHistory };
+}
+
+const flush = () => new Promise((r) => window.setTimeout(r, 0));
+
+/**
+ * Real-timer flush that also clears the post-resolve trailing-chunk grace a
+ * normally-completed sub-session now waits before it unregisters its handler.
+ * Use after resolving an answer prompt when the test then asserts on the
+ * downstream summary dispatch / slot text (which only lands once that grace
+ * elapses). Padded past the grace so the deferred teardown has fired.
+ */
+const flushPastGrace = () =>
+ new Promise((r) => window.setTimeout(r, FANOUT_TRAILING_CHUNK_GRACE_MS + 20));
+
+/**
+ * Build a `run` input with sensible defaults: `mainAgent` (the summarizer)
+ * defaults to the first agent for the common case where the main agent is also
+ * an answerer, but it is decoupled from `agents` — tests override it to a
+ * backend that is NOT an answerer. `originalPromptText` is a fixed question.
+ */
+function runInput(
+ agents: BackendId[],
+ overrides: Partial[0]> = {}
+): Parameters[0] {
+ return {
+ agents,
+ mainAgent: agents[0],
+ prompt: [{ type: "text", text: "q" }],
+ originalPromptText: "the original question",
+ signal: new AbortController().signal,
+ onChange: () => {},
+ ...overrides,
+ };
+}
+
+describe("createFanoutTurn", () => {
+ it("seeds one running slot per agent (insertion order) plus a pending summary", () => {
+ const turn = createFanoutTurn(["opencode", "claude", "codex"]);
+ expect(Object.keys(turn.answers)).toEqual(["opencode", "claude", "codex"]);
+ expect(turn.answers.claude).toEqual({ backendId: "claude", status: "running", text: "" });
+ expect(turn.summary).toEqual({ status: "pending", text: "" });
+ });
+});
+
+describe("FanoutOrchestrator.run", () => {
+ it("streams each agent's answer into its own slot and marks them done", async () => {
+ const { host, procs, readOnlyRegistered, readOnlyUnregistered, excludedFromHistory } = makeHost(
+ {
+ claude: { sessionId: "s-claude" },
+ codex: { sessionId: "s-codex" },
+ }
+ );
+ const orchestrator = new FanoutOrchestrator(host);
+ const controller = new AbortController();
+ const snapshots: string[] = [];
+
+ const runPromise = orchestrator.run(
+ runInput(["claude", "codex"], {
+ prompt: [{ type: "text", text: "review this" }],
+ signal: controller.signal,
+ onChange: (turn) => snapshots.push(JSON.stringify(turn.answers)),
+ })
+ );
+
+ await flush();
+ procs.get("claude")!.emit(textChunk("s-claude", "Claude says hi"));
+ procs.get("codex")!.emit(textChunk("s-codex", "Codex says hi"));
+ procs.get("claude")!.resolvePrompt();
+ procs.get("codex")!.resolvePrompt();
+ // Answers settled; the main agent (claude) now opens a summary sub-session
+ // once the post-resolve trailing-chunk grace on both answers elapses.
+ await flushPastGrace();
+ procs.get("claude")!.emit(textChunk("s-claude", "summary"));
+ procs.get("claude")!.resolvePrompt();
+
+ const turn = await runPromise;
+ expect(turn.answers.claude).toEqual({
+ backendId: "claude",
+ status: "done",
+ text: "Claude says hi",
+ });
+ expect(turn.answers.codex).toEqual({
+ backendId: "codex",
+ status: "done",
+ text: "Codex says hi",
+ });
+ expect(turn.summary.status).toBe("done");
+ expect(turn.summary.text).toBe("summary");
+ // Three sub-sessions registered read-only: two answers + the summary (a
+ // second session on the main backend), all unregistered on teardown.
+ expect(readOnlyRegistered.sort()).toEqual(["s-claude", "s-claude", "s-codex"]);
+ expect(readOnlyUnregistered.sort()).toEqual(["s-claude", "s-claude", "s-codex"]);
+ // Every sub-session (incl. the summary's) is tombstoned so it never leaks
+ // into Recent Chats as a phantom native session.
+ expect(excludedFromHistory.map((e) => e.sessionId).sort()).toEqual([
+ "s-claude",
+ "s-claude",
+ "s-codex",
+ ]);
+ expect(snapshots.length).toBeGreaterThan(1);
+ });
+
+ it("isolates a failed agent as an error slot while others complete", async () => {
+ const { host, procs } = makeHost({
+ claude: { sessionId: "s-claude" },
+ codex: { sessionId: "s-codex" },
+ });
+ const orchestrator = new FanoutOrchestrator(host);
+ const controller = new AbortController();
+
+ const runPromise = orchestrator.run(
+ runInput(["claude", "codex"], { signal: controller.signal })
+ );
+
+ await flush();
+ procs.get("claude")!.emit(textChunk("s-claude", "ok"));
+ procs.get("claude")!.resolvePrompt();
+ procs.get("codex")!.rejectPrompt(new Error("backend boom"));
+ // The main agent (claude) summarizes over the one survivor once claude's
+ // post-resolve trailing-chunk grace elapses.
+ await flushPastGrace();
+ procs.get("claude")!.resolvePrompt();
+
+ const turn = await runPromise;
+ expect(turn.answers.claude.status).toBe("done");
+ expect(turn.answers.codex.status).toBe("error");
+ expect(turn.answers.codex.error).toContain("backend boom");
+ // The failed agent is never fed as an answer AND never named to the
+ // summarizer, so the summary can't mention or speculate about it.
+ const summaryCall = (procs.get("claude")!.proc.prompt as jest.Mock).mock.calls[1][0];
+ const text = summaryCall.prompt[0].text as string;
+ expect(text).toContain("the original question");
+ expect(text).toContain("CLAUDE");
+ expect(text).not.toContain("CODEX");
+ });
+
+ it("applies the read-only sandbox id (never plan) only for backends that advertise one", async () => {
+ const { host, procs } = makeHost({
+ // codex advertises a genuine read-only sandbox; its plan id is "plan".
+ codex: { sessionId: "s-codex", readOnlyModeId: "read-only" },
+ // opencode has no readOnlyModeId → no mode switch (relies on prompt +
+ // permission layers). Stands in for any backend lacking a sandbox.
+ opencode: { sessionId: "s-opencode" },
+ });
+ const orchestrator = new FanoutOrchestrator(host);
+ const controller = new AbortController();
+
+ const runPromise = orchestrator.run(
+ runInput(["codex", "opencode"], { signal: controller.signal })
+ );
+ await flush();
+ procs.get("codex")!.resolvePrompt();
+ procs.get("opencode")!.resolvePrompt();
+ // Main agent (codex) summary turn.
+ await flushPastGrace();
+ procs.get("codex")!.resolvePrompt();
+ await runPromise;
+
+ // Applies the read-only sandbox id, NOT canonical.plan ("plan") — a backend
+ // (Claude) whose plan mode writes plan files must never be put into it here.
+ expect(procs.get("codex")!.setSessionMode).toHaveBeenCalledWith({
+ sessionId: "s-codex",
+ modeId: "read-only",
+ });
+ expect(procs.get("codex")!.setSessionMode).not.toHaveBeenCalledWith({
+ sessionId: "s-codex",
+ modeId: "plan",
+ });
+ // No readOnlyModeId → setSessionMode is never called for that backend.
+ expect(procs.get("opencode")!.setSessionMode).not.toHaveBeenCalled();
+ });
+
+ it("cancels EVERY in-flight sub-session and lands each slot terminal-cancelled on abort", async () => {
+ const { host, procs } = makeHost({
+ claude: { sessionId: "s-claude" },
+ codex: { sessionId: "s-codex" },
+ });
+ const orchestrator = new FanoutOrchestrator(host);
+ const controller = new AbortController();
+ const runPromise = orchestrator.run(
+ runInput(["claude", "codex"], { signal: controller.signal })
+ );
+
+ await flush();
+ // Both sub-sessions are mid-prompt; the user cancels the turn.
+ controller.abort();
+ // Backends honor the cancel and resolve their pending prompts.
+ procs.get("claude")!.resolvePrompt();
+ procs.get("codex")!.resolvePrompt();
+ const turn = await runPromise;
+
+ // Every in-flight sub-session got cancel called (abort listener path).
+ expect(procs.get("claude")!.cancel).toHaveBeenCalledWith({ sessionId: "s-claude" });
+ expect(procs.get("codex")!.cancel).toHaveBeenCalledWith({ sessionId: "s-codex" });
+ // No slot is left running; an abort mid-prompt is terminal-cancelled, not done.
+ expect(turn.answers.claude.status).toBe("cancelled");
+ expect(turn.answers.codex.status).toBe("cancelled");
+ // No summary sub-session ran after cancel (only the two answer prompts).
+ expect(procs.get("claude")!.promptCount()).toBe(1);
+ expect(turn.summary.status).toBe("pending");
+ });
+
+ it("lands a brief all-failed note (no fabricated summary) when zero agents succeed", async () => {
+ const { host, procs } = makeHost({
+ claude: { sessionId: "s-claude" },
+ codex: { sessionId: "s-codex" },
+ });
+ const orchestrator = new FanoutOrchestrator(host);
+ const controller = new AbortController();
+ const runPromise = orchestrator.run(
+ runInput(["claude", "codex"], { signal: controller.signal })
+ );
+
+ await flush();
+ procs.get("claude")!.rejectPrompt(new Error("boom-a"));
+ procs.get("codex")!.rejectPrompt(new Error("boom-b"));
+ const turn = await runPromise;
+
+ expect(turn.summary.status).toBe("done");
+ expect(turn.summary.text).toBe(FANOUT_ALL_FAILED_SUMMARY);
+ // No summary sub-session was dispatched — nothing to reconcile.
+ expect(procs.get("claude")!.promptCount()).toBe(1);
+ expect(procs.get("codex")!.promptCount()).toBe(1);
+ });
+});
diff --git a/src/agentMode/session/fanout/FanoutOrchestrator.ts b/src/agentMode/session/fanout/FanoutOrchestrator.ts
new file mode 100644
index 00000000..73b8c093
--- /dev/null
+++ b/src/agentMode/session/fanout/FanoutOrchestrator.ts
@@ -0,0 +1,564 @@
+import { logWarn } from "@/logger";
+import { err2String } from "@/utils";
+import type {
+ BackendDescriptor,
+ BackendId,
+ BackendProcess,
+ ModelApplySpec,
+ ModelSelection,
+ PromptContent,
+ SessionEvent,
+ SessionId,
+} from "@/agentMode/session/types";
+import {
+ buildSummaryUserPrompt,
+ FANOUT_AGENT_TIMEOUT_ERROR,
+ FANOUT_AGENT_TIMEOUT_MS,
+ FANOUT_ALL_FAILED_SUMMARY,
+ FANOUT_CANCEL_GRACE_MS,
+ FANOUT_TRAILING_CHUNK_GRACE_MS,
+ selectSummaryInputs,
+ type AgentAnswer,
+ type FanoutTurn,
+} from "./fanoutTypes";
+
+/**
+ * Backend capabilities the orchestrator needs from the session manager. A narrow
+ * seam so the orchestrator never imports `AgentSessionManager` (dependency cycle)
+ * and stays unit-testable with a stub host.
+ */
+export interface FanoutHost {
+ /** Obtain a running backend process + descriptor for `backendId`. */
+ ensureBackendForFanout(
+ backendId: BackendId
+ ): Promise<{ proc: BackendProcess; descriptor: BackendDescriptor }>;
+ /** The user's previously-configured default model selection for `backendId`. */
+ getDefaultSelection(backendId: BackendId): ModelSelection | null;
+ /** Display label for `backendId`, used to label each agent's answer; falls back to the id. */
+ getDisplayName(backendId: BackendId): string;
+ /** Absolute vault working directory shared by all sub-sessions. */
+ getCwd(): string | null;
+ /** Neutral MCP server specs to open each sub-session with. */
+ getMcpServers(proc: BackendProcess): Parameters[0]["mcpServers"];
+ /**
+ * Register a session id as a read-only fan-out sub-session so the shared
+ * permission prompter denies write/exec tools for it. Returns an unregister fn.
+ */
+ registerReadOnlySession(sessionId: SessionId): () => void;
+ /**
+ * Tombstone a fan-out sub-session so it never surfaces in Recent Chats.
+ * opencode/codex persist `newSession` to disk and the native-discovery sweep
+ * would otherwise list these ephemeral sessions as phantom chats.
+ */
+ excludeSubSessionFromHistory(backendId: BackendId, sessionId: SessionId): void;
+}
+
+/**
+ * The assistant prose chunk from a session event, or `null` otherwise. Only
+ * `agent_message_chunk` text feeds an answer/summary; thoughts and tool calls
+ * are excluded.
+ */
+function textChunkOf(event: SessionEvent): string | null {
+ const update = event.update;
+ if (update.sessionUpdate !== "agent_message_chunk") return null;
+ if (update.content.type !== "text") return null;
+ return update.content.text;
+}
+
+/** Inputs for one fan-out turn — identical prompt + context for every agent. */
+export interface FanoutRunInput {
+ /**
+ * The `@`-mentioned installed answerers (deduped). Each gets an answer slot.
+ * Decoupled from {@link mainAgent}: the summarizer answers only if itself mentioned.
+ */
+ agents: ReadonlyArray;
+ /**
+ * The session's main agent — ALWAYS the summarizer, tracked separately from
+ * {@link agents}, whether or not it is one of the answerers.
+ */
+ mainAgent: BackendId;
+ /** The identical prompt blocks (text envelope + context + images) every agent receives. */
+ prompt: PromptContent[];
+ /**
+ * Plain text of the user's original question, fed to the summary prompt.
+ * Distinct from {@link prompt}, which also carries preamble + context envelope.
+ */
+ originalPromptText: string;
+ /** Aborts every in-flight sub-session prompt when fired. */
+ signal: AbortSignal;
+ /** Called whenever any slot mutates, so the UI can render live partials. */
+ onChange: (turn: FanoutTurn) => void;
+}
+
+/**
+ * Build the initial live turn: one `running` slot per ANSWERER (insertion order
+ * preserved) plus a pending summary. Exported so the caller can seed the UI
+ * before the first stream chunk lands.
+ */
+export function createFanoutTurn(agents: ReadonlyArray): FanoutTurn {
+ const answers: Record = {};
+ for (const backendId of agents) {
+ answers[backendId] = { backendId, status: "running", text: "" };
+ }
+ return { answers, summary: { status: "pending", text: "" } };
+}
+
+/**
+ * Orchestrates a multi-agent read-only QA turn. Every ANSWERER runs in an
+ * ephemeral, read-only sub-session on its own backend (never a visible
+ * AgentSession/tab) with the identical prompt; answers stream into per-agent
+ * slots of one {@link FanoutTurn}. Once every answer settles the main agent
+ * (summarizer) writes the narrative summary over the survivors.
+ *
+ * One agent's error never throws out of the run — its slot goes `error`, others
+ * continue. Prompts are cancellable via `signal`; every sub-session closes at
+ * turn end.
+ */
+export class FanoutOrchestrator {
+ constructor(private readonly host: FanoutHost) {}
+
+ async run(input: FanoutRunInput): Promise {
+ const turn = createFanoutTurn(input.agents);
+ input.onChange(turn);
+
+ await Promise.all(input.agents.map((backendId) => this.runAgent(backendId, turn, input)));
+
+ // Every answer settled; the main agent summarizes the survivors. Cancellation
+ // skips it — nothing to reconcile and the turn is ending.
+ if (!input.signal.aborted) {
+ await this.runSummary(turn, input);
+ }
+
+ return turn;
+ }
+
+ /**
+ * Run one agent in an ephemeral read-only sub-session. Resolves (never rejects)
+ * once the slot is terminal, so one agent's failure never throws out of the run:
+ *
+ * - normal completion → `done`
+ * - user cancel (run signal aborted) → `cancelled` (not a fault)
+ * - per-agent timeout → `error` with the timeout reason
+ * - any thrown backend call → `error` with the failure text
+ */
+ private async runAgent(
+ backendId: BackendId,
+ turn: FanoutTurn,
+ input: FanoutRunInput
+ ): Promise {
+ const slot = turn.answers[backendId];
+ // A slot transitions only while `running`; once terminal it is frozen. Gating
+ // every mutation here keeps streamed text, the status flip, and the error path
+ // from racing each other.
+ const mutateIfRunning = (apply: () => void) => {
+ if (slot.status !== "running") return;
+ apply();
+ input.onChange(turn);
+ };
+ try {
+ const outcome = await this.runReadOnlySubSession({
+ backendId,
+ prompt: input.prompt,
+ signal: input.signal,
+ onText: (text) => mutateIfRunning(() => (slot.text += text)),
+ });
+ // An abort is a clean cancel, not a fault: the slot goes `cancelled`, not `done`.
+ mutateIfRunning(() => (slot.status = outcome === "aborted" ? "cancelled" : "done"));
+ } catch (err) {
+ mutateIfRunning(() => {
+ logWarn(`[AgentMode] fan-out agent ${backendId} failed`, err);
+ slot.status = "error";
+ slot.error = err2String(err);
+ });
+ }
+ }
+
+ /**
+ * The main agent's narrative summary, over the agents that SUCCEEDED
+ * ({@link selectSummaryInputs}). With ZERO successes it lands `done` with a
+ * brief all-failed note rather than an invented summary or a hard error. Runs
+ * read-only in its own ephemeral sub-session of the main backend, streaming
+ * into `summary.text` while status moves pending → streaming → done.
+ */
+ private async runSummary(turn: FanoutTurn, input: FanoutRunInput): Promise {
+ const inputs = selectSummaryInputs(turn);
+ const summaryPrompt = buildSummaryUserPrompt(input.originalPromptText, inputs, (backendId) =>
+ this.host.getDisplayName(backendId)
+ );
+ if (!summaryPrompt) {
+ turn.summary.status = "done";
+ turn.summary.text = FANOUT_ALL_FAILED_SUMMARY;
+ turn.summary.complete = true;
+ input.onChange(turn);
+ return;
+ }
+
+ turn.summary.status = "streaming";
+ input.onChange(turn);
+ try {
+ const outcome = await this.runReadOnlySubSession({
+ backendId: input.mainAgent,
+ prompt: summaryPrompt,
+ signal: input.signal,
+ onText: (text) => {
+ turn.summary.text += text;
+ input.onChange(turn);
+ },
+ });
+ // Only a clean finish is trustworthy; an aborted summary leaves partial text
+ // the continuity replay must not prefer.
+ if (outcome === "done") turn.summary.complete = true;
+ } catch (err) {
+ // Errored/timed out mid-stream: partial text, NOT complete.
+ logWarn(`[AgentMode] fan-out summary failed`, err);
+ } finally {
+ turn.summary.status = "done";
+ input.onChange(turn);
+ }
+ }
+
+ /**
+ * Open an ephemeral, read-only sub-session on `backendId`, apply the read-only
+ * sandbox mode + default model, stream assistant text through `onText`, and
+ * tear it down when the attempt settles. Shared by per-agent answers AND the
+ * summary. Registered via {@link FanoutHost.registerReadOnlySession} so the
+ * permission prompter hard-denies writes.
+ *
+ * Returns `"aborted"` when the run signal fired (user cancel → `cancelled` slot),
+ * else `"done"`. THROWS {@link FANOUT_AGENT_TIMEOUT_ERROR} if the WHOLE attempt
+ * — setup AND `prompt()` — outlives {@link FANOUT_AGENT_TIMEOUT_MS}; bounding
+ * setup too means a cold/wedged `newSession` can't hang the turn.
+ *
+ * Teardown (best-effort `cancel` + handler unregister) happens in the attempt's
+ * own `finally`, so even a late-resolving `newSession` is torn down after the
+ * race bailed. On the normal path the handler is held open through
+ * {@link FANOUT_TRAILING_CHUNK_GRACE_MS} so trailing chunks still route in;
+ * cancel/timeout suppress that.
+ */
+ private async runReadOnlySubSession(params: {
+ backendId: BackendId;
+ prompt: PromptContent[];
+ signal: AbortSignal;
+ onText: (text: string) => void;
+ }): Promise<"done" | "aborted"> {
+ const { backendId, prompt, signal, onText } = params;
+
+ // The attempt owns the full lifecycle (setup, prompt, trailing-chunk grace,
+ // teardown), so its `finally` always closes any session it opened — even a
+ // late-resolving `newSession`. It reports its in-flight `prompt()` via
+ // `onPrompt` with a `cancelPrompt` so the race's cancel paths can interrupt
+ // and await the query's real settlement before the backend is reused.
+ const attempt = async (
+ onPrompt: (p: Promise, cancelPrompt: () => void) => void,
+ raceSettled: () => boolean
+ ): Promise<"done"> => {
+ let proc: BackendProcess | null = null;
+ let sessionId: SessionId | null = null;
+ let unregisterReadOnly: (() => void) | null = null;
+ let unregisterHandler: (() => void) | null = null;
+ try {
+ const ensured = await this.host.ensureBackendForFanout(backendId);
+ proc = ensured.proc;
+ const descriptor = ensured.descriptor;
+
+ const opened = await proc.newSession({
+ cwd: this.host.getCwd() ?? "",
+ mcpServers: this.host.getMcpServers(proc),
+ });
+ sessionId = opened.sessionId;
+ unregisterReadOnly = this.host.registerReadOnlySession(sessionId);
+ // Tombstone the disk-persisted session so the discovery sweep never lists
+ // it as a phantom Recent Chat.
+ this.host.excludeSubSessionFromHistory(backendId, sessionId);
+
+ unregisterHandler = proc.registerSessionHandler(sessionId, (event) => {
+ const text = textChunkOf(event);
+ if (text !== null) onText(text);
+ });
+
+ // Sandbox mode and model selection mutate disjoint fields, so run both
+ // round-trips concurrently. The model channel comes from the sub-session's
+ // own `BackendState.model.apply` spec, so config-option backends (opencode
+ // ≥ 1.15.13) route through the same RPC the visible session would.
+ const modelApply = opened.state.model?.apply ?? null;
+ await Promise.all([
+ this.applyReadOnlyMode(proc, descriptor, sessionId),
+ this.applyDefaultModel(proc, descriptor, backendId, sessionId, modelApply),
+ ]);
+
+ // If the race already won during setup, do NOT dispatch: the slot is
+ // terminal, and a query now could overlap the later summary on this same
+ // backend. The `finally` still tears the sub-session down.
+ if (raceSettled()) return "done";
+
+ const promptProc = proc;
+ const promptSessionId = sessionId;
+ const promptPromise = promptProc.prompt({ sessionId, prompt });
+ onPrompt(promptPromise, () => {
+ promptProc.cancel({ sessionId: promptSessionId }).catch(() => undefined);
+ });
+ await promptPromise;
+ // Hold the handler open a bounded window so trailing chunks some backends
+ // flush after `session/prompt` resolves still route in. Skipped once the
+ // race bailed — a cancel-honored prompt must suppress late output.
+ if (!raceSettled()) await this.awaitTrailingChunks();
+ return "done";
+ } finally {
+ unregisterHandler?.();
+ unregisterReadOnly?.();
+ if (proc && sessionId) {
+ // Best-effort cancel ends the in-flight query. Runs on every exit (done
+ // / aborted / timeout / throw), including a late-opened session.
+ proc.cancel({ sessionId }).catch(() => undefined);
+ }
+ }
+ };
+
+ return this.runAttemptWithTimeout(
+ (onPrompt, raceSettled) => attempt(onPrompt, raceSettled),
+ signal
+ );
+ }
+
+ /** Hold {@link FANOUT_TRAILING_CHUNK_GRACE_MS} so the handler captures trailing chunks. */
+ private awaitTrailingChunks(): Promise {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, FANOUT_TRAILING_CHUNK_GRACE_MS);
+ });
+ }
+
+ /**
+ * Run one `attempt` (setup + prompt) racing the run signal (user cancel) and a
+ * per-agent deadline covering the WHOLE attempt. Abort → resolve `"aborted"`;
+ * timeout → throw {@link FANOUT_AGENT_TIMEOUT_ERROR}, so a hung agent never
+ * blocks the turn. A still-pending setup await is interrupted promptly by either
+ * path — the helper settles without waiting on it.
+ *
+ * Cancel only INTERRUPTS; the `prompt()` promise keeps unwinding the backend
+ * query after `cancel` returns. The Claude SDK backend's permission-bridge/
+ * session context is process-global for the active query, so reusing that
+ * backend (the summary reuses the main agent's) mid-unwind can misroute
+ * permission decisions or corrupt the summary. So on abort/timeout, if a prompt
+ * is in flight, we cancel it and AWAIT its settlement (bounded by
+ * {@link FANOUT_CANCEL_GRACE_MS}; log and proceed if it ignores cancel) before
+ * settling. During setup (no prompt) there's nothing to await. The happy path
+ * never enters this grace.
+ *
+ * The deadline timer and abort listener are both torn down on whichever path
+ * settles first, so neither leaks.
+ */
+ private runAttemptWithTimeout(
+ attempt: (
+ onPrompt: (p: Promise, cancelPrompt: () => void) => void,
+ raceSettled: () => boolean
+ ) => Promise<"done">,
+ signal: AbortSignal
+ ): Promise<"done" | "aborted"> {
+ return new Promise<"done" | "aborted">((resolve, reject) => {
+ let settled = false;
+ // The in-flight prompt's settlement, mapped to `undefined` on BOTH outcomes
+ // (a cancelled prompt usually rejects; we only care that it stopped, and the
+ // mapping keeps the swallowed rejection from surfacing as unhandled). Stays
+ // `null` during setup, so the cancel paths know there's nothing to await.
+ let promptSettled: Promise | null = null;
+ // Interrupts the in-flight prompt's backend query (set once dispatched).
+ let cancelInFlightPrompt: (() => void) | null = null;
+
+ const cleanup = () => {
+ window.clearTimeout(timeout);
+ signal.removeEventListener("abort", onAbort);
+ };
+
+ // Cancel the in-flight prompt, then wait (bounded by the grace) for it to
+ // settle before `done()`. With no prompt in flight, finish immediately. `done`
+ // wraps the outer resolve/reject, so a double call (grace vs settle) is a no-op.
+ const settleAfterCancel = (done: () => void) => {
+ if (promptSettled === null) {
+ done();
+ return;
+ }
+ cancelInFlightPrompt?.();
+ const grace = window.setTimeout(() => {
+ logWarn(
+ `[AgentMode] fan-out prompt did not settle within the cancel grace; reusing backend anyway`
+ );
+ done();
+ }, FANOUT_CANCEL_GRACE_MS);
+ // `promptSettled` never rejects (both outcomes mapped to undefined).
+ void promptSettled.then(() => {
+ window.clearTimeout(grace);
+ done();
+ });
+ };
+
+ // Both cancel paths share one single-shot teardown, differing only in how
+ // the helper settles (aborted vs. timeout error).
+ const beginCancel = (done: () => void) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ settleAfterCancel(done);
+ };
+ const onAbort = () => beginCancel(() => resolve("aborted"));
+ const timeout = window.setTimeout(
+ () => beginCancel(() => reject(new Error(FANOUT_AGENT_TIMEOUT_ERROR))),
+ FANOUT_AGENT_TIMEOUT_MS
+ );
+ signal.addEventListener("abort", onAbort, { once: true });
+
+ // If Stop was pressed before this attempt, the signal is already aborted and
+ // the just-armed listener will never fire. Settle now WITHOUT starting
+ // `attempt`, so no sub-session is opened after Stop.
+ if (signal.aborted) {
+ beginCancel(() => resolve("aborted"));
+ return;
+ }
+
+ // Records the dispatched prompt so a later abort/timeout can cancel and await
+ // its unwind. If abort/timeout ALREADY fired (during setup), cancel at once
+ // so no live query runs behind an already-terminal slot.
+ const onPrompt = (p: Promise, cancelPrompt: () => void) => {
+ promptSettled = p.then(
+ () => undefined,
+ () => undefined
+ );
+ cancelInFlightPrompt = cancelPrompt;
+ if (settled) {
+ cancelPrompt();
+ p.catch(() => undefined);
+ }
+ };
+
+ // `raceSettled()` is true once abort/timeout won, so the attempt skips its
+ // trailing-chunk hold and tears down at once on either bail.
+ attempt(onPrompt, () => settled).then(
+ () => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ // A cancel-honored resolve is still a user abort — read it off the signal
+ // so the slot lands `cancelled`, not `done`.
+ resolve(signal.aborted ? "aborted" : "done");
+ },
+ (err) => {
+ // Lost the race: the terminal state is chosen — swallow the rejection.
+ if (settled) return;
+ settled = true;
+ cleanup();
+ reject(err instanceof Error ? err : new Error(err2String(err)));
+ }
+ );
+ });
+ }
+
+ /**
+ * Apply the backend's genuine read-only sandbox mode when it advertises one via
+ * `ModeMapping.readOnlyModeId` (codex → `read-only`). Belt-and-suspenders on top
+ * of the prompt preamble + permission denial.
+ *
+ * Keyed off `readOnlyModeId`, NOT `canonical.plan`: a backend's plan mode may
+ * write plan artifacts (Claude's `plan` writes files), the opposite of
+ * read-only. Backends without a true read-only sandbox leave it unset and rely
+ * on the prompt + permission layers (which hard-deny writes regardless).
+ */
+ private async applyReadOnlyMode(
+ proc: BackendProcess,
+ descriptor: BackendDescriptor,
+ sessionId: SessionId
+ ): Promise {
+ const mapping = descriptor.getModeMapping?.(null, null);
+ if (mapping?.kind !== "setMode") return;
+ const nativeId = mapping.readOnlyModeId;
+ if (!nativeId) return;
+ try {
+ await proc.setSessionMode({ sessionId, modeId: nativeId });
+ } catch (e) {
+ logWarn(`[AgentMode] fan-out read-only mode failed for ${descriptor.id}`, e);
+ }
+ }
+
+ /**
+ * Switch the sub-session onto the user's configured default model AND effort.
+ * Best-effort — a missing default or unsupported switch leaves the backend's own.
+ *
+ * The orchestrator holds only a raw `(proc, sessionId)` pair, so it mirrors
+ * `AgentSession.applyModelWireId` + `descriptor.applySelection` generically off
+ * the sub-session's own `BackendState.model.apply` spec (`modelApply`):
+ *
+ * - `setModel` spec (claude, codex, opencode ≤ 1.15.12): model via
+ * `setSessionModel`. Effort rides the wire id (codex) or applies via a
+ * second `setSessionConfigOption` using `wire.effortConfigFor` (Claude SDK,
+ * where `wire.encode` drops effort) — without it, the default effort runs.
+ *
+ * - `setConfigOption` spec (opencode ≥ 1.15.13, `session/set_model` gone): the
+ * MODEL is set via `setSessionConfigOption` (`setSessionModel` would hit the
+ * unsupported RPC). Effort is a sibling option only surfaced for the ACTIVE
+ * model, so we activate the bare model first, then apply effort against the
+ * refreshed `effortConfigId` (mirroring opencode's `applySelection`).
+ */
+ private async applyDefaultModel(
+ proc: BackendProcess,
+ descriptor: BackendDescriptor,
+ backendId: BackendId,
+ sessionId: SessionId,
+ modelApply: ModelApplySpec | null
+ ): Promise {
+ const selection = this.host.getDefaultSelection(backendId);
+ if (!selection) return;
+ try {
+ if (modelApply?.kind === "setConfigOption") {
+ await this.applyConfigOptionModel(proc, descriptor, sessionId, selection, modelApply);
+ return;
+ }
+ await proc.setSessionModel({ sessionId, modelId: descriptor.wire.encode(selection) });
+ if (selection.effort !== null) {
+ const effortConfig = descriptor.wire.effortConfigFor?.(selection.baseModelId);
+ if (effortConfig) {
+ await proc.setSessionConfigOption({
+ sessionId,
+ configId: effortConfig.id,
+ value: selection.effort,
+ });
+ }
+ }
+ } catch (e) {
+ logWarn(`[AgentMode] fan-out default model failed for ${backendId}`, e);
+ }
+ }
+
+ /**
+ * Apply model + effort for the config-option channel (opencode ≥ 1.15.13),
+ * mirroring opencode's `descriptor.applySelection`. The bare model is set first
+ * (effort dropped) so the backend surfaces the model-specific effort option;
+ * effort is then applied against the returned state's `effortConfigId`.
+ */
+ private async applyConfigOptionModel(
+ proc: BackendProcess,
+ descriptor: BackendDescriptor,
+ sessionId: SessionId,
+ selection: ModelSelection,
+ modelApply: Extract
+ ): Promise {
+ const bareWire = descriptor.wire.encode({
+ baseModelId: selection.baseModelId,
+ effort: null,
+ });
+ const refreshed = await proc.setSessionConfigOption({
+ sessionId,
+ configId: modelApply.configId,
+ value: bareWire,
+ });
+ if (selection.effort === null) return;
+ const refreshedApply = refreshed.model?.apply;
+ const effortConfigId =
+ refreshedApply?.kind === "setConfigOption"
+ ? refreshedApply.effortConfigId
+ : modelApply.effortConfigId;
+ if (!effortConfigId) return;
+ await proc.setSessionConfigOption({
+ sessionId,
+ configId: effortConfigId,
+ value: selection.effort,
+ });
+ }
+}
diff --git a/src/agentMode/session/fanout/answerers.ts b/src/agentMode/session/fanout/answerers.ts
new file mode 100644
index 00000000..1e6f1e9e
--- /dev/null
+++ b/src/agentMode/session/fanout/answerers.ts
@@ -0,0 +1,39 @@
+import type { BackendId } from "@/agentMode/session/types";
+
+/** Frozen empty answerer list — referential stability for the "no qualifying mentions" case. */
+export const EMPTY_ANSWERERS: ReadonlyArray = Object.freeze([]);
+
+/**
+ * Resolve the agents that should ANSWER a turn from the user's `@`-mentions: the
+ * deduped, installed mentions ONLY. The main agent is NOT auto-included — it is
+ * the separate summarizer, answering only when itself mentioned. Order is stable
+ * (the pill sync plugin reports them sorted by backend id). Pure and UI-free so
+ * the composer and the session layer share one source of truth — see {@link isFanout}.
+ */
+export function resolveAnswerers(args: {
+ mentionedAgentIds: ReadonlyArray;
+ installedAgentIds: ReadonlySet;
+}): ReadonlyArray {
+ const { mentionedAgentIds, installedAgentIds } = args;
+ const answerers: BackendId[] = [];
+ const seen = new Set();
+ for (const id of mentionedAgentIds) {
+ if (seen.has(id)) continue;
+ if (!installedAgentIds.has(id)) continue;
+ seen.add(id);
+ answerers.push(id);
+ }
+ return answerers.length > 0 ? answerers : EMPTY_ANSWERERS;
+}
+
+/**
+ * Whether a resolved answerer set actually fans out. True for any non-empty set
+ * EXCEPT the degenerate `[main]` (only the user's own agent), which collapses to
+ * the normal single-agent path so the main agent isn't asked to both answer and
+ * summarize the same backend. Callers gate the `mentionedAgents` emission on this.
+ */
+export function isFanout(answerers: ReadonlyArray, mainAgentId: BackendId): boolean {
+ if (answerers.length === 0) return false;
+ if (answerers.length === 1 && answerers[0] === mainAgentId) return false;
+ return true;
+}
diff --git a/src/agentMode/session/fanout/fanoutTypes.test.ts b/src/agentMode/session/fanout/fanoutTypes.test.ts
new file mode 100644
index 00000000..6acecb81
--- /dev/null
+++ b/src/agentMode/session/fanout/fanoutTypes.test.ts
@@ -0,0 +1,274 @@
+import type { AgentChatMessage, AgentToolKind } from "@/agentMode/session/types";
+import { AI_SENDER, USER_SENDER } from "@/constants";
+import type { MessageContext } from "@/types/message";
+import {
+ buildConversationHistoryBlock,
+ buildPriorFanoutContextBlock,
+ buildSummaryUserPrompt,
+ EMPTY_PENDING_FANOUT_CONTEXT,
+ FANOUT_HISTORY_MAX_CHARS,
+ isWriteOrExecToolKind,
+ parseFanoutComposite,
+ renderFanoutComposite,
+ selectSummaryInputs,
+ serializeFanoutComposite,
+ type FanoutTurn,
+} from "./fanoutTypes";
+
+const histMsg = (sender: string, message: string): AgentChatMessage => ({
+ id: `${sender}-${message.slice(0, 8)}`,
+ sender,
+ timestamp: null,
+ isVisible: true,
+ message,
+});
+
+const upper = (id: string) => id.toUpperCase();
+
+describe("isWriteOrExecToolKind", () => {
+ it("denies write/exec tool kinds", () => {
+ const denied: AgentToolKind[] = ["edit", "delete", "move", "execute"];
+ for (const kind of denied) {
+ expect(isWriteOrExecToolKind(kind)).toBe(true);
+ }
+ });
+
+ it("allows read/search/fetch/think/switch_mode/other tool kinds", () => {
+ const allowed: AgentToolKind[] = ["read", "search", "fetch", "think", "switch_mode", "other"];
+ for (const kind of allowed) {
+ expect(isWriteOrExecToolKind(kind)).toBe(false);
+ }
+ });
+
+ it("fails safe (denies) when the kind is unknown", () => {
+ expect(isWriteOrExecToolKind(undefined)).toBe(true);
+ });
+});
+
+describe("selectSummaryInputs", () => {
+ const turn = (): FanoutTurn => ({
+ answers: {
+ claude: { backendId: "claude", status: "done", text: "claude answer" },
+ codex: { backendId: "codex", status: "error", text: "", error: "boom" },
+ opencode: { backendId: "opencode", status: "done", text: " " },
+ },
+ summary: { status: "pending", text: "" },
+ });
+
+ it("keeps only done non-empty slots as succeeded; treats errored and done-but-empty as failed", () => {
+ const { succeeded, failed } = selectSummaryInputs(turn());
+ expect(succeeded).toEqual([{ backendId: "claude", text: "claude answer" }]);
+ expect(failed).toEqual(["codex", "opencode"]);
+ });
+});
+
+describe("buildSummaryUserPrompt", () => {
+ it("returns null when zero agents succeeded (never fabricates)", () => {
+ const prompt = buildSummaryUserPrompt(
+ "the question",
+ { succeeded: [], failed: ["claude", "codex"] },
+ upper
+ );
+ expect(prompt).toBeNull();
+ });
+
+ it("composes a single text block with the question and the succeeded answers only", () => {
+ const prompt = buildSummaryUserPrompt(
+ " the question ",
+ {
+ succeeded: [
+ { backendId: "claude", text: "claude says X" },
+ { backendId: "opencode", text: "opencode says Y" },
+ ],
+ failed: ["codex"],
+ },
+ upper
+ );
+ expect(prompt).not.toBeNull();
+ expect(prompt!).toHaveLength(1);
+ expect(prompt![0].type).toBe("text");
+ const text = (prompt![0] as { text: string }).text;
+ expect(text).toContain("the question");
+ expect(text).toContain("### CLAUDE\nclaude says X");
+ expect(text).toContain("### OPENCODE\nopencode says Y");
+ // The summarizer is never told about agents that did not answer.
+ expect(text).not.toContain("CODEX");
+ });
+
+ it("caps an oversized answer so it can't blow the summary sub-session", () => {
+ const huge = "z".repeat(50_000);
+ const prompt = buildSummaryUserPrompt(
+ "q",
+ { succeeded: [{ backendId: "claude", text: huge }], failed: [] },
+ upper
+ );
+ const text = (prompt![0] as { text: string }).text;
+ expect(text).toContain("[answer truncated]");
+ expect(text.length).toBeLessThan(huge.length);
+ });
+});
+
+describe("buildPriorFanoutContextBlock", () => {
+ it("returns null for an empty buffer so the prompt stays unchanged", () => {
+ expect(buildPriorFanoutContextBlock([])).toBeNull();
+ expect(buildPriorFanoutContextBlock(EMPTY_PENDING_FANOUT_CONTEXT)).toBeNull();
+ });
+
+ it("frames a single turn as prior conversation with labeled question + summary, escaping XML", () => {
+ const block = buildPriorFanoutContextBlock([
+ { question: "what about & ?", summary: "Do Y." },
+ ])!;
+ expect(block).toContain("");
+ expect(block).toContain("");
+ expect(block).toContain("\nDo Y.\n");
+ // Reads as history, not a new instruction to re-answer.
+ expect(block).toContain("conversation history");
+ // A stray tag in the question can't break the framing.
+ expect(block).not.toContain("?");
+ expect(block).toContain("<b>");
+ expect(block).toContain("&");
+ });
+});
+
+describe("buildConversationHistoryBlock", () => {
+ it("renders prior turns labeled by role, framed as read-only history", () => {
+ const block = buildConversationHistoryBlock(
+ [histMsg(USER_SENDER, "What is the plan?"), histMsg(AI_SENDER, "Here is the plan.")],
+ FANOUT_HISTORY_MAX_CHARS
+ )!;
+ expect(block).toContain("");
+ expect(block).toContain("");
+ expect(block).toContain('');
+ expect(block).toContain('');
+ expect(block).toContain("What is the plan?");
+ expect(block).toContain("Here is the plan.");
+ expect(block.toLowerCase()).toContain("do not");
+ // Order preserved: user turn precedes the assistant turn.
+ expect(block.indexOf("What is the plan?")).toBeLessThan(block.indexOf("Here is the plan."));
+ });
+
+ it("returns null for an empty transcript", () => {
+ expect(buildConversationHistoryBlock([], FANOUT_HISTORY_MAX_CHARS)).toBeNull();
+ });
+
+ it("drops the oldest turns first and prepends a truncation marker past the cap", () => {
+ const big = "x".repeat(400);
+ const messages = Array.from({ length: 20 }, (_, i) =>
+ histMsg(i % 2 === 0 ? USER_SENDER : AI_SENDER, `turn-${i}-${big}`)
+ );
+ const block = buildConversationHistoryBlock(messages, 1000)!;
+ expect(block).toContain("[earlier conversation truncated]");
+ // Oldest dropped, most-recent kept.
+ expect(block).not.toContain("turn-0-");
+ expect(block).toContain("turn-19-");
+ });
+
+ // Only `.basename`/`.path` are read off notes; a minimal stub suffices.
+ const withContext = (
+ sender: string,
+ message: string,
+ context: MessageContext
+ ): AgentChatMessage => ({ ...histMsg(sender, message), context });
+
+ it("includes the user's attached context (a selected-text excerpt) in history", () => {
+ const msg = withContext(USER_SENDER, "explain the selected excerpt above", {
+ notes: [],
+ urls: [],
+ selectedTextContexts: [
+ {
+ id: "s1",
+ sourceType: "note",
+ noteTitle: "DesignDoc",
+ notePath: "DesignDoc.md",
+ startLine: 3,
+ endLine: 9,
+ content: "the fan-out renderer drops context",
+ },
+ ],
+ });
+ const block = buildConversationHistoryBlock([msg], FANOUT_HISTORY_MAX_CHARS)!;
+ expect(block).toContain("[context]");
+ expect(block).toContain("[selected from DesignDoc]");
+ expect(block).toContain("the fan-out renderer drops context");
+ });
+});
+
+describe("serializeFanoutComposite / parseFanoutComposite", () => {
+ const name = (id: string) => id.toUpperCase();
+
+ const multiTurn = (): FanoutTurn => ({
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "opencode says X" },
+ codex: { backendId: "codex", status: "done", text: "codex says Y" },
+ },
+ summary: { status: "done", text: "the narrative summary" },
+ });
+
+ it("round-trips a multi-agent turn (serialize → parse)", () => {
+ const body = serializeFanoutComposite(multiTurn(), name);
+ expect(body).toContain("");
+ expect(body).toContain("");
+ const parsed = parseFanoutComposite(body)!;
+ expect(parsed).not.toBeNull();
+ expect(parsed.summary.text).toBe("the narrative summary");
+ expect(parsed.summary.status).toBe("done");
+ expect(Object.keys(parsed.answers)).toEqual(["opencode", "codex"]);
+ expect(parsed.answers.opencode).toMatchObject({ status: "done", text: "opencode says X" });
+ expect(parsed.answers.codex).toMatchObject({ status: "done", text: "codex says Y" });
+ });
+
+ it("losslessly round-trips an answer that literally contains the marker prefix and escape sentinel", () => {
+ // An answer quoting the format must not forge a real section marker, and the
+ // exact text (incl. the raw PUA escape sentinel) must come back verbatim.
+ const sentinel = "\uE000";
+ const forged =
+ 'Here is the format: and ' +
+ ` with a bare ${sentinel} sentinel and ` +
+ ` and ${sentinel} body`;
+ const turn: FanoutTurn = {
+ answers: { opencode: { backendId: "opencode", status: "done", text: forged } },
+ summary: { status: "done", text: summaryText },
+ };
+ const body = serializeFanoutComposite(turn, name);
+ const parsed = parseFanoutComposite(body)!;
+ // Only the REAL agent (opencode) is reconstructed — no forged "evil" slot.
+ expect(Object.keys(parsed.answers)).toEqual(["opencode"]);
+ expect(parsed.answers.opencode.text).toBe(forged);
+ expect(parsed.summary.text).toBe(summaryText);
+ });
+
+ it("requires the full composite wrapper — a plain message or a mere mention is not a turn", () => {
+ expect(parseFanoutComposite("plain text")).toBeNull();
+ // A message discussing the serializer (e.g. in a code block) must not be
+ // mistaken for a composite and hidden behind the fan-out card on reload.
+ const discussing =
+ "The format uses comments like `` and " +
+ '`` to mark sections.';
+ expect(parseFanoutComposite(discussing)).toBeNull();
+ // Even both wrapper markers present, but with NO real sections, is not a turn.
+ expect(
+ parseFanoutComposite("\n\n")
+ ).toBeNull();
+ });
+});
+
+describe("renderFanoutComposite", () => {
+ const name = (id: string) => id.toUpperCase();
+
+ it("renders clean markdown (no markers): summary + each succeeded agent + did-not-answer notes", () => {
+ const turn: FanoutTurn = {
+ answers: {
+ opencode: { backendId: "opencode", status: "done", text: "opencode body" },
+ codex: { backendId: "codex", status: "error", text: "", error: "boom" },
+ },
+ summary: { status: "done", text: "the summary" },
+ };
+ const out = renderFanoutComposite(turn, name);
+ expect(out).not.toContain("`;
+
+/**
+ * Version-agnostic open marker matcher. {@link parseFanoutComposite} requires
+ * both this and the close marker before treating a body as a composite, so an
+ * answer merely mentioning the format is never misread as a serialized turn.
+ */
+const FANOUT_MARKER_OPEN_RE = //;
+
+/** Closing marker of a serialized fan-out composite. */
+const FANOUT_MARKER_CLOSE = "";
+
+/** Section marker introducing the summary block. */
+const FANOUT_MARKER_SUMMARY = "";
+
+/**
+ * Marker-escape sentinel: a Private-Use-Area codepoint that won't occur in
+ * normal prose. An answer may legitimately contain the literal marker prefix
+ * (e.g. quoting this format); writing it verbatim would forge a section marker.
+ * We neutralize the colon after `copilot` on write and restore it on read.
+ *
+ * Lossless even when the answer already contains the sentinel: literal sentinels
+ * are escaped FIRST (`S`+`0`), then marker colons (`S`+`1`), so the two never
+ * collide and the read side reverses both unambiguously. The sentinel-doubling
+ * step is required \u2014 without it, raw escaped byte sequences corrupt on read.
+ */
+const FANOUT_MARKER_SENTINEL = "\uE000";
+const FANOUT_SENTINEL_LITERAL_ESCAPE = `${FANOUT_MARKER_SENTINEL}0`;
+const FANOUT_SENTINEL_COLON_ESCAPE = `${FANOUT_MARKER_SENTINEL}1`;
+const FANOUT_LITERAL_MARKER_PREFIX = "`,
+ `### ${name}`,
+ escapeFanoutMarkers(capPersistedAnswer(slot.text.trim()))
+ );
+ } else {
+ // A failed/cancelled agent: persist its partial text (with terminal status)
+ // so a reload matches the live tab, else a body-less "did not answer" marker.
+ const errorAttr =
+ slot.status === "error" && slot.error ? ` error="${escapeMarkerAttr(slot.error)}"` : "";
+ const statusAttr = ` status="${escapeMarkerAttr(slot.status)}"`;
+ const partial = slot.text.trim();
+ if (partial.length > 0) {
+ lines.push(
+ ``,
+ `### ${name}`,
+ escapeFanoutMarkers(capPersistedAnswer(partial))
+ );
+ } else {
+ lines.push(
+ ``
+ );
+ }
+ }
+ }
+
+ lines.push(FANOUT_MARKER_CLOSE);
+ return lines.join("\n");
+}
+
+/**
+ * The CLEAN composite (markers stripped) for copy / insert of the whole turn:
+ * readable markdown so the user copies prose, never the invisible markers.
+ */
+export function renderFanoutComposite(
+ turn: FanoutTurn,
+ displayName: (backendId: BackendId) => string
+): string {
+ const { succeeded } = selectSummaryInputs(turn);
+ const succeededIds = new Set(succeeded.map((s) => s.backendId));
+ const sections: string[] = [];
+
+ const summaryText = turn.summary.text.trim();
+ sections.push(summaryText.length > 0 ? `### Summary\n${summaryText}` : "### Summary");
+
+ for (const backendId of Object.keys(turn.answers)) {
+ const name = displayName(backendId);
+ const slot = turn.answers[backendId];
+ if (succeededIds.has(backendId)) {
+ sections.push(`### ${name}\n${slot.text.trim()}`);
+ } else {
+ // A terminal slot keeps partial text if any; an empty one gets the note.
+ const partial = slot.text.trim();
+ sections.push(
+ partial.length > 0 ? `### ${name}\n${partial}` : `### ${name}\n_${FANOUT_NO_ANSWER_NOTE}_`
+ );
+ }
+ }
+
+ return sections.join("\n\n");
+}
+
+/**
+ * Escape a marker attribute value so it can't break the comment or parser: `--`
+ * confuses the HTML comment, `"` ends the attribute, `>` terminates the marker
+ * early. Backend-controlled text flows through here, so all three are neutralized.
+ */
+function escapeMarkerAttr(value: string): string {
+ return value.replace(/--/g, "—").replace(/"/g, "'").replace(/>/g, "›");
+}
+
+/** Read a `key="value"` attribute out of a marker's inner text. */
+function readMarkerAttr(marker: string, key: string): string | undefined {
+ const match = marker.match(new RegExp(`${key}="([^"]*)"`));
+ return match ? match[1] : undefined;
+}
+
+/** Map a serialized status string back to a terminal {@link AgentAnswerStatus}. */
+function statusFromMarker(raw: string | undefined): AgentAnswerStatus {
+ if (raw === "done" || raw === "error" || raw === "cancelled") return raw;
+ // Any unknown/non-terminal value reads as a failed answer.
+ return "error";
+}
+
+/**
+ * Inverse of {@link serializeFanoutComposite}. Returns `null` for a plain/old
+ * message (no composite marker). Reconstructs a {@link FanoutTurn} keying ONLY on
+ * the section markers (the cosmetic `### Heading` lines are ignored); inner text
+ * is marker-unescaped so a literal `/g;
+ type Section = { marker: string; body: string };
+ const sections: Section[] = [];
+ let match: RegExpExecArray | null;
+ let lastMarker: string | null = null;
+ let lastIndex = 0;
+ while ((match = markerRe.exec(body)) !== null) {
+ if (lastMarker !== null) {
+ sections.push({ marker: lastMarker, body: body.slice(lastIndex, match.index) });
+ }
+ lastMarker = match[0];
+ lastIndex = markerRe.lastIndex;
+ }
+ if (lastMarker !== null) sections.push({ marker: lastMarker, body: body.slice(lastIndex) });
+
+ for (const section of sections) {
+ if (section.marker.startsWith("