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("