mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
* feat(agent-mode): @-mention agent selection in composer (phase 1/5, #57) Add an "Agents" group at the top of the existing @-typeahead so users can @-mention installed coding agents (e.g. @claude / @codex / @opencode) to ask the same turn of several agents. The group is registry-driven and installed- only: it lists every backend whose install state is `ready`, so newly registered backends are mentionable automatically with no per-agent branches. Selections render as removable agent pills (reusing the shared Lexical pill / typeahead rendering) and resolve at send time into a structured `mentionedAgents: BackendId[]` (main agent first, deduped if re-mentioned), never left as literal text in the prompt. The host chat-components stay agent-agnostic: a local `AgentMentionBrand` shape is passed down as props, so the generic composer never imports Agent Mode internals. `mentionedAgents` threads through sendMessage -> sendPrompt to a typed seam, `AgentSession.getLastMentionedAgents()`, that later phases consume; the single-agent path (no mentions) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on @-mention agent selection (phase 1/5, #57) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-mode): fan-out orchestration + read-only enforcement (phase 2/5, #57) When a turn has more than one agent (main + ≥1 @-mentioned), runTurn now dispatches the identical prompt + context to every backend in parallel via ephemeral read-only sub-sessions instead of the single backend.prompt(). The single-agent path (0 mentions) is unchanged. Each sub-session is created on its own backend (ensureBackend + newSession), never registered as a visible AgentSession / tab, runs the agent's configured default model, and is closed at turn end. Read-only is enforced three ways: a universal "answer only, no writes" prompt preamble; a shared permission prompter that hard-denies write/exec tool kinds (edit/delete/move/execute) and allows reads/search/fetch for fan-out sub-sessions on ALL backends (claude + codex + opencode); plus a per-backend read-only sandbox mode for setMode-style backends (codex plan→read-only) as belt-and-suspenders. Answers stream live into per-agent slots of a single in-memory FanoutTurn; a failed agent sets its slot to error and the others continue (allSettled-style), and sub-session prompts are cancellable via the turn's abort signal. The summary slot is left pending for Phase 3. Persistence is no-migration: a fan-out turn collapses to summary-only text written as an ordinary assistant message, so per-agent answers are never serialized and existing single-agent transcripts load unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on fan-out orchestration + read-only enforcement (phase 2/5, #57) Classify NotebookEdit as an edit tool kind so the read-only fan-out permission policy denies it. Previously NotebookEdit (a native Claude SDK .ipynb write tool) fell through deriveToolKind to "other", which isWriteOrExecToolKind allows, letting a fan-out read-only sub-session on the Claude SDK backend perform a notebook write. Adds a guard test over every native write/exec tool so this class of gap cannot regress. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-mode): main-agent narrative summary for multi-agent QA (phase 3/5, #57) Once every fan-out answer settles, the session's main agent generates a narrative summary that reconciles and contrasts the per-agent answers, streamed token-by-token into the live FanoutTurn's summary slot (status pending -> streaming -> done) for Phase 4 to render. The summary runs read-only via the Phase 2 mechanism: per-agent answers and the summary now share a single runReadOnlySubSession helper that opens an ephemeral sub-session, registers it as read-only (hard write/exec denial), applies the sandbox mode + default model, streams assistant text, and tears down. The summary feeds the main agent a NEW composed user-turn prompt (read-only instruction + the user's original question + each succeeded answer labeled by display name); no existing system prompt is touched. Failure-aware (D7): the summary runs over the agents that succeeded and names any that failed or returned empty. With zero successes it does not fabricate a summary -- it lands summary.status="done" with a brief all-failed note (the chosen zero-success terminal state), and dispatches no sub-session. Cancellation skips the summary. Persistence is unchanged: the collapse-to-summary-only path from Phase 2 now writes the generated summary text to disk. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on multi-agent QA summary (phase 3/5, #57) Correct doc comments that this commit makes stale: the summary slot is now filled by the main agent over the survivors (D6) once every answer settles, not left pending for a future phase. No behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-mode): summary-first dropdown UI for multi-agent QA (phase 4/5, #57) Render a live multi-agent FanoutTurn as one assistant turn: a summary-first Select switcher (D8) that drills into the main agent's narrative summary or each participating agent's full answer. Each agent entry reflects its live state (D7) — spinner while streaming, the answer when done, an error state on failure — and updates per token as the orchestrator streams into the live turn. The fan-out view renders only for the active/live turn (the last assistant message while AgentSession.getLiveFanoutTurn() is non-null); normal single-agent assistant messages and reloaded summary-only transcripts render exactly as before through the existing path. Branded icons + display names are registry-driven (backendRegistry), and answers/summary reuse the existing AgentMarkdownText renderer. The live turn is surfaced through AgentChatBackend.getLiveFanoutTurn() and the useAgentChatRuntimeState subscription, so only the memoized FanoutTurnView re-renders on the streaming hot path. Pure option-list derivation and status-to-state mapping live in fanoutDropdown.ts with unit tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on multi-agent QA dropdown UI (phase 4/5, #57) The orchestrator mutates one FanoutTurn in place and re-emits the SAME reference per streamed token, so the Phase 4 UI subscription handed that identity straight into React state — setState bailed (Object.is-equal) and the dropdown froze on its first frame, never reflecting live per-agent streaming/status updates. getLiveFanoutTurn() now returns a structural snapshot so each coalesced notify yields a fresh reference and re-renders. Also clear a prior turn's live fan-out state BEFORE the next turn's placeholder is announced (was cleared after notifyMessages fired), so a following single-agent turn's placeholder can no longer briefly render the stale dropdown. Adds snapshotFanoutTurn unit tests and a cross-turn no-leak session test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-mode): partial-failure & cancellation hardening for multi-agent QA (phase 5/5, #57) Harden the two failure modes of the multi-agent read-only QA fan-out so the feature is robust end-to-end: - Partial failure (D7): one agent's rejection/throw was already isolated to its own error slot; add a per-agent answer TIMEOUT so a hung/long-running sub-session fails its OWN slot (error + reason) without stalling the siblings or the summary. Duration is a single documented constant (FANOUT_AGENT_TIMEOUT_MS, 5 min); no user-facing setting. The summary still runs over the survivors. - Cancellation: cancelling the turn aborts every in-flight sub-session promptly (proc.cancel via the run signal) and an abort mid-prompt now transitions the slot to a terminal "cancelled" state instead of being mislabelled "done" or left "running". The sub-session is closed in finally on every exit path (done / aborted / timeout / throw) so no ACP sub-session leaks, and no summary is generated after cancel. - UI: add a distinct "cancelled" agent state (muted slash chip) alongside the refined error chip (short reason, incl. timeouts); both preserve any partial text streamed before the stop. Registry-driven, reuses existing tokens. Tests: one agent rejects -> error slot, others complete, summary over survivors; per-agent timeout fires -> that slot errors, turn + summary still finish; cancel mid-flight -> every in-flight sub-session gets cancel, slots reach terminal cancelled, no summary after cancel; UI maps error + cancelled states. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on partial-failure & cancellation hardening (phase 5/5, #57) Clear the per-agent deadline timer (and remove the abort listener) on the abort path of runPromptWithTimeout. Previously onAbort resolved "aborted" without calling cleanup(), leaving a live 5-minute setTimeout (holding proc / sessionId / prompt) running after the user cancelled the turn. Move cleanup() into both the onAbort and timeout callbacks so every settle path tears down both the timer and the listener exactly once. Add a regression test asserting the deadline timer count is 0 after an abort and that advancing past the deadline never relabels a settled cancelled slot. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): clear stale agent mentions on session switch + terminal summary states (#57) Address Codex review on PR #2628: - P2: reset `mentionedAgentIdsRef` in the session-switch effect so an `@agent` pill composed in one session cannot ride along into an unrelated prompt in another (the ref lives in AgentChatInput, not the keyed ChatInput, so the editor remount does not clear it). - P3: render terminal summary states instead of a perpetual spinner. A cancelled turn leaves the summary `pending` (runSummary skipped) and a failed summary lands `done` with empty text; both now show a "Summary cancelled" / "Summary unavailable" line via the new pure `summaryDisplayState` helper, unit-tested. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): carry fan-out Q+summary into the next single-agent turn (#57) A fan-out turn runs every agent through ephemeral read-only sub-sessions and only writes the summary to the store; the visible session's backend never sees the turn, so a normal single-agent follow-up loses that context. Buffer each completed fan-out turn's {question, summary} on the session and inject the buffered entries (in order) as one labeled <prior_turns> context block ahead of the user message on the next single-agent prompt, then clear. Consecutive fan-out turns accumulate; only single-agent turns flush. Backend-agnostic; live-only, no persistence change. Empty buffer leaves the prompt byte-for-byte unchanged. Addresses Codex P1 on PR #2628. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on fan-out follow-up continuity (#57) Clear the pending fan-out buffer only AFTER backend.prompt() resolves, not before. The block is captured into promptBlocks pre-await, so the prompt sent is unchanged, but a thrown prompt now preserves the buffer so the dropped multi-agent QA context replays on the next single-agent turn instead of being lost with no record. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): pill text leak, read-only plan-file bypass, blank summary persistence (#57) E (pill text leak): AgentPillNode extended BasePillNode without overriding getTextContent(), so the editor serialized the backend id into the prompt (`@Claude …` became `claude …`). Override getTextContent() to return "" — the mention already feeds mentionedAgents structurally and the visual pill comes from decorate(). Other pill types are unchanged. C (read-only fan-out could still write Claude plan files): - (a) Stop abusing canonical.plan as the read-only sandbox. Add an optional ModeMapping.readOnlyModeId for a backend's GENUINE read-only sandbox; set it only on Codex ("read-only"). Claude/OpenCode leave it unset. FanoutOrchestrator .applyReadOnlyMode now applies readOnlyModeId (skipped when absent) instead of canonical.plan, so a read-only QA turn never enters Claude's real plan mode (which drafts and writes plan files). Registry-driven, no name branch. - (b) Make the read-only-session check precede the plan-file auto-allow in the Claude SDK permissionBridge. The bridge now consults a read-only-session predicate (threaded generically via the optional BackendProcess .setReadOnlySessionPredicate, wired from AgentSessionManager.isReadOnlyFanout Session) at the TOP of canUseTool and hard-denies write/exec tools (reusing isWriteOrExecToolKind) BEFORE the plan-file auto-allow — closing the hole even if a mode switch is wrong for some backend. A (summary failure persisted a blank assistant message): when summary generation threw/ended empty but agents answered, the turn collapsed to "" and reloaded as a blank bubble. collapseFanoutTurnToSummaryText now returns a new FANOUT_SUMMARY_UNAVAILABLE note whenever at least one agent succeeded but no summary text exists; zero-success still uses FANOUT_ALL_FAILED_SUMMARY (written by runSummary); a turn with no successes and no summary collapses to "" so nothing misleading is persisted. Per-agent answers remain live-only. Tests: agent pill contributes empty text + prompt omits backend id; read-only session denies a ~/.claude/plans/*.md Write before the auto-allow; applyReadOnly Mode applies readOnlyModeId (never plan) only when advertised; summary-throws- with-answers persists the fallback, zero-success uses the all-failed note, a normal summary is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(agent-mode): give fan-out agents the conversation history (#57) Fan-out QA dispatched every agent to a FRESH ephemeral session that received only the current turn's prompt, so `@agent` follow-ups that reference earlier conversation ("that plan", "the answer above") and fan-out→fan-out continuity failed (Codex finding D on PR #2628). This realizes D9 of the fan-out design: render the prior visible transcript into a read-only `<conversation_history>` block and prepend it to every fan-out agent's prompt, ahead of the current question + context. - New pure `buildConversationHistoryBlock(messages, maxChars)` in fanoutTypes.ts renders prior display messages labeled by role, escaped, framed as read-only context (not a task to redo). Returns null on empty history so the prompt stays byte-for-byte unchanged. - Oldest-first safety cap (`FANOUT_HISTORY_MAX_CHARS` = 48000) with a truncation marker: fan-out sub-sessions are single-turn, so the whole transcript rides in one prompt with nothing to compact; an oversized block would hard-error the model API rather than auto-truncate. - Injection reuses `buildPromptBlocks`'s `leadingContextBlock` seam (same as the single-agent prior-fan-out path); the current turn is excluded by the exact user/placeholder ids `sendPrompt` captured. - The block is backend-prompt only — never displayed or persisted — and every agent (main + mentioned) gets the identical block (D10). Separate from the existing single-agent pending-fan-out buffer, which stays. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): apply user model+effort to fan-out sub-sessions (#57) FanoutOrchestrator.applyDefaultModel applied only the encoded model id to each ephemeral sub-session via setSessionModel. For backends where effort travels via a config option rather than the wire id (Claude SDK: claudeWire drops effort, effortConfigFor exposes the select spec), the sub-session never received the user's configured effort and ran at the backend default. Mirror descriptor.applySelection generically off the wire codec: after setSessionModel, when selection.effort is set and descriptor.wire.effortConfigFor(baseModelId) returns a spec, apply effort with setSessionConfigOption. Wire-encoded-effort backends (codex/opencode suffix style) return nothing from effortConfigFor and need no extra call, so there is no per-agent-name branching. Kept best-effort (try/catch with logWarn) so a missing/unsupported option leaves the backend default in place. The opencode config-option-model catalog case (where set_model itself is gone) remains a documented residual. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on fan-out model+effort apply (#57) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): bound fan-out history cap and include tool/plan parts (#57) Two fixes to the pure buildConversationHistoryBlock used to inject the prior visible transcript into every fan-out QA sub-session. Finding 1 (real cap): the oldest-first drop loop stopped at a single turn, so one giant recent message (a long answer or pasted dump) passed through uncapped and risked a prompt-too-large error in the fresh sub-session. Add a final hard cap: after dropping older turns, if the joined body still exceeds FANOUT_HISTORY_MAX_CHARS, truncate its head and append a "[turn truncated]" marker. The rendered block body is now bounded by ~maxChars plus the small constant framing/markers for ANY input, never unbounded, never empty when there is at least one non-empty turn. Finding 2 (fidelity): the builder rendered only prose and dropped turns whose prose was empty, so a follow-up like "explain the command output above" or "review the plan above" got no context that single-agent backend memory would have carried. Serialize each turn's renderable parts off part kind (no per-agent branching): prose from message (which already aggregates the text parts, so no duplication), tool_call parts as "[tool: <identity>]" plus their text output (trimmed per part so one card can't dominate), and plan parts as their entries. thought parts are omitted. A turn with any renderable content (prose OR tool/plan parts) is no longer dropped; only truly empty turns are skipped. All content stays XML-escaped so it can't break the framing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): settle timed-out fan-out prompts before reusing the backend (#57) On a per-agent prompt timeout/abort the orchestrator called proc.cancel and resolved the slot immediately, but cancel only INTERRUPTS — the underlying proc.prompt() keeps unwinding the backend query. Since run() reuses the main agent's backend for the summary, the summary's prompt could start on the same backend while the main agent's timed-out prompt was still unwinding. The Claude SDK backend's permission-bridge/session context is process-global for the active query, so two overlapping prompts on one backend can misroute permission decisions/events or corrupt the summary. runPromptWithTimeout now holds the real prompt promise and, on the abort AND timeout paths, awaits its actual settlement (swallowed) before resolving the helper — so "settled" means the backend query truly stopped, not just that cancel was requested. That wait is bounded by a new FANOUT_CANCEL_GRACE_MS (3s) constant so a backend that ignores cancel cannot hang the turn forever (it logs and proceeds, preserving the timeout's bounded wall-clock). The happy path (prompt resolves on its own) never enters the grace. Applied uniformly to every sub-session, not special-cased to the main agent. Preserves the single-shot settled guard, clears the deadline + grace timers on all paths (no timer leak), keeps the swallowed prompt rejection from surfacing as unhandled, and keeps abort terminal-cancelled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): address review on timed-out prompt settle (#57) The settle-wait awaited the swallowed prompt via a bare `promptPromise.finally` whose derived promise was only `void`-ed. `.finally` re-raises the original rejection, and a cancelled/timed-out backend prompt usually REJECTS as it unwinds, so that branch leaked an unhandled rejection (the up-front `promptPromise.catch` only guards its own branch, not the `.finally` chain). Replace it with a `promptSettled = promptPromise.then(noop, noop)` that maps both outcomes to a resolved value; the cancel grace awaits `promptSettled`, so the swallowed rejection can never surface as unhandled while settlement semantics (fulfilled OR rejected both count as "the backend query stopped") are unchanged. Add a regression test that rejects the cancelled main prompt and asserts no unhandled rejection plus the summary still reuses the backend. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): preserve replay buffer on cancel + route fan-out model via config-option channel (#57) Finding 1 (UX continuity): the post-fanout single-agent replay cleared the pendingFanoutContext buffer whenever backend.prompt() resolved, including a CANCELLED resolution. A user stopping that first replay mid-stream could lose the multi-agent <prior_turns> context because the backend may not have durably ingested the injected block. The clear is now gated on resp.stopReason !== "cancelled", so a cancelled replay keeps the buffer and re-injects on the next turn (a duplicate re-injection is harmless). The empty-buffer byte-for-byte path and the thrown-prompt-preserves-buffer behavior are unchanged. Finding 2 (UX correct model): FanoutOrchestrator.applyDefaultModel always applied the QA sub-session's model via setSessionModel, which hits the now-gone session/set_model RPC on opencode >= 1.15.13 (where the catalog is a category:"model" config option) and silently left the sub-session on the backend default model. The orchestrator now threads the opened sub-session's BackendState.model.apply spec from newSession and, for a setConfigOption spec, applies the model via setSessionConfigOption (and effort via the model-specific effortConfigId reported by the refreshed state), mirroring AgentSession.applyModelWireId + the opencode descriptor.applySelection. The setSessionModel backends (claude/codex) are unchanged. This resolves the opencode >=1.15.13 model residual. Channel selection is driven off the apply spec, with no per-agent-name branching, and remains best-effort (failures are logged and leave the backend default in place). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): capture trailing ACP chunks before closing fan-out sub-sessions (#57) Some ACP backends (opencode, fast models) flush a turn's final `agent_message_chunk` events just AFTER the `session/prompt` result resolves. Fan-out sub-sessions previously unregistered their per-session update handler immediately once `prompt()` resolved, so those trailing chunks arrived after the handler was gone and were dropped, truncating a fan-out agent's answer or the main-agent summary right at the end. On the normal resolve path only, hold the still-registered handler open for a short bounded grace (FANOUT_TRAILING_CHUNK_GRACE_MS = 500ms) before unregistering and closing the ephemeral sub-session, so late chunks still land in the same slot. Cancel/timeout/throw skip the grace entirely, mirroring the single-agent "except on explicit cancel" carve-out where suppressed output stays suppressed. The grace timer is one-shot and cannot leak; the existing cancel-settle grace, single-shot guard, deadline-timer cleanup, no-unhandled-rejection handling, and terminal slot states are all preserved. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): mark prior image attachments in fan-out history (#57) The fan-out conversation-history renderer built each prior turn's block from prose (message.message) and tool/plan parts only, ignoring message.content — the display array that carries image attachment blocks. As a result an image-only turn (empty prose, no parts) rendered as null and was DROPPED from history entirely, and a text+image turn lost any signal the image existed, so an @agent follow-up like "what's in the screenshot above?" reached the fresh fan-out sessions with no hint of the image. renderTurnContent now counts image attachment blocks in content (defensively narrowed: a non-null object whose type is "image" or "image_url") and appends a concise marker, e.g. "[1 image attachment omitted from history; ...]" (singular/plural correct). An image-only turn now renders the marker and is no longer dropped; a turn with no image content is byte-for-byte unchanged. This only signals that omitted image context existed — threading the actual image bytes into the prompt (full multimodal history) is a tracked follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): include prior-turn context (selections/notes/tabs) in fan-out history (#57) The fan-out conversation-history renderer (buildConversationHistoryBlock / renderTurnContent in fanoutTypes.ts) serialized each prior turn's prose, structured parts, and image markers but never message.context. Fan-out agents run fresh sessions with no memory, so any visible context a prior turn attached (selected-text excerpts, notes, folders, web tabs, urls, tags) was lost — a follow-up like "@codex explain the selected excerpt above" reached them with no excerpt, even though the single-agent backend had seen it inline. renderTurnContent now appends a [context] section rendering the stored context: selection excerpts (label + actual content, per-item trimmed so one can't dominate) plus concise identifier lines for notes/folders/urls/tags/web tabs. Empty/undefined context renders nothing (byte-for-byte unchanged); a context-only turn is no longer dropped. Dynamic values are escaped by the existing outer escapeXml pass, matching the tool/plan/image renderers. Full note/web-tab bodies are read from the vault at prompt time and are not stored on message.context, so only note NAMES + the already-captured selection excerpts are included; threading full note bodies is a tracked follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(agent-mode): preserve note paths and web-tab URLs in fan-out history Fan-out history rendered notes by basename and web tabs by title only, so a fresh fan-out sub-session asked to read the note above or check the page above had no resolvable path/URL. Render n.path and keep the URL alongside each tab title so the actionable identifier survives. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * feat(agent-mode): main agent summarizes only; answerers are the @-mentioned set Pre-ship redesign of multi-agent per-turn QA. The session's main agent no longer auto-joins the answerer list: answerers are the deduped, installed @-mentioned agents (it answers only if explicitly @-ed), and the main agent always writes the summary. Fan-out fires for >=1 answerer except a lone [main], which collapses to the normal single-agent path. The summary always runs, even for a single answerer. Pure routing helpers (resolveAnswerers/isFanout/EMPTY_ANSWERERS) live in a UI-free fanout/answerers.ts so the session and composer share one source of truth without a session->ui dependency. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * feat(agent-mode): persist multi-agent response on the message object with a tabs UI The AI response message now owns its fan-out sub-responses. A live-only message.fanout drives the UI; the full composite is persisted as the message body itself - composite markdown with invisible HTML-comment section markers - so reload reconstructs the dropdown and any unaware loader/export still renders clean markdown. No new on-disk field, no migration; marker-less bodies parse to null and render exactly as before. serializeFanoutComposite/parseFanoutComposite round-trip losslessly (a self-disambiguating two-symbol escape prevents marker forgery AND preserves answer text that contains the escape sentinel); each persisted answer is capped. The store setFanout bumps the message version so the streaming dropdown re-renders without freezing. FanoutTurnView is now a segmented tab row (Summary first/default, brand icon + live status dot per tab) with a per-slot inline copy. The new FanoutMessageCard reuses the existing Agent-Mode AI action bar (Insert/Replace + Copy, no Retry) acting on the whole composite. The liveFanoutTurn side-channel is retired. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * feat(agent-mode): gate the @agent typeahead to paid users (paywall layer 1) Multi-agent per-turn QA is paid-only. Free users get the frozen EMPTY_AGENT_MENTION_BRANDS, so the @-mention 'Agents' group never renders and no agent pill can be inserted (typeahead, paste/text-parse, and draft-restore paths all verified closed). The gate is reactive via useCanUseMultiAgent(), so it flips live on upgrade/downgrade; paid users hit the unchanged path. A subtle conditional upsell hint above the composer points free users to upgrade (navigateToPlusPage with a new MULTI_AGENT UTM medium). canUseMultiAgent()/useCanUseMultiAgent() mirror isPlusEnabled()/useIsPlusUser() respectively; their intentional sync-vs-reactive delta on the unvalidated self-host edge is documented. A determined free user pasting agent-pill HTML is caught by the send-time backend check in the next phase. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * feat(agent-mode): authoritative send-boundary paywall for multi-agent QA (layer 2) The session boundary is the unbypassable gate: in runTurn, before any fan-out work, ensureMultiAgentEntitlement() must pass. Paying users hit a sync isPlusEnabled() fast path with zero network cost; otherwise it re-verifies against the backend /license endpoint (covering a stale-false cache for a real paid user) and blocks on a negative or unverifiable result. A blocked turn is a hard stop - no silent single-agent fallback - that shows an upgrade prompt (Notice + the MULTI_AGENT upgrade page), marks the placeholder as an error, settles as 'refusal', and returns the session to idle so the user can resend. This catches any agent pill that slips past the UI gate (e.g. pasted pill HTML). app is threaded via getApp DI, never the global. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): resolve send-time mentions against the real installed set, not the paywalled list The @-mention typeahead list is entitlement-gated (empty for free users), but it was also reused as the send-time installed-agent allowlist. So a pasted/imported agent pill (or a stale-false Plus cache) resolved to answerers=[], omitted mentionedAgents, and silently ran single-agent - never reaching AgentSession's authoritative entitlement gate. Resolve installed backends independently of the gated UI list so such turns fan out and hit the session gate, which blocks or re-verifies. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): make the fan-out summary concise and answer-focused The summary instruction asked for a reconcile-and-contrast narrative with no length bound, so it ballooned and analyzed the environment scaffolding (tool lists, skill/agent catalogs) that agents dump into their answers. Rewrite it to give one direct, concise answer scaled to the question, ignore that scaffolding, and stop narrating each agent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): one context-aware copy/insert for fan-out, drop the per-slot row The per-slot copy sat in its own full-width hover row above each answer, and the action bar separately copied the whole composite - redundant and visually heavy on a single-agent tab. Collapse to ONE affordance: the card's action bar Copy/Insert now act on the tab in view (summary tab copies the summary, an agent tab copies that agent - copy what you see). FanoutTurnView becomes controlled so the card owns the selected tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): persist a terminal fan-out agent's partial text on reload A cancelled/errored agent that streamed partial text shows it in the live tab (FanoutTerminalState), but serialization wrote every non-succeeded slot as a body-less 'did not answer' marker, so autosave/reload lost output the user saw (and an all-terminal turn could reload with no agent bodies). Persist non-empty terminal text (with its status, and the error reason in an error attr) so reload matches the live tab; truly empty slots still get the body-less note. The summary still excludes terminal slots. Reverses the earlier deliberate drop-partial choice in favor of live/reload consistency. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): summary must report the agents' answers, not answer as itself The prior instruction said 'give a direct answer to the question', so for a first-person question (e.g. 'what model are you') the summarizer answered about ITSELF instead of synthesizing the agents' answers. Reframe it as a third-party reconciler: it summarizes what THE AGENTS said, never substitutes its own identity/opinion, attributes claims when it matters, and stays concise. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): restore whole-composite copy/insert via the Summary tab The minimal copy redesign dropped any way to copy the whole turn. Bring it back without the old redundant top row: the single context-aware action bar now copies/inserts the WHOLE composite on the Summary tab (the default, so copy-all is one click) and just the selected agent on an agent tab. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): escape > in fan-out marker attributes Backend-controlled error text (now persisted in an error= marker attribute) can contain >, which terminated the agent marker early because parseFanoutComposite matches with agent[^>]*, corrupting the reloaded turn. Neutralize > (like the existing -- and " escapes) so such markers always parse. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): replay the answers when a fan-out summary is unavailable When the summary failed/cancelled but agents answered, only the generic 'summary unavailable' note was buffered for single-agent continuity, so a follow-up like 'what did they say?' lost the per-agent answers the user saw and that are persisted in the composite. Replay the readable answers (renderFanoutComposite) in that case; a real summary is still preferred when present. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): persist partial answers on cancel; tighten summary prompt boundaries Two fixes to fan-out content fidelity and the summary: - Persist-on-cancel (Codex P2): the content gate only counted summary/succeeded text, so a turn cancelled mid-stream (partials, no done slot) saved nothing even though serializeFanoutComposite can now persist partials. Count any non-empty slot text, and include terminal partial text in renderFanoutComposite so the composite, copy-all, and single-agent replay all match the live tabs. - Summary boundaries: the summarizer kept adding meta ('the agents converge…', 'only environment scaffolding', 'Note: …'). The instruction now forbids mentioning agent counts, who did/didn't answer, scaffolding, or any caveats, and buildSummaryUserPrompt no longer tells it to 'note this gap' — failed agents are omitted from the prompt entirely so it can't mention them. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): use the sigma thinking spinner in the fan-out response area The body's thinking/streaming/waiting/writing states used a generic Loader2 ring; swap them to the app's animated sigma (Σ) CopilotSpinner so multi-agent matches the thinking/reasoning indicator everywhere else. The tab status dots keep their compact icons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): bound fan-out per-agent setup against cancel and timeout Only the per-agent prompt was raced against cancel + the timeout; the SETUP phase (ensureBackendForFanout, newSession, read-only-mode/default-model round-trips) was not, so a cold or wedged backend whose session/new never returned could hang the whole fan-out turn with Stop unable to settle it. Generalize runPromptWithTimeout into runAttemptWithTimeout, bounding the ENTIRE attempt (setup + prompt) by one FANOUT_AGENT_TIMEOUT_MS deadline + the abort signal. A wedged setup now settles promptly: cancelled on abort, errored on timeout, without awaiting the stuck promise. Teardown stays in the attempt's single finally so a late-resolving newSession is still cancelled (no orphan sub-session); timer + abort listener are cleared on every settle path; the prompt-phase cancel grace (FANOUT_CANCEL_GRACE_MS) and trailing-chunk handling are preserved (and now correctly skipped on the timeout path too). Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): high-quality, strictly-attributed fan-out summary prompt The summarizer kept answering in the first person ('I am powered by…') because the agents' answers are first-person and it mirrored them. Rewrite the summary instruction as a structured spec: strict THIRD-PERSON attribution by agent name (explicitly converting the agents' 'I/my' into '<agent> says it…', no sentence may begin with 'I'); per-agent / agreements / disagreements sections for two or more answers, degrading to a short attributed summary for a single agent; and the existing scope guards (ignore scaffolding, never mention counts or non-answerers). Uses generic placeholder examples, no hardcoded model/agent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): settle a pre-aborted fan-out turn before launching agents If Stop is pressed during the async work before fan-out dispatch (license re-verify, web-tab serialization), the signal is already aborted when runAttemptWithTimeout runs; the abort listener it arms never fires for an already-fired signal, so agents still opened sub-sessions and dispatched read-only prompts, only marked cancelled after they finished/timed out. Check signal.aborted right after arming the listener and settle via the same cancel path without starting the attempt, so no sub-session opens and no prompt runs after Stop. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): replay answers after an interrupted/partial fan-out summary The continuity buffer preferred summary.text whenever it was non-empty, but a summary that streamed partial text then was cancelled or errored is incomplete — runSummary's finally forces status to 'done' regardless, so status couldn't tell them apart, and a follow-up like 'what did they say?' got only the partial summary while the full answers sat unused in the composite. Add FanoutSummary.complete (live-only), set by runSummary ONLY on a clean finish (or the all-failed terminal). The replay trusts summary.text only when complete; otherwise it falls back to the composite (answers), like the no-summary path. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): don't dispatch a fan-out prompt after the race already settled If abort/timeout won while the attempt was still in setup, the detached attempt later reached prompt() anyway — onPrompt only cancelled AFTER the backend query started, so a timed-out/cancelled agent could still run a read-only prompt in the background and, for a main-agent timeout, overlap the later summary on the same backend. Check raceSettled() before dispatch and just tear down the late sub-session instead. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): strip fan-out serialization markers from conversation history A fan-out turn's persisted message body is the composite — HTML-comment section markers plus marker-escaped content. buildConversationHistoryBlock fed that raw into <conversation_history>, so a later @agent turn saw hidden markers, status/error attributes, and the escape sentinel that were never visible to the user. Render the clean composite from the live/parsed fanout turn instead (falling back to parsing the body); non-fan-out messages are unchanged. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * feat(agent-mode): adaptive two-mode fan-out summary (answer vs deliverable) The agreements/disagreements form only suits convergent questions. When the agents each produce an ALTERNATIVE artifact (a rewrite, draft, message, code, plan), those are options to choose between, not claims to reconcile. The summary prompt now first picks a mode: ANSWER mode keeps the each-agent / agreements / disagreements reconciliation; DELIVERABLE mode instead lists what is DISTINCTIVE about each option (angle, tone, structure, who it suits) and gives a Recommendation (pick one, or which parts to merge), without reproducing the artifacts. The summarizer detects the mode itself, so the user need not flag it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): require the full composite wrapper before parsing a fan-out body parseFanoutComposite keyed on a mere marker substring, so a normal assistant answer that just MENTIONS the format (e.g. <!--copilot:...--> in a code block) parsed to a non-null empty turn; loadMessages then set message.fanout and reload rendered the fan-out card instead of the real answer, hiding it. Require the COMPLETE wrapper (a version-agnostic open marker AND the close marker) and reject an empty parse (no summary text and no agent sections). Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): cap agent answers fed into the summary prompt buildSummaryUserPrompt concatenated each succeeded answer in FULL, with no cap (unlike the persisted and history paths), so one huge answer (a long file excerpt or tool dump) could push the summary sub-session past its context window or time it out, leaving an otherwise-useful turn with no summary. Truncate each answer to FANOUT_SUMMARY_ANSWER_MAX_CHARS (tighter than the persisted cap, since several answers stack into one model input) with a marker. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): serialize live fan-out state on autosave (no blank bubble) A fan-out turn streams into message.fanout; its composite body is written to message.message only at completion. If autosave fired mid-turn (a long turn outliving the debounce) and the user then reloaded/closed/crashed, formatChatContent serialized only the empty message.message, saving a blank assistant bubble even though per-agent text was visible. Serialize the live fanout (backend-id labels; the completed turn later overwrites the body with display-name labels) so the streamed answers survive an interrupted autosave. Fixes Codex P2. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * docs(agent-mode): justify the multi-agent fan-out PR scope and design Add designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md: a full accounting of the ~7.2k-line PR (54% tests, fan-out core isolated in a new folder, additive cross-cutting edits) plus the design rationale for routing, the composite message object, backward-compatible persistence, isolation/timeouts/caps, the adaptive summary prompt, and the two-layer paywall. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): stop fan-out sub-sessions leaking into Recent Chats Fan-out sub-sessions open a real newSession on the backend; opencode/codex persist it to disk and the native-discovery sweep then lists each as a phantom Recent Chats entry with an auto-generated title. proc.cancel ends the query but does not delete the session, so the prior "never persisted" comment was wrong for those backends. Tombstone each sub-session id on creation (via the existing index tombstone honored by mergeDiscoveredSessions) so the sweep skips it, even across restarts. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * test(agent-mode): slim fan-out test suite to load-bearing cases Cut the multi-agent fan-out test suite from ~3,872 to ~1,800 added lines: keep one happy-path plus the load-bearing edges per module (serialize/parse +escape round-trip, routing, both paywall layers, one orchestrator success/error/cancel/ summary-unavailable, persistence non-blank, one continuity replay, read-only safety gating) and drop exhaustive timing/grace permutations and most pure-render UI assertions. Full suite green (3,844). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * refactor(agent-mode): trim verbose comments on fan-out code to STYLE_GUIDE minimum Comment-only pass over the new fan-out/paywall production code: condense or drop multi-line JSDoc that restated the code and decision-ref narration, keeping the non-obvious WHY notes (escape correctness, cancel/settle ordering, read-only invariants, referential stability). Pure comment changes; no code/behavior change. Prod added lines 3,326 -> 2,886. Suite green (3,844). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * refactor(agent-mode): drop tool/plan from fan-out history, trim history comments, slim doc Phase 4 (behavior-preserving golf): the fan-out conversation-history block no longer renders agent-internal tool-call/plan cards (all USER context kept: prose, image markers, selections/notes/web-tabs). Trim the history-machinery comments to the STYLE_GUIDE minimum and inline a single-use helper. Slim the architecture doc to a concise reference. The orchestrator trailing-chunk grace is intentionally left intact (it captures answer text some backends flush after prompt resolve). Suite green (3,844). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * refactor(agent-mode): trim plusUtils multi-agent entitlement comments Comment-only condensation of the multi-agent paywall helpers' JSDoc (left over from the Phase 3 comment pass). No code or behavior change. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): close fan-out sub-session sweep race; restore sentinel test; drop dead helper - Recent Chats leak: the index tombstone is async/fire-and-forget, so a native listSessions sweep racing the in-flight turn could still surface an ephemeral fan-out sub-session. Also skip currently-registered read-only sub-sessions in the sweep (closes the window before the tombstone lands). - Restore the serializer collision test's PUA escape sentinel (was reduced to an empty string during the test slim, dropping coverage of the raw-sentinel path). - Remove the now-dead collapseFanoutTurnToSummaryText helper and FANOUT_SUMMARY_UNAVAILABLE (composite persistence superseded them; their comments wrongly said "summary only"). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): deny unknown MCP tools in read-only fan-out turns An MCP tool whose name isn't a known built-in derives to kind `other`, which the read-only gate otherwise allows — so a third-party mutating tool like mcp__filesystem__write_file could run inside a read-only QA sub-session, breaking the no-writes guarantee. Fail safe: deny unknown MCP tools (mcpServer present + kind `other`) in read-only sessions; known-classified MCP reads still pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * docs(agent-mode): document fan-out history contract + deferred design debt Record the intentional history tradeoff (prose + user context, no tool/plan cards) and the read-only MCP fail-safe, and capture the deferred design follow-ups (host sub-session primitive, fanoutTypes split, explicit summary status enum) so they aren't lost. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): deny unknown ACP MCP tools in read-only; fingerprint live fan-out in autosave - Read-only consistency: the ACP permission prompter (opencode/codex) allowed kind `other`, so an unknown/MCP tool could run in a read-only fan-out turn — the same hole already closed on the Claude SDK bridge. Deny `other` there too (read/search/fetch/think/switch_mode still pass). - Autosave de-dupe keyed only on the last message's text, which stays empty for an in-flight fan-out turn, so mid-stream saves were skipped and a crash kept only the first partial snapshot. Fold a fingerprint of the live fan-out slots (per-answer status+length, summary status+length) into the signature. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG * fix(agent-mode): anchor @-mentions to the active session, not a cold-starting backend The fan-out mention anchor (mainAgentId) preferred getStartingBackendId() over the active session's backend. While another backend cold-starts in a different tab, startingBackendId is set before the new session becomes active, so a queued prompt from the visible chat (e.g. Claude) that mentions the starting backend (e.g. @opencode) saw mainAgentId === opencode — the degenerate single-answerer case — and silently flushed back as a single-agent turn instead of fanning out. Anchor to the active session's backend when one exists; fall back to the starting backend only for the initial no-session startup. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Swdw8vKE3zczYJFzAboWG --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
16fedfbae1
commit
2cf80bc411
49 changed files with 4684 additions and 46 deletions
91
designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md
Normal file
91
designdocs/MULTI_AGENT_FANOUT_ARCHITECTURE.md
Normal file
|
|
@ -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<br/>(main agent only if explicitly @-ed)"]
|
||||
A --> AN["each answerer runs in a FRESH read-only<br/>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<br/>→ Summary-first segmented dropdown"]
|
||||
M --> SAVE["persist the composite as the message body<br/>(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
|
||||
`<!--copilot:` in answer text is escaped on write, restored on read.
|
||||
- **Isolation + caps.** Each answerer runs read-only (writes/exec auto-denied via
|
||||
`permissionPrompter`), with a per-agent timeout and char caps bounding both the
|
||||
summary prompt input and the persisted transcript.
|
||||
- **Two-layer paywall.** Free users get no `@agent` typeahead; if a pill is still
|
||||
present, the send boundary re-verifies entitlement (paying users skip the
|
||||
network call) and blocks with an upgrade prompt otherwise.
|
||||
- **History contract (intentional tradeoff).** The `<conversation_history>` fed
|
||||
to each sub-session carries the user-facing context only: prose plus image
|
||||
markers, selections, notes, and web tabs. Agent-internal tool-call and plan
|
||||
cards are deliberately omitted to keep the renderer lean, so a follow-up like
|
||||
"explain the command output above" can miss its referent when the prior turn
|
||||
was a tool/plan card with no prose. Restoring a bounded tool/plan renderer is a
|
||||
one-function change (`renderTurnContent`) if that gap matters in practice.
|
||||
- **Read-only is fail-safe for MCP.** An unknown MCP tool (kind `other`) can't be
|
||||
verified read-only, so the permission bridge denies it in a sub-session;
|
||||
known-classified MCP reads still pass.
|
||||
|
||||
## Deferred design debt
|
||||
|
||||
Captured for follow-up; intentionally not in the v1 PR to keep it merge-focused:
|
||||
|
||||
- Move "prepare / own / clean up an ephemeral read-only backend session" into a
|
||||
single host primitive (`openReadOnlySubSession`) so the orchestrator only runs
|
||||
prompts, races timeout/cancel, and summarizes. Reduces lifecycle coupling.
|
||||
- Split `fanoutTypes.ts` by responsibility (composite, prompt, history) for
|
||||
auditability (not a line reduction).
|
||||
- Model the summary as one explicit status (`pending|streaming|done|error|cancelled`)
|
||||
instead of `status` plus a live-only `complete` flag.
|
||||
|
||||
## Reload caveat
|
||||
|
||||
A fan-out turn (answers and summary) runs entirely on ephemeral sub-sessions the
|
||||
main backend never records, so it reloads ONLY from the autosaved markdown note
|
||||
(`parseFanoutComposite` rebuilds the dropdown). A chat reopened via native
|
||||
backend resume (no note) cannot show the turn. The fan-out sub-sessions are
|
||||
tombstoned on creation so they never leak into Recent Chats as phantom entries.
|
||||
|
||||
## Where to start
|
||||
|
||||
`fanout/answerers.ts` (routing) → `fanoutTypes.ts` (types + serialize/parse) →
|
||||
`FanoutOrchestrator.ts` (state machine) → `AgentSession.runFanoutPath`
|
||||
(integration) → `FanoutTurnView.tsx` + `fanoutDropdown.ts` (render) →
|
||||
`plusUtils.ts` (gate).
|
||||
</content>
|
||||
|
|
@ -189,6 +189,9 @@ export const CodexBackendDescriptor: BackendDescriptor = {
|
|||
return {
|
||||
kind: "setMode",
|
||||
canonical: { default: "auto", plan: "read-only", auto: "full-access" },
|
||||
// Codex's `read-only` preset is a genuine no-write/no-exec sandbox, so it
|
||||
// doubles as the fan-out read-only sandbox mode.
|
||||
readOnlyModeId: "read-only",
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -179,7 +179,8 @@ export function createAgentSessionManager(app: App, plugin: CopilotPlugin): Agen
|
|||
// happen before assignment below.
|
||||
let managerRef: AgentSessionManager | null = null;
|
||||
const prompter = createDefaultPermissionPrompter(
|
||||
(id) => managerRef?.getSessionByBackendId(id) ?? null
|
||||
(id) => managerRef?.getSessionByBackendId(id) ?? null,
|
||||
(id) => managerRef?.isReadOnlyFanoutSession(id) ?? false
|
||||
);
|
||||
const askUserQuestionPrompter = createDefaultAskUserQuestionPrompter(
|
||||
(id) => managerRef?.getSessionByBackendId(id) ?? null
|
||||
|
|
|
|||
|
|
@ -175,6 +175,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
|
|||
private permissionPrompter: ((req: PermissionPrompt) => Promise<PermissionDecision>) | null =
|
||||
null;
|
||||
private askUserQuestionPrompter: AskUserQuestionPrompter | null = null;
|
||||
private isReadOnlySession: ((sessionId: SessionId) => boolean) | null = null;
|
||||
private exitListeners = new Set<() => void>();
|
||||
private shuttingDown = false;
|
||||
private readonly bridge: PermissionBridge;
|
||||
|
|
@ -196,6 +197,7 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
|
|||
getPrompter: () => this.permissionPrompter,
|
||||
getAskUserQuestionPrompter: () => this.askUserQuestionPrompter,
|
||||
isPlanModePlanFilePath: opts.isPlanModePlanFilePath,
|
||||
getIsReadOnlySession: () => this.isReadOnlySession,
|
||||
});
|
||||
logInfo(
|
||||
`[AgentMode] ClaudeSdkBackendProcess constructed (claude=${opts.pathToClaudeCodeExecutable})`
|
||||
|
|
@ -215,6 +217,10 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
|
|||
this.permissionPrompter = fn;
|
||||
}
|
||||
|
||||
setReadOnlySessionPredicate(fn: (sessionId: SessionId) => boolean): void {
|
||||
this.isReadOnlySession = fn;
|
||||
}
|
||||
|
||||
setAskUserQuestionPrompter(fn: AskUserQuestionPrompter): void {
|
||||
this.askUserQuestionPrompter = fn;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -308,6 +308,85 @@ describe("PermissionBridge.canUseTool", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("read-only fan-out session gating", () => {
|
||||
it("denies a plan-file Write BEFORE the plan-file auto-allow when the session is read-only", async () => {
|
||||
const planMatcher = jest.fn((p: string) => p.endsWith("/.claude/plans/foo.md"));
|
||||
const prompter = jest.fn();
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
isPlanModePlanFilePath: planMatcher,
|
||||
// The current session is a read-only fan-out sub-session.
|
||||
getIsReadOnlySession: () => () => true,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
|
||||
const result = await bridge.canUseTool(
|
||||
"Write",
|
||||
{ file_path: "/Users/x/.claude/plans/foo.md", content: "# plan" },
|
||||
ctx
|
||||
);
|
||||
|
||||
// The read-only deny fires first: the plan-file auto-allow never runs and
|
||||
// the prompter is never consulted.
|
||||
expect(result.behavior).toBe("deny");
|
||||
if (result.behavior === "deny") {
|
||||
expect(result.message).toContain("Read-only QA turn");
|
||||
}
|
||||
expect(planMatcher).not.toHaveBeenCalled();
|
||||
expect(prompter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still allows reads in a read-only session (only write/exec are denied)", async () => {
|
||||
const prompter = jest.fn(async () => ({
|
||||
outcome: { outcome: "selected" as const, optionId: "allow_once" as const },
|
||||
}));
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
getIsReadOnlySession: () => () => true,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
|
||||
const result = await bridge.canUseTool("Read", { file_path: "/tmp/a.md" }, ctx);
|
||||
// A read tool falls through to the normal prompter path.
|
||||
expect(prompter).toHaveBeenCalled();
|
||||
expect(result.behavior).toBe("allow");
|
||||
});
|
||||
|
||||
it("denies an UNKNOWN MCP tool (kind 'other') in a read-only session — fail safe", async () => {
|
||||
const prompter = jest.fn();
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
getIsReadOnlySession: () => () => true,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
|
||||
// A third-party MCP tool whose name isn't a known built-in derives to
|
||||
// `other`; it can't be verified read-only, so the gate must deny it.
|
||||
const result = await bridge.canUseTool("mcp__notion__create_page", { title: "x" }, ctx);
|
||||
expect(result.behavior).toBe("deny");
|
||||
expect(prompter).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not gate writes when the session is NOT read-only (plan auto-allow still applies)", async () => {
|
||||
const prompter = jest.fn();
|
||||
const bridge = new PermissionBridge({
|
||||
getPrompter: () => prompter,
|
||||
isPlanModePlanFilePath: (p) => p.endsWith("/.claude/plans/foo.md"),
|
||||
getIsReadOnlySession: () => () => false,
|
||||
});
|
||||
bridge.setSessionContext("session-1");
|
||||
|
||||
const result = await bridge.canUseTool(
|
||||
"Write",
|
||||
{ file_path: "/Users/x/.claude/plans/foo.md", content: "# plan" },
|
||||
ctx
|
||||
);
|
||||
// Not read-only → the plan-file auto-allow proceeds as before.
|
||||
expect(result.behavior).toBe("allow");
|
||||
expect(prompter).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanMode handling", () => {
|
||||
it("synthesizes a prompt with switch_mode kind and isPlanProposal=true", async () => {
|
||||
let captured: PermissionPrompt | null = null;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import type {
|
|||
} from "@/agentMode/session/types";
|
||||
import { PERMISSION_OPTION_KINDS } from "@/agentMode/session/types";
|
||||
import { resolveToolName } from "@/agentMode/session/toolName";
|
||||
import { isWriteOrExecToolKind } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import { err2String } from "@/utils";
|
||||
import { logSdkInbound, logSdkOutbound } from "./sdkDebugTap";
|
||||
import { deriveToolKind, deriveToolTitle, vendorMetaFields } from "./toolMeta";
|
||||
|
|
@ -57,6 +58,17 @@ export interface PermissionBridgeOptions {
|
|||
* prompter like any other tool.
|
||||
*/
|
||||
isPlanModePlanFilePath?: (absolutePath: string) => boolean;
|
||||
/**
|
||||
* Lazily fetch the predicate deciding whether a backend session is an
|
||||
* ephemeral read-only fan-out QA sub-session. Lazy (like `getPrompter`) so
|
||||
* the manager can register it after the backend is constructed. Consulted at
|
||||
* the TOP of `canUseTool`, BEFORE the plan-file auto-allow: a read-only
|
||||
* session hard-denies every write/exec tool (including plan-file `Write`s) so
|
||||
* the auto-allow can never reopen a write path during a read-only QA turn.
|
||||
* Closes the hole generically even if a mode switch failed to sandbox the
|
||||
* backend.
|
||||
*/
|
||||
getIsReadOnlySession?: () => ((sessionId: SessionId) => boolean) | null;
|
||||
}
|
||||
|
||||
export class PermissionBridge {
|
||||
|
|
@ -89,6 +101,30 @@ export class PermissionBridge {
|
|||
sessionId
|
||||
);
|
||||
|
||||
// Read-only fan-out QA sub-sessions hard-deny writes/exec BEFORE the
|
||||
// plan-file auto-allow below, so a read-only turn can never finalize a
|
||||
// plan file (or any other write) even if the sandbox mode switch was wrong
|
||||
// for this backend. Reads/searches/fetches fall through to the normal path
|
||||
// (the prompter then allows them).
|
||||
const isReadOnlySession = this.opts.getIsReadOnlySession?.();
|
||||
if (sessionId && isReadOnlySession?.(sessionId)) {
|
||||
const { tool, mcpServer } = resolveToolName(toolName);
|
||||
const kind = deriveToolKind(tool, mcpServer);
|
||||
// An MCP tool whose name isn't a known built-in derives to `other`, which
|
||||
// is otherwise allowed. We can't verify a third-party MCP tool is
|
||||
// read-only (e.g. `mcp__filesystem__write_file`), so fail safe and deny
|
||||
// unknown MCP tools in a read-only QA turn; known-classified MCP reads
|
||||
// (read/search/fetch) still fall through.
|
||||
const isUnverifiableMcpTool = Boolean(mcpServer) && kind === "other";
|
||||
if (isWriteOrExecToolKind(kind) || isUnverifiableMcpTool) {
|
||||
return this.deny(
|
||||
"canUseTool:response",
|
||||
"Read-only QA turn: write and exec tools are disabled.",
|
||||
sessionId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (toolName === "Write") {
|
||||
const filePath = typeof input.file_path === "string" ? input.file_path : null;
|
||||
if (filePath && this.opts.isPlanModePlanFilePath?.(filePath)) {
|
||||
|
|
|
|||
36
src/agentMode/sdk/toolMeta.test.ts
Normal file
36
src/agentMode/sdk/toolMeta.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { isWriteOrExecToolKind } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import { deriveToolKind } from "./toolMeta";
|
||||
|
||||
describe("deriveToolKind", () => {
|
||||
it("classifies native read tools as read", () => {
|
||||
for (const name of ["Read", "Glob", "Grep", "LS"]) {
|
||||
expect(deriveToolKind(name)).toBe("read");
|
||||
}
|
||||
});
|
||||
|
||||
it("classifies native fetch tools as fetch", () => {
|
||||
for (const name of ["WebSearch", "WebFetch"]) {
|
||||
expect(deriveToolKind(name)).toBe("fetch");
|
||||
}
|
||||
});
|
||||
|
||||
// Read-only fan-out leans on `deriveToolKind` to label every native Claude
|
||||
// file-mutation / shell tool as a write/exec kind so the shared permission
|
||||
// prompter denies it. A tool that falls through to `"other"` would be ALLOWED
|
||||
// in a read-only sub-session — that is the gap this guard catches. NotebookEdit
|
||||
// (.ipynb writes) regressed here once: it was not in any branch and slipped
|
||||
// through as `"other"`.
|
||||
it("classifies every native Claude write/exec tool so read-only fan-out denies it", () => {
|
||||
const writeOrExecTools = ["Write", "Edit", "MultiEdit", "NotebookEdit", "Bash"];
|
||||
for (const name of writeOrExecTools) {
|
||||
const kind = deriveToolKind(name);
|
||||
expect(isWriteOrExecToolKind(kind)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("routes native plan tools to switch_mode (not an MCP tool of the same name)", () => {
|
||||
expect(deriveToolKind("ExitPlanMode")).toBe("switch_mode");
|
||||
expect(deriveToolKind("EnterPlanMode")).toBe("switch_mode");
|
||||
expect(deriveToolKind("ExitPlanMode", "some-mcp")).toBe("other");
|
||||
});
|
||||
});
|
||||
|
|
@ -38,7 +38,7 @@ export function deriveToolKind(toolName: string, mcpServer?: string): AgentToolK
|
|||
if (lower === "read" || lower === "glob" || lower === "grep" || lower === "ls") {
|
||||
return "read";
|
||||
}
|
||||
if (lower === "write" || lower === "edit" || lower === "multiedit") {
|
||||
if (lower === "write" || lower === "edit" || lower === "multiedit" || lower === "notebookedit") {
|
||||
return "edit";
|
||||
}
|
||||
if (lower === "bash") return "execute";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
AgentChatMessage,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
BackendId,
|
||||
BackendState,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
|
|
@ -22,10 +23,19 @@ import type {
|
|||
*/
|
||||
export interface AgentChatBackend {
|
||||
subscribe(listener: () => void): () => void;
|
||||
/**
|
||||
* Append a user message and start the turn. `mentionedAgents` is the resolved
|
||||
* answerer selection (the deduped `@`-mentioned installed agents, which may or
|
||||
* may not include the main agent). Present only when the turn fans out; absent
|
||||
* for the single-agent path (no qualifying mentions, or only the main agent
|
||||
* `@`-ed). Consumed by the fan-out orchestration via
|
||||
* `AgentSession.getLastMentionedAgents()`; the main agent summarizes separately.
|
||||
*/
|
||||
sendMessage(
|
||||
text: string,
|
||||
context?: MessageContext,
|
||||
promptContent?: PromptContent[]
|
||||
promptContent?: PromptContent[],
|
||||
mentionedAgents?: ReadonlyArray<BackendId>
|
||||
): { id: string; turn: Promise<void> };
|
||||
cancel(): Promise<void>;
|
||||
deleteMessage(id: string): Promise<boolean>;
|
||||
|
|
|
|||
|
|
@ -126,6 +126,33 @@ describe("AgentChatPersistenceManager", () => {
|
|||
expect(loaded.messages[1].message).toBe("hi back");
|
||||
});
|
||||
|
||||
it("serializes a mid-stream fan-out turn so an interrupted autosave isn't blank", async () => {
|
||||
// A long fan-out turn whose composite body has NOT been written to `message`
|
||||
// yet (still streaming), saved mid-turn (reload/close/crash). The live fanout
|
||||
// must be serialized so the streamed per-agent text survives, not a blank bubble.
|
||||
const fanoutMsg: AgentChatMessage = {
|
||||
id: "msg-2",
|
||||
sender: AI_SENDER,
|
||||
message: "",
|
||||
isVisible: true,
|
||||
timestamp: { epoch: 2, display: "2026/01/01 12:00:00", fileName: "20260101_120000" },
|
||||
fanout: {
|
||||
answers: {
|
||||
opencode: { backendId: "opencode", status: "running", text: "partial opencode answer" },
|
||||
},
|
||||
summary: { status: "streaming", text: "" },
|
||||
},
|
||||
};
|
||||
const saved = await manager.saveSession(
|
||||
[makeMessage(USER_SENDER, "q"), fanoutMsg],
|
||||
"claude",
|
||||
{}
|
||||
);
|
||||
const file = app.files.get(saved!.path)!;
|
||||
const loaded = await manager.loadFile(file as unknown as TFile);
|
||||
expect(loaded.messages[1].message).toContain("partial opencode answer");
|
||||
});
|
||||
|
||||
it("escapes and round-trips a label containing quotes and backslashes", async () => {
|
||||
const tricky = 'has "quotes" and \\backslashes\\';
|
||||
const messages = [makeMessage(USER_SENDER, "hi")];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { serializeFanoutComposite } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import { AGENT_CHAT_MODE, AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { logError, logInfo, logWarn } from "@/logger";
|
||||
import { getSettings } from "@/settings/model";
|
||||
|
|
@ -288,7 +289,17 @@ export class AgentChatPersistenceManager {
|
|||
return messages
|
||||
.map((m) => {
|
||||
const ts = m.timestamp ? m.timestamp.display : "Unknown time";
|
||||
return `**${m.sender}**: ${m.message}\n[Timestamp: ${ts}]`;
|
||||
// A fan-out turn streams into `m.fanout`; its composite body is written
|
||||
// to `m.message` only at completion. If autosave fires mid-turn (a long
|
||||
// turn outliving the debounce, then reload/close/crash), serialize the
|
||||
// LIVE fanout so the saved chat keeps the streamed per-agent text instead
|
||||
// of a blank assistant bubble. Backend ids label the sections here; the
|
||||
// completed turn later overwrites `m.message` with display-name labels.
|
||||
const body =
|
||||
m.message.length === 0 && m.fanout
|
||||
? serializeFanoutComposite(m.fanout, (id) => id)
|
||||
: m.message;
|
||||
return `**${m.sender}**: ${body}\n[Timestamp: ${ts}]`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
AgentChatMessage,
|
||||
AgentQuestionAnswers,
|
||||
AskUserQuestionPrompt,
|
||||
BackendId,
|
||||
BackendState,
|
||||
CurrentPlan,
|
||||
PermissionPrompt,
|
||||
|
|
@ -59,9 +60,15 @@ export class AgentChatUIState implements AgentChatBackend {
|
|||
sendMessage(
|
||||
text: string,
|
||||
context?: MessageContext,
|
||||
promptContent?: PromptContent[]
|
||||
promptContent?: PromptContent[],
|
||||
mentionedAgents?: ReadonlyArray<BackendId>
|
||||
): { id: string; turn: Promise<void> } {
|
||||
const { userMessageId, turn } = this.session.sendPrompt(text, context, promptContent);
|
||||
const { userMessageId, turn } = this.session.sendPrompt(
|
||||
text,
|
||||
context,
|
||||
promptContent,
|
||||
mentionedAgents
|
||||
);
|
||||
this.notifyListeners();
|
||||
const wrapped = turn.then(
|
||||
() => undefined,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AI_SENDER } from "@/constants";
|
||||
import { AI_SENDER, USER_SENDER } from "@/constants";
|
||||
import { AgentMessagePart } from "@/agentMode/session/types";
|
||||
import { serializeFanoutComposite, type FanoutTurn } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import { formatDateTime } from "@/utils";
|
||||
import { AgentMessageStore } from "./AgentMessageStore";
|
||||
|
||||
|
|
@ -305,4 +306,65 @@ describe("AgentMessageStore", () => {
|
|||
expect(store.getMessage(c)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("fanout", () => {
|
||||
const liveTurn = (summaryText = "the summary"): FanoutTurn => ({
|
||||
answers: {
|
||||
opencode: { backendId: "opencode", status: "done", text: "opencode answer" },
|
||||
codex: { backendId: "codex", status: "done", text: "codex answer" },
|
||||
},
|
||||
summary: { status: "done", text: summaryText },
|
||||
});
|
||||
|
||||
it("setFanout surfaces a FRESH snapshot each tick so React state updates don't bail", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const id = store.addMessage(placeholder());
|
||||
expect(store.getDisplayMessages().find((m) => m.id === id)?.fanout).toBeUndefined();
|
||||
|
||||
const turn = liveTurn();
|
||||
expect(store.setFanout(id, turn)).toBe(true);
|
||||
const after = store.getDisplayMessages().find((m) => m.id === id)?.fanout;
|
||||
expect(after?.summary.text).toBe("the summary");
|
||||
// A snapshot, not the same reference.
|
||||
expect(after).not.toBe(turn);
|
||||
expect(after?.answers).not.toBe(turn.answers);
|
||||
|
||||
// Re-setting (mutated live turn) yields a fresh reference, not a frozen one.
|
||||
store.setFanout(id, liveTurn("updated summary"));
|
||||
const second = store.getDisplayMessages().find((m) => m.id === id)?.fanout;
|
||||
expect(second).not.toBe(after);
|
||||
expect(second?.summary.text).toBe("updated summary");
|
||||
});
|
||||
|
||||
it("setFanout returns false for an unknown message", () => {
|
||||
const store = new AgentMessageStore();
|
||||
expect(store.setFanout("nope", liveTurn())).toBe(false);
|
||||
});
|
||||
|
||||
it("loadMessages rebuilds the dropdown from an assistant composite, never from a user/plain body", () => {
|
||||
const store = new AgentMessageStore();
|
||||
const body = serializeFanoutComposite(liveTurn("loaded summary"), (x) => x.toUpperCase());
|
||||
store.loadMessages([
|
||||
// A user message carrying the same body must NOT be read as a composite.
|
||||
{ id: "u1", sender: USER_SENDER, message: body, timestamp: null, isVisible: true },
|
||||
{ id: "a1", sender: AI_SENDER, message: body, timestamp: null, isVisible: true },
|
||||
{
|
||||
id: "a2",
|
||||
sender: AI_SENDER,
|
||||
message: "just a normal reply",
|
||||
timestamp: null,
|
||||
isVisible: true,
|
||||
},
|
||||
]);
|
||||
const display = store.getDisplayMessages();
|
||||
const assistant = display.find((m) => m.id === "a1");
|
||||
// The body is kept as-is (composite + markers) AND the dropdown is rebuilt.
|
||||
expect(assistant?.message).toBe(body);
|
||||
expect(assistant?.fanout?.summary.text).toBe("loaded summary");
|
||||
expect(Object.keys(assistant?.fanout?.answers ?? {})).toEqual(["opencode", "codex"]);
|
||||
// A plain assistant reply and the user message stay without a fanout.
|
||||
expect(display.find((m) => m.id === "a2")?.fanout).toBeUndefined();
|
||||
expect(display.find((m) => m.id === "u1")?.fanout).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { logInfo } from "@/logger";
|
||||
import {
|
||||
parseFanoutComposite,
|
||||
snapshotFanoutTurn,
|
||||
type FanoutTurn,
|
||||
} from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import {
|
||||
AgentChatMessage,
|
||||
AgentMessagePart,
|
||||
|
|
@ -6,6 +11,7 @@ import {
|
|||
NewAgentChatMessage,
|
||||
StopReason,
|
||||
} from "@/agentMode/session/types";
|
||||
import { USER_SENDER } from "@/constants";
|
||||
import { FormattedDateTime, MessageContext } from "@/types/message";
|
||||
import { formatDateTime } from "@/utils";
|
||||
|
||||
|
|
@ -26,6 +32,9 @@ interface StoredAgentMessage {
|
|||
content?: unknown[];
|
||||
turnStopReason?: StopReason;
|
||||
turnDurationMs?: number;
|
||||
// Live per-agent fan-out state. In-memory only; the persisted body carries the
|
||||
// composite that reconstructs it on load.
|
||||
fanout?: FanoutTurn;
|
||||
// Bumped on every in-place mutation of this message so `getDisplayMessages`
|
||||
// can cache the adapted view and only re-adapt when the version moves. The
|
||||
// streaming append paths mutate `parts`/`displayText` in place (same array
|
||||
|
|
@ -235,6 +244,19 @@ export class AgentMessageStore {
|
|||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the live fan-out turn on a message and bump its version so the memoized
|
||||
* view invalidates. Stores the live reference; `getDisplayMessages` snapshots
|
||||
* it per tick. Returns false when the message is missing.
|
||||
*/
|
||||
setFanout(id: string, turn: FanoutTurn): boolean {
|
||||
const msg = this.messages.find((m) => m.id === id);
|
||||
if (!msg) return false;
|
||||
msg.fanout = turn;
|
||||
this.touch(msg);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append text to a message body. Used to stream `agent_message_chunk`
|
||||
* updates into the placeholder assistant message. Returns false if the
|
||||
|
|
@ -411,6 +433,11 @@ export class AgentMessageStore {
|
|||
loadMessages(messages: AgentChatMessage[]): void {
|
||||
this.clear();
|
||||
for (const msg of messages) {
|
||||
// Reconstruct the fan-out dropdown from a saved composite body;
|
||||
// `parseFanoutComposite` returns null for a plain/old message. The body is
|
||||
// kept as-is — only the live `fanout` view is rebuilt.
|
||||
const fanout =
|
||||
msg.sender === USER_SENDER ? undefined : (parseFanoutComposite(msg.message) ?? undefined);
|
||||
this.messages.push({
|
||||
id: msg.id || this.generateId(),
|
||||
displayText: msg.message,
|
||||
|
|
@ -423,6 +450,7 @@ export class AgentMessageStore {
|
|||
parts: msg.parts,
|
||||
turnStopReason: msg.turnStopReason,
|
||||
turnDurationMs: msg.turnDurationMs,
|
||||
fanout,
|
||||
version: 0,
|
||||
});
|
||||
}
|
||||
|
|
@ -449,6 +477,9 @@ export class AgentMessageStore {
|
|||
parts: m.parts,
|
||||
turnStopReason: m.turnStopReason,
|
||||
turnDurationMs: m.turnDurationMs,
|
||||
// Snapshot so each adapted view carries a fresh reference; the orchestrator
|
||||
// mutates one turn in place, so the same reference would freeze the dropdown.
|
||||
...(m.fanout ? { fanout: snapshotFanoutTurn(m.fanout) } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,12 @@ import {
|
|||
buildPromptBlocks,
|
||||
buildUserDisplayContent,
|
||||
tryReadExitPlanModeCall,
|
||||
withReadOnlyPreamble,
|
||||
} from "./AgentSession";
|
||||
import { ensureMultiAgentEntitlement, showMultiAgentUpgradePrompt } from "@/plusUtils";
|
||||
import { AuthRequiredError, MethodUnsupportedError } from "./errors";
|
||||
import type { FanoutRunInput } from "./fanout/FanoutOrchestrator";
|
||||
import { FANOUT_READONLY_PREAMBLE, type FanoutTurn } from "./fanout/fanoutTypes";
|
||||
import type {
|
||||
AgentToolCallOutput,
|
||||
BackendDescriptor,
|
||||
|
|
@ -24,6 +28,14 @@ jest.mock("@/logger", () => ({
|
|||
jest.mock("@/settings/model", () => ({
|
||||
getSettings: jest.fn().mockReturnValue({ agentMode: { mcpServers: [] } }),
|
||||
}));
|
||||
// The authoritative send-boundary paywall (Phase 4) lives in plusUtils; mock it
|
||||
// so fan-out tests don't reach the real `isPlusEnabled()`/BrevilabsClient. The
|
||||
// helper defaults to "entitled" so existing fan-out tests keep passing; the
|
||||
// paywall tests below flip it per-case.
|
||||
jest.mock("@/plusUtils", () => ({
|
||||
ensureMultiAgentEntitlement: jest.fn(async () => true),
|
||||
showMultiAgentUpgradePrompt: jest.fn(),
|
||||
}));
|
||||
|
||||
interface MockBackend {
|
||||
asBackend: BackendProcess;
|
||||
|
|
@ -770,6 +782,405 @@ describe("AgentSession.sendPrompt", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("withReadOnlyPreamble", () => {
|
||||
it("leads the first text block with the read-only instruction", () => {
|
||||
const out = withReadOnlyPreamble([{ type: "text", text: "the question" }]);
|
||||
expect(out).toEqual([{ type: "text", text: `${FANOUT_READONLY_PREAMBLE}\n\nthe question` }]);
|
||||
});
|
||||
|
||||
it("inserts a leading text block when the prompt has none (image-only)", () => {
|
||||
const out = withReadOnlyPreamble([{ type: "image", mimeType: "image/png", data: "x" }]);
|
||||
expect(out[0]).toEqual({ type: "text", text: FANOUT_READONLY_PREAMBLE });
|
||||
expect(out).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("AgentSession fan-out branching", () => {
|
||||
it("dispatches to the fan-out runner (not backend.prompt) when >1 agent", async () => {
|
||||
const mock = makeMockBackend();
|
||||
const runFanoutTurn = jest.fn(async (input: FanoutRunInput): Promise<FanoutTurn> => {
|
||||
const turn: FanoutTurn = {
|
||||
answers: {
|
||||
opencode: { backendId: "opencode", status: "done", text: "opencode answer" },
|
||||
claude: { backendId: "claude", status: "done", text: "claude answer" },
|
||||
},
|
||||
summary: { status: "pending", text: "" },
|
||||
};
|
||||
input.onChange(turn);
|
||||
return turn;
|
||||
});
|
||||
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(stopReason).toBe("end_turn");
|
||||
expect(runFanoutTurn).toHaveBeenCalledTimes(1);
|
||||
expect(mock.prompt).not.toHaveBeenCalled();
|
||||
// Every agent received the identical prompt blocks, led by the read-only
|
||||
// QA preamble (the universal "answer only, no writes" instruction).
|
||||
expect(runFanoutTurn.mock.calls[0][0].agents).toEqual(["opencode", "claude"]);
|
||||
// The summarizer is ALWAYS the session's own main agent (here it is also one
|
||||
// of the answerers because it was explicitly `@`-mentioned).
|
||||
expect(runFanoutTurn.mock.calls[0][0].mainAgent).toBe("opencode");
|
||||
const fanoutPrompt = runFanoutTurn.mock.calls[0][0].prompt[0] as { type: "text"; text: string };
|
||||
expect(fanoutPrompt.text).toContain("read-only");
|
||||
expect(fanoutPrompt.text).toContain("review");
|
||||
// Live per-agent answers ride on the assistant message itself (message.fanout),
|
||||
// surfaced through the display view for the UI dropdown.
|
||||
const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER);
|
||||
expect(placeholder?.fanout?.answers.claude.text).toBe("claude answer");
|
||||
});
|
||||
|
||||
it("persists the full composite (summary + per-agent answers + markers) and keeps the live turn on the message", async () => {
|
||||
const mock = makeMockBackend();
|
||||
const runFanoutTurn = jest.fn(async (input: FanoutRunInput): Promise<FanoutTurn> => {
|
||||
const turn: FanoutTurn = {
|
||||
answers: {
|
||||
opencode: { backendId: "opencode", status: "done", text: "OPENCODE_ANSWER" },
|
||||
claude: { backendId: "claude", status: "done", text: "CLAUDE_ANSWER" },
|
||||
},
|
||||
summary: { status: "done", text: "the narrative summary" },
|
||||
};
|
||||
input.onChange(turn);
|
||||
return turn;
|
||||
});
|
||||
const session = new AgentSession({
|
||||
backend: mock.asBackend,
|
||||
backendSessionId: "acp-1",
|
||||
internalId: "internal-1",
|
||||
backendId: "opencode",
|
||||
runFanoutTurn,
|
||||
});
|
||||
|
||||
await session.sendPrompt("review", undefined, undefined, ["opencode", "claude"]).turn;
|
||||
|
||||
const placeholder = session.store.getDisplayMessages().find((m) => m.sender === AI_SENDER);
|
||||
// Phase 2: the persisted body is the FULL composite so the dropdown is
|
||||
// reconstructable on reload — summary AND per-agent answers AND the invisible
|
||||
// section markers all ride in the message body.
|
||||
expect(placeholder?.message).toContain("<!--copilot:multi-agent v=1-->");
|
||||
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<FanoutTurn> => {
|
||||
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<string, unknown>) => Promise<boolean>> {
|
||||
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<FanoutTurn> => {
|
||||
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("<conversation_history>");
|
||||
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("<user-message>\nexpand on that plan\n</user-message>");
|
||||
// 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<FanoutTurn> => {
|
||||
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<FanoutTurn> => {
|
||||
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<typeof makeMockBackend>): 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("<prior_turns>");
|
||||
expect(text).toContain("compare X and Y");
|
||||
expect(text).toContain("the fan-out summary");
|
||||
expect(text).toContain("<user-message>\nnow expand on that\n</user-message>");
|
||||
|
||||
// Buffer cleared: a second single-agent turn carries no prior-turn block.
|
||||
await session.sendPrompt("and again").turn;
|
||||
expect(lastPromptText(mock)).not.toContain("<prior_turns>");
|
||||
});
|
||||
|
||||
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("<prior_turns>");
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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<FanoutTurn>;
|
||||
|
||||
/**
|
||||
* 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<BackendId> = 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<BackendId> = 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<BackendId>
|
||||
): { userMessageId: string; turn: Promise<StopReason> } {
|
||||
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<BackendId> {
|
||||
return this.lastMentionedAgents;
|
||||
}
|
||||
|
||||
private async runTurn(
|
||||
displayText: string,
|
||||
userMessageId: string,
|
||||
context: MessageContext | undefined,
|
||||
promptContent?: PromptContent[]
|
||||
): Promise<StopReason> {
|
||||
|
|
@ -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
|
||||
// `<conversation_history>` 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 `<prior_turns>` 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<boolean> {
|
||||
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<StopReason> {
|
||||
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 `<web_*>` 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
|
||||
// `<web_*>` 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<PromptContent, { type: "text" }>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<SessionId>();
|
||||
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<FanoutTurn> {
|
||||
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(
|
||||
|
|
|
|||
377
src/agentMode/session/fanout/FanoutOrchestrator.test.ts
Normal file
377
src/agentMode/session/fanout/FanoutOrchestrator.test.ts
Normal file
|
|
@ -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<BackendId, MockProc>;
|
||||
readOnlyRegistered: string[];
|
||||
readOnlyUnregistered: string[];
|
||||
excludedFromHistory: Array<{ backendId: BackendId; sessionId: string }>;
|
||||
}
|
||||
|
||||
function makeHost(
|
||||
config: Record<BackendId, { sessionId: string; readOnlyModeId?: string }>
|
||||
): HostHarness {
|
||||
const procs = new Map<BackendId, MockProc>();
|
||||
const descriptors = new Map<BackendId, BackendDescriptor>();
|
||||
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<Parameters<FanoutOrchestrator["run"]>[0]> = {}
|
||||
): Parameters<FanoutOrchestrator["run"]>[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);
|
||||
});
|
||||
});
|
||||
564
src/agentMode/session/fanout/FanoutOrchestrator.ts
Normal file
564
src/agentMode/session/fanout/FanoutOrchestrator.ts
Normal file
|
|
@ -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<BackendProcess["newSession"]>[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<BackendId>;
|
||||
/**
|
||||
* 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<BackendId>): FanoutTurn {
|
||||
const answers: Record<BackendId, AgentAnswer> = {};
|
||||
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<FanoutTurn> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<unknown>, 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<void> {
|
||||
return new Promise<void>((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<unknown>, 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<void> | 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<unknown>, 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<void> {
|
||||
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<void> {
|
||||
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<ModelApplySpec, { kind: "setConfigOption" }>
|
||||
): Promise<void> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
39
src/agentMode/session/fanout/answerers.ts
Normal file
39
src/agentMode/session/fanout/answerers.ts
Normal file
|
|
@ -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<BackendId> = 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<BackendId>;
|
||||
installedAgentIds: ReadonlySet<BackendId>;
|
||||
}): ReadonlyArray<BackendId> {
|
||||
const { mentionedAgentIds, installedAgentIds } = args;
|
||||
const answerers: BackendId[] = [];
|
||||
const seen = new Set<BackendId>();
|
||||
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<BackendId>, mainAgentId: BackendId): boolean {
|
||||
if (answerers.length === 0) return false;
|
||||
if (answerers.length === 1 && answerers[0] === mainAgentId) return false;
|
||||
return true;
|
||||
}
|
||||
274
src/agentMode/session/fanout/fanoutTypes.test.ts
Normal file
274
src/agentMode/session/fanout/fanoutTypes.test.ts
Normal file
|
|
@ -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 <b> & </summary>?", summary: "Do Y." },
|
||||
])!;
|
||||
expect(block).toContain("<prior_turns>");
|
||||
expect(block).toContain("<multi_agent_turn>");
|
||||
expect(block).toContain("<summary>\nDo Y.\n</summary>");
|
||||
// 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("</summary>?");
|
||||
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("<conversation_history>");
|
||||
expect(block).toContain("</conversation_history>");
|
||||
expect(block).toContain('<turn role="user">');
|
||||
expect(block).toContain('<turn role="assistant">');
|
||||
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("<!--copilot:multi-agent v=1-->");
|
||||
expect(body).toContain("<!--copilot:multi-agent-end-->");
|
||||
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: <!--copilot:agent id="evil" status="done"--> and ' +
|
||||
`<!--copilot:multi-agent-end--> with a bare ${sentinel} sentinel and ` +
|
||||
`<!--copilot${sentinel}1 lookalike inside my answer.`;
|
||||
const summaryText = `summary with <!--copilot:summary--> 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 `<!--copilot:multi-agent v=1-->` and " +
|
||||
'`<!--copilot:agent id="x" status="done"-->` to mark sections.';
|
||||
expect(parseFanoutComposite(discussing)).toBeNull();
|
||||
// Even both wrapper markers present, but with NO real sections, is not a turn.
|
||||
expect(
|
||||
parseFanoutComposite("<!--copilot:multi-agent v=1-->\n\n<!--copilot:multi-agent-end-->")
|
||||
).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("<!--copilot:");
|
||||
expect(out).toContain("### Summary\nthe summary");
|
||||
expect(out).toContain("### OPENCODE\nopencode body");
|
||||
expect(out).toContain("### CODEX");
|
||||
expect(out).toContain("did not answer");
|
||||
});
|
||||
});
|
||||
738
src/agentMode/session/fanout/fanoutTypes.ts
Normal file
738
src/agentMode/session/fanout/fanoutTypes.ts
Normal file
|
|
@ -0,0 +1,738 @@
|
|||
import type {
|
||||
AgentChatMessage,
|
||||
AgentToolKind,
|
||||
BackendId,
|
||||
PromptContent,
|
||||
} from "@/agentMode/session/types";
|
||||
import { USER_SENDER } from "@/constants";
|
||||
import { escapeXml } from "@/LLMProviders/chainRunner/utils/xmlParsing";
|
||||
import {
|
||||
isNoteSelectedTextContext,
|
||||
isWebSelectedTextContext,
|
||||
type MessageContext,
|
||||
} from "@/types/message";
|
||||
|
||||
/**
|
||||
* Read-only QA preamble prepended to every fan-out agent's prompt. Per-backend
|
||||
* permission denial and sandbox mode are belt-and-suspenders on top of it.
|
||||
*/
|
||||
export const FANOUT_READONLY_PREAMBLE =
|
||||
"You are answering a read-only question. Do NOT modify any files, run any " +
|
||||
"commands that change state, or execute write/shell tools — answer only. " +
|
||||
"You may freely read, search, grep, and fetch to inform your answer. " +
|
||||
"Respond with your analysis directly.";
|
||||
|
||||
/**
|
||||
* Per-turn fan-out state. Held LIVE on the owning assistant message
|
||||
* (`AgentChatMessage.fanout`) and PERSISTED to the message body as a composite
|
||||
* ({@link serializeFanoutComposite}) so the dropdown reconstructs on reload
|
||||
* ({@link parseFanoutComposite}).
|
||||
*/
|
||||
export interface FanoutTurn {
|
||||
/**
|
||||
* One slot per ANSWERER (the deduped `@`-mentioned installed agents), keyed by
|
||||
* `BackendId`. The session main agent is the separate summarizer and has a
|
||||
* slot only if it was itself `@`-mentioned.
|
||||
*/
|
||||
answers: Record<BackendId, AgentAnswer>;
|
||||
/** Narrative summary, filled by the main agent. The only part that persists. */
|
||||
summary: FanoutSummary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Live status of one agent's answer. `cancelled` is distinct from `error`: the
|
||||
* user aborted the turn, not an agent fault. Both are terminal; neither feeds
|
||||
* the summary.
|
||||
*/
|
||||
export type AgentAnswerStatus = "running" | "done" | "error" | "cancelled";
|
||||
|
||||
/** One agent's slot in a fan-out turn. `error` is set when `status === "error"`. */
|
||||
export interface AgentAnswer {
|
||||
backendId: BackendId;
|
||||
status: AgentAnswerStatus;
|
||||
text: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent answer timeout. Each agent's `prompt()` races this deadline; on
|
||||
* expiry the orchestrator cancels that sub-session and marks the slot `error`
|
||||
* with {@link FANOUT_AGENT_TIMEOUT_ERROR}, so one hung sub-session fails its own
|
||||
* slot without stalling the others.
|
||||
*/
|
||||
export const FANOUT_AGENT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** Human-readable reason set on a slot that exceeded {@link FANOUT_AGENT_TIMEOUT_MS}. */
|
||||
export const FANOUT_AGENT_TIMEOUT_ERROR = "Timed out waiting for this agent to answer.";
|
||||
|
||||
/**
|
||||
* Grace window the orchestrator waits, after requesting `cancel`, for a
|
||||
* cancelled/timed-out sub-session's `prompt()` to settle before that backend is
|
||||
* reused (the summary reuses the main agent's backend). The Claude SDK backend's
|
||||
* permission-bridge/session context is process-global for the active query, so a
|
||||
* second prompt mid-unwind can misroute permission decisions or corrupt the
|
||||
* summary. Bounded so a backend that ignores cancel can't hang the turn forever
|
||||
* — if the grace elapses we proceed anyway (logged).
|
||||
*/
|
||||
export const FANOUT_CANCEL_GRACE_MS = 3 * 1000;
|
||||
|
||||
/**
|
||||
* Tail grace the orchestrator holds an ephemeral sub-session's update handler
|
||||
* open after `prompt()` resolves normally, before tearing it down. Some ACP
|
||||
* backends (opencode, fast models) flush a turn's FINAL `agent_message_chunk`
|
||||
* events just after the `session/prompt` result resolves; without this window
|
||||
* those trailing chunks arrive once the handler is gone and are dropped,
|
||||
* truncating an answer at the end. Only applied on the normal resolve path —
|
||||
* cancel/timeout intentionally suppress late output and skip this wait.
|
||||
*/
|
||||
export const FANOUT_TRAILING_CHUNK_GRACE_MS = 500;
|
||||
|
||||
/** Status of the main-agent narrative summary slot. */
|
||||
export type FanoutSummaryStatus = "pending" | "streaming" | "done";
|
||||
|
||||
/** The summary slot — the only part of a fan-out turn that is persisted. */
|
||||
export interface FanoutSummary {
|
||||
status: FanoutSummaryStatus;
|
||||
text: string;
|
||||
/**
|
||||
* True once the summary finished SUCCESSFULLY (not cancel/error/timeout).
|
||||
* `status` alone can't say — it is forced to `done` on every exit so the UI
|
||||
* never sticks on a spinner. Live-only; never serialized.
|
||||
*/
|
||||
complete?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool kinds that mutate the vault or execute commands, hard-denied in a
|
||||
* read-only fan-out sub-session. `other` is intentionally NOT here: denying it
|
||||
* would block legitimate read-only MCP tools, and the prompt + sandbox already
|
||||
* steer the agent away from mutations.
|
||||
*/
|
||||
const WRITE_OR_EXEC_KINDS: ReadonlySet<AgentToolKind> = new Set<AgentToolKind>([
|
||||
"edit",
|
||||
"delete",
|
||||
"move",
|
||||
"execute",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether a tool kind must be denied in a read-only fan-out sub-session.
|
||||
* `undefined` (kind not reported) is treated as a write to fail safe.
|
||||
*/
|
||||
export function isWriteOrExecToolKind(kind: AgentToolKind | undefined): boolean {
|
||||
if (kind === undefined) return true;
|
||||
return WRITE_OR_EXEC_KINDS.has(kind);
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh, structurally-copied snapshot of a live fan-out turn. The orchestrator
|
||||
* mutates one {@link FanoutTurn} in place and re-emits the SAME reference per
|
||||
* token; React `setState` bails on `Object.is`-equal updates, so a fresh copy
|
||||
* (turn + each answer slot) is needed for the dropdown to re-render and for a
|
||||
* captured snapshot to stay stable as the live turn keeps mutating.
|
||||
*/
|
||||
export function snapshotFanoutTurn(turn: FanoutTurn): FanoutTurn {
|
||||
const answers: Record<BackendId, AgentAnswer> = {};
|
||||
for (const backendId of Object.keys(turn.answers)) {
|
||||
answers[backendId] = { ...turn.answers[backendId] };
|
||||
}
|
||||
return { answers, summary: { ...turn.summary } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider-neutral instruction for the main agent's narrative summary. Frames a
|
||||
* NEW user turn (never replaces a backend system prompt) and is read-only.
|
||||
*/
|
||||
export const FANOUT_SUMMARY_INSTRUCTION =
|
||||
"You are a neutral synthesizer. The labeled blocks below are what SEVERAL " +
|
||||
"DIFFERENT AI agents each produced in response to the user's request. Write a " +
|
||||
"synthesis for the user ABOUT their outputs — you are reporting on what the " +
|
||||
"agents produced, not doing the task yourself.\n\n" +
|
||||
"VOICE (always):\n" +
|
||||
"- Third person, attributing each point to the agent by name (convert the " +
|
||||
'agents\' "I/my" into "<agent> …"). NEVER write in the first person or as if ' +
|
||||
'the request were made of you; no sentence may begin with "I".\n' +
|
||||
"- Use ONLY the outputs shown; ignore any environment scaffolding (tool lists, " +
|
||||
"available skills/agents, boilerplate). Do not mention how many agents there " +
|
||||
"were, who did not respond, or anything missing. Be concise.\n\n" +
|
||||
"FIRST pick the MODE from the request and the outputs:\n" +
|
||||
"- ANSWER mode — the agents answered a question or analyzed something (facts, " +
|
||||
"explanation, a recommendation, identity); there is a best answer to converge " +
|
||||
"on.\n" +
|
||||
"- DELIVERABLE mode — the agents each produced an ALTERNATIVE ARTIFACT the user " +
|
||||
"will choose from or use (a rewrite, draft, message, translation, code, plan, " +
|
||||
"design); these are options, not competing claims.\n\n" +
|
||||
"If only ONE agent responded (either mode): one to three sentences on its " +
|
||||
"answer or approach, attributed, no headings.\n\n" +
|
||||
"TWO OR MORE in ANSWER mode — markdown sections, omitting any that is empty:\n" +
|
||||
' "**Each agent**" — one concise bullet per agent.\n' +
|
||||
' "**Agreements**" — the points the agents share.\n' +
|
||||
' "**Disagreements**" — where they differ, naming the sides.\n\n' +
|
||||
"TWO OR MORE in DELIVERABLE mode — help the user CHOOSE or MERGE, NOT " +
|
||||
"agreements/disagreements:\n" +
|
||||
' "**Options**" — one bullet per agent on what is DISTINCTIVE about its take ' +
|
||||
"(the angle, tone, structure, or tradeoff that would make someone pick it, and " +
|
||||
"who it suits).\n" +
|
||||
' "**Recommendation**" — name the best option for the likely goal and why in a ' +
|
||||
"sentence or two; if a combination is clearly better, say which parts of which " +
|
||||
"to merge.\n" +
|
||||
" Do NOT reproduce the artifacts (the user already has each in its own tab); " +
|
||||
"summarize the approach only.\n\n" +
|
||||
"Do NOT modify any files or run write/shell tools.";
|
||||
|
||||
/** The text persisted when every fan-out agent failed. */
|
||||
export const FANOUT_ALL_FAILED_SUMMARY =
|
||||
"All agents failed to answer; no summary could be generated.";
|
||||
|
||||
/**
|
||||
* A fan-out turn the visible session's backend never saw (it ran on ephemeral
|
||||
* sub-sessions). Buffered and replayed as a labeled prior-turn block on the next
|
||||
* single-agent prompt for continuity. LIVE-ONLY: never serialized.
|
||||
*/
|
||||
export interface PendingFanoutContext {
|
||||
question: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
/** Frozen empty buffer — the referentially-stable "nothing pending" value. */
|
||||
export const EMPTY_PENDING_FANOUT_CONTEXT: ReadonlyArray<PendingFanoutContext> = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Compose the buffered fan-out turns into a labeled prior-turn block for the next
|
||||
* single-agent prompt so the backend reads them as earlier conversation, not a
|
||||
* fresh task. Returns `null` for an empty buffer (prompt unchanged).
|
||||
*/
|
||||
export function buildPriorFanoutContextBlock(
|
||||
entries: ReadonlyArray<PendingFanoutContext>
|
||||
): string | null {
|
||||
if (entries.length === 0) return null;
|
||||
// Escape the user-controlled question/summary so a stray `</summary>` can't
|
||||
// break the framing — same convention as the sibling `<web_*>` builders.
|
||||
const turns = entries
|
||||
.map(
|
||||
(e) =>
|
||||
`<multi_agent_turn>\n<question>\n${escapeXml(e.question)}\n</question>\n` +
|
||||
`<summary>\n${escapeXml(e.summary)}\n</summary>\n</multi_agent_turn>`
|
||||
)
|
||||
.join("\n");
|
||||
return (
|
||||
"<prior_turns>\n" +
|
||||
"Earlier in this conversation you ran the following multi-agent turn(s). " +
|
||||
"Each shows the user's question and the summary that was already shown to " +
|
||||
"the user. Treat these as conversation history for continuity; do not " +
|
||||
"redo or re-answer them.\n" +
|
||||
`${turns}\n` +
|
||||
"</prior_turns>"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Char cap on the rendered `<conversation_history>` block injected into every
|
||||
* fan-out agent's prompt. Each fan-out agent runs in a FRESH single-turn
|
||||
* ephemeral sub-session, so the entire prior transcript rides in ONE prompt;
|
||||
* unlike a long-running session, nothing here gets compacted, so an oversized
|
||||
* block would hard-error the model API ("prompt too long") rather than
|
||||
* auto-truncate (D3). 48k chars (~12k tokens) covers typical chats with room to
|
||||
* spare against a ~200k-token window even multiplied across agents; oldest-first
|
||||
* truncation only kicks in on pathologically long conversations.
|
||||
*/
|
||||
export const FANOUT_HISTORY_MAX_CHARS = 48_000;
|
||||
|
||||
/** Marker prepended when the oldest turns are dropped to fit the cap. */
|
||||
const FANOUT_HISTORY_TRUNCATION_MARKER = "[earlier conversation truncated]";
|
||||
|
||||
/** Inline marker appended when a single retained turn is itself truncated to fit the cap. */
|
||||
const FANOUT_HISTORY_TURN_TRUNCATION_MARKER = "[turn truncated]";
|
||||
|
||||
/** Per-item char budget so one large excerpt can't dominate the history. */
|
||||
const FANOUT_HISTORY_TOOL_OUTPUT_MAX_CHARS = 2_000;
|
||||
|
||||
/** Trim `s` to its leading `max` chars, appending `marker` only when it actually overflows. */
|
||||
function trimHead(s: string, max: number, marker: string): string {
|
||||
if (s.length <= max) return s;
|
||||
return `${s.slice(0, max)}\n${marker}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count the image attachment blocks in a user message's `content` array. The
|
||||
* field is typed `unknown[]`, so each entry is narrowed defensively to a non-null
|
||||
* object whose `type` is `"image"` (prompt-block shape) or `"image_url"` (the
|
||||
* live `buildUserDisplayContent` projection); other entries are ignored.
|
||||
*/
|
||||
function countImageAttachments(content: readonly unknown[] | undefined): number {
|
||||
if (!content) return 0;
|
||||
let count = 0;
|
||||
for (const entry of content) {
|
||||
if (typeof entry !== "object" || entry === null) continue;
|
||||
const type = (entry as { type?: unknown }).type;
|
||||
if (type === "image" || type === "image_url") count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a turn's attached {@link MessageContext} (pinned notes, selected
|
||||
* excerpts, folders, urls, tags, web tabs) into one `[context]` section so a
|
||||
* fan-out agent — a FRESH session with no memory — can resolve a follow-up like
|
||||
* "explain the selected excerpt above". Selection excerpts carry their actual
|
||||
* text (trimmed per item); every other field collapses to an identifier line.
|
||||
* Note paths and tab urls (not basenames/titles) are emitted so a fresh
|
||||
* session's Read/fetch can resolve them. Values are NOT escaped here:
|
||||
* `buildConversationHistoryBlock` escapes the whole turn body once. An empty
|
||||
* context renders nothing, leaving the turn byte-for-byte unchanged.
|
||||
*/
|
||||
function renderMessageContext(context: MessageContext | undefined): string[] {
|
||||
if (!context) return [];
|
||||
const lines: string[] = [];
|
||||
|
||||
for (const sel of context.selectedTextContexts ?? []) {
|
||||
const label = isNoteSelectedTextContext(sel)
|
||||
? sel.noteTitle
|
||||
: isWebSelectedTextContext(sel)
|
||||
? sel.title || sel.url
|
||||
: "selection";
|
||||
const excerpt = trimHead(
|
||||
sel.content.trim(),
|
||||
FANOUT_HISTORY_TOOL_OUTPUT_MAX_CHARS,
|
||||
FANOUT_HISTORY_TURN_TRUNCATION_MARKER
|
||||
);
|
||||
lines.push(`[selected from ${label}]\n${excerpt}`);
|
||||
}
|
||||
|
||||
const noteNames = (context.notes ?? []).map((n) => n.path);
|
||||
if (noteNames.length > 0) lines.push(`[notes: ${noteNames.join(", ")}]`);
|
||||
|
||||
if (context.folders && context.folders.length > 0) {
|
||||
lines.push(`[folders: ${context.folders.join(", ")}]`);
|
||||
}
|
||||
if (context.urls && context.urls.length > 0) {
|
||||
lines.push(`[urls: ${context.urls.join(", ")}]`);
|
||||
}
|
||||
if (context.tags && context.tags.length > 0) {
|
||||
lines.push(`[tags: ${context.tags.join(", ")}]`);
|
||||
}
|
||||
if (context.webTabs && context.webTabs.length > 0) {
|
||||
const tabs = context.webTabs.map((t) => (t.title ? `${t.title} (${t.url})` : t.url)).join(", ");
|
||||
lines.push(`[web tabs: ${tabs}]`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return [];
|
||||
return [`[context]\n${lines.join("\n")}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* History-safe prose for an assistant turn. A fan-out turn's `message` body is
|
||||
* the PERSISTED composite (HTML-comment markers + marker-escaped content), so
|
||||
* feeding it raw would leak hidden metadata; render the clean composite from the
|
||||
* live/parsed `fanout` turn instead. A non-fan-out message returns unchanged.
|
||||
*/
|
||||
function historyProse(message: AgentChatMessage): string {
|
||||
const turn = message.fanout ?? parseFanoutComposite(message.message);
|
||||
return turn ? renderFanoutComposite(turn, (id) => id) : message.message;
|
||||
}
|
||||
|
||||
/**
|
||||
* One transcript turn's renderable body: prose, an image-attachment marker, then
|
||||
* its attached {@link MessageContext}. `null` when all are absent.
|
||||
*/
|
||||
function renderTurnContent(message: AgentChatMessage): string | null {
|
||||
const segments: string[] = [];
|
||||
const prose = historyProse(message).trim();
|
||||
if (prose.length > 0) segments.push(prose);
|
||||
// Note that images existed even though their bytes aren't in fan-out history,
|
||||
// so the context loss isn't silent.
|
||||
const imageCount = countImageAttachments(message.content);
|
||||
if (imageCount > 0) {
|
||||
const noun = imageCount === 1 ? "image attachment" : "image attachments";
|
||||
segments.push(`[${imageCount} ${noun} omitted from history; existed in this turn]`);
|
||||
}
|
||||
segments.push(...renderMessageContext(message.context));
|
||||
if (segments.length === 0) return null;
|
||||
return segments.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the prior visible transcript into a single read-only
|
||||
* `<conversation_history>` block for fan-out agent prompts. Each fan-out agent
|
||||
* opens a FRESH session with no memory, so this is how it sees what came before,
|
||||
* framed as context to USE, not a task to redo.
|
||||
*
|
||||
* `messages` must be PRIOR turns only (caller excludes the in-flight pair). Each
|
||||
* turn is labeled by role and XML-escaped so its text can't break the framing.
|
||||
* The body is bounded by `maxChars`: oldest turns drop first, and a single
|
||||
* retained turn that still overflows is itself truncated. Returns `null` for no
|
||||
* prior history so the caller leaves the prompt byte-for-byte unchanged.
|
||||
*/
|
||||
export function buildConversationHistoryBlock(
|
||||
messages: readonly AgentChatMessage[],
|
||||
maxChars: number
|
||||
): string | null {
|
||||
const rendered: string[] = [];
|
||||
for (const m of messages) {
|
||||
const content = renderTurnContent(m);
|
||||
if (content === null) continue;
|
||||
const role = m.sender === USER_SENDER ? "user" : "assistant";
|
||||
rendered.push(`<turn role="${role}">\n${escapeXml(content)}\n</turn>`);
|
||||
}
|
||||
if (rendered.length === 0) return null;
|
||||
|
||||
// Drop oldest-first until the joined turns fit the cap, tracking a running
|
||||
// char total (each turn's length plus the "\n" separator that joins it to the
|
||||
// next) so we never re-join the whole transcript per drop. Only the most
|
||||
// recent turns survive a pathologically long chat.
|
||||
let total = rendered.reduce((n, t) => n + t.length + 1, -1);
|
||||
let truncated = false;
|
||||
while (rendered.length > 1 && total > maxChars) {
|
||||
total -= rendered.shift()!.length + 1;
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
// Final hard cap: a single surviving turn (a long answer or pasted dump) can
|
||||
// alone exceed `maxChars`; the drop loop can't shrink it, so truncate the
|
||||
// joined body's head and mark it. Guarantees the body is bounded by ~maxChars
|
||||
// regardless of input — the prompt-too-large error the cap exists to prevent.
|
||||
let body = rendered.join("\n");
|
||||
if (body.length > maxChars) {
|
||||
body = trimHead(body, maxChars, FANOUT_HISTORY_TURN_TRUNCATION_MARKER);
|
||||
truncated = true;
|
||||
}
|
||||
|
||||
const header =
|
||||
"Earlier in this conversation the following was said. Treat this as " +
|
||||
"read-only context to inform your answer; do NOT redo or re-answer these " +
|
||||
"earlier turns. Answer only the current question that follows.";
|
||||
if (truncated) body = `${FANOUT_HISTORY_TRUNCATION_MARKER}\n${body}`;
|
||||
return `<conversation_history>\n${header}\n${body}\n</conversation_history>`;
|
||||
}
|
||||
|
||||
/** One agent's succeeded answer, ready to feed into the summary prompt. */
|
||||
export interface SucceededAnswer {
|
||||
backendId: BackendId;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The agents whose answers feed the summary, partitioned: `succeeded` are `done`
|
||||
* slots with non-empty text; `failed` are slots that errored or finished empty.
|
||||
* Insertion order is preserved on both, matching the answer-slot order.
|
||||
*/
|
||||
export interface SummaryInputs {
|
||||
succeeded: SucceededAnswer[];
|
||||
failed: BackendId[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition a settled turn's answers into {@link SummaryInputs}. A `done` slot
|
||||
* with only whitespace is treated as a failure — it carries nothing to reconcile.
|
||||
*/
|
||||
export function selectSummaryInputs(turn: FanoutTurn): SummaryInputs {
|
||||
const succeeded: SucceededAnswer[] = [];
|
||||
const failed: BackendId[] = [];
|
||||
for (const backendId of Object.keys(turn.answers)) {
|
||||
const slot = turn.answers[backendId];
|
||||
const text = slot.text.trim();
|
||||
if (slot.status === "done" && text.length > 0) {
|
||||
succeeded.push({ backendId, text });
|
||||
} else {
|
||||
failed.push(backendId);
|
||||
}
|
||||
}
|
||||
return { succeeded, failed };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-answer char cap on answers fed into the SUMMARY prompt (bounds MODEL
|
||||
* INPUT, not the on-disk transcript). Several answers stack into one summary
|
||||
* prompt, so this is tighter than the persisted cap to avoid blowing the context
|
||||
* window (worst case ≈ agent count × this).
|
||||
*/
|
||||
const FANOUT_SUMMARY_ANSWER_MAX_CHARS = 12_000;
|
||||
const FANOUT_SUMMARY_ANSWER_TRUNCATION_MARKER = "[answer truncated]";
|
||||
|
||||
/**
|
||||
* Compose the NEW user-turn prompt fed to the main agent for the summary: the
|
||||
* instruction, the user's original prompt, then each succeeded answer labeled by
|
||||
* its agent's display name. `displayNameFor` falls back to the id when unknown.
|
||||
* Returns `null` when zero agents succeeded so the caller doesn't fabricate a
|
||||
* summary over nothing.
|
||||
*/
|
||||
export function buildSummaryUserPrompt(
|
||||
originalPrompt: string,
|
||||
inputs: SummaryInputs,
|
||||
displayNameFor: (backendId: BackendId) => string
|
||||
): PromptContent[] | null {
|
||||
if (inputs.succeeded.length === 0) return null;
|
||||
// Cap each answer's length so a single oversized one can't blow the summary
|
||||
// sub-session's context/timeout.
|
||||
const sections = inputs.succeeded.map(
|
||||
({ backendId, text }) =>
|
||||
`### ${displayNameFor(backendId)}\n${trimHead(text, FANOUT_SUMMARY_ANSWER_MAX_CHARS, FANOUT_SUMMARY_ANSWER_TRUNCATION_MARKER)}`
|
||||
);
|
||||
// Only SUCCEEDED answers are shown; failed agents are omitted entirely so the
|
||||
// summary can't mention or speculate about them.
|
||||
const parts = [
|
||||
FANOUT_SUMMARY_INSTRUCTION,
|
||||
`## Question\n${originalPrompt.trim()}`,
|
||||
`## Agent answers\n${sections.join("\n\n")}`,
|
||||
];
|
||||
return [{ type: "text", text: parts.join("\n\n") }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Char cap on EACH persisted agent answer in the composite body (bounds the
|
||||
* on-disk transcript, not the model input). Large enough that a normal QA answer
|
||||
* is never clipped; the summary is persisted uncapped.
|
||||
*/
|
||||
export const FANOUT_PERSISTED_ANSWER_MAX_CHARS = 24_000;
|
||||
|
||||
const FANOUT_PERSISTED_ANSWER_TRUNCATION_MARKER = "[answer truncated]";
|
||||
|
||||
/** Composite format version, embedded in the opening marker for forward-compat. */
|
||||
const FANOUT_COMPOSITE_VERSION = 1;
|
||||
|
||||
/** Opening marker that flags an assistant body as a serialized fan-out composite. */
|
||||
const FANOUT_MARKER_OPEN = `<!--copilot:multi-agent v=${FANOUT_COMPOSITE_VERSION}-->`;
|
||||
|
||||
/**
|
||||
* 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 = /<!--copilot:multi-agent v=\d+-->/;
|
||||
|
||||
/** Closing marker of a serialized fan-out composite. */
|
||||
const FANOUT_MARKER_CLOSE = "<!--copilot:multi-agent-end-->";
|
||||
|
||||
/** Section marker introducing the summary block. */
|
||||
const FANOUT_MARKER_SUMMARY = "<!--copilot: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 = "<!--copilot:";
|
||||
const FANOUT_ESCAPED_MARKER_PREFIX = `<!--copilot${FANOUT_SENTINEL_COLON_ESCAPE}`;
|
||||
|
||||
/**
|
||||
* Escape body text so it can never forge a section marker. Order matters: double
|
||||
* any literal sentinel FIRST so the colon escapes are the only single-sentinel
|
||||
* sequences.
|
||||
*/
|
||||
function escapeFanoutMarkers(text: string): string {
|
||||
return text
|
||||
.split(FANOUT_MARKER_SENTINEL)
|
||||
.join(FANOUT_SENTINEL_LITERAL_ESCAPE)
|
||||
.split(FANOUT_LITERAL_MARKER_PREFIX)
|
||||
.join(FANOUT_ESCAPED_MARKER_PREFIX);
|
||||
}
|
||||
|
||||
/** Inverse of {@link escapeFanoutMarkers}: restore colon escapes, then collapse doubled sentinels. */
|
||||
function unescapeFanoutMarkers(text: string): string {
|
||||
return text
|
||||
.split(FANOUT_ESCAPED_MARKER_PREFIX)
|
||||
.join(FANOUT_LITERAL_MARKER_PREFIX)
|
||||
.split(FANOUT_SENTINEL_LITERAL_ESCAPE)
|
||||
.join(FANOUT_MARKER_SENTINEL);
|
||||
}
|
||||
|
||||
/** Trim a persisted agent answer to the cap, marking it only when it overflows. */
|
||||
function capPersistedAnswer(text: string): string {
|
||||
if (text.length <= FANOUT_PERSISTED_ANSWER_MAX_CHARS) return text;
|
||||
return `${text.slice(0, FANOUT_PERSISTED_ANSWER_MAX_CHARS)}\n${FANOUT_PERSISTED_ANSWER_TRUNCATION_MARKER}`;
|
||||
}
|
||||
|
||||
/** Note emitted (in the `note` attribute) for an agent that produced no answer. */
|
||||
const FANOUT_NO_ANSWER_NOTE = "did not answer";
|
||||
|
||||
/**
|
||||
* Serialize a completed fan-out turn into the PERSISTED assistant message body:
|
||||
* the summary plus each agent's answer, delimited by HTML-comment section markers
|
||||
* the reload parse keys on ({@link parseFanoutComposite}); the `### Heading`
|
||||
* lines are cosmetic. A failed/cancelled agent persists its partial text when it
|
||||
* streamed any, else a body-less marker carrying `status` + `note`. Each answer
|
||||
* is capped and marker-escaped so it can't forge a section.
|
||||
*/
|
||||
export function serializeFanoutComposite(
|
||||
turn: FanoutTurn,
|
||||
displayName: (backendId: BackendId) => string
|
||||
): string {
|
||||
const { succeeded } = selectSummaryInputs(turn);
|
||||
const succeededIds = new Set(succeeded.map((s) => s.backendId));
|
||||
const summaryText = turn.summary.text.trim();
|
||||
|
||||
const lines: string[] = [FANOUT_MARKER_OPEN, FANOUT_MARKER_SUMMARY, "### Summary"];
|
||||
if (summaryText.length > 0) lines.push(escapeFanoutMarkers(summaryText));
|
||||
|
||||
for (const backendId of Object.keys(turn.answers)) {
|
||||
const name = displayName(backendId);
|
||||
const nameAttr = ` name="${escapeMarkerAttr(name)}"`;
|
||||
const slot = turn.answers[backendId];
|
||||
if (succeededIds.has(backendId)) {
|
||||
lines.push(
|
||||
`<!--copilot:agent id="${escapeMarkerAttr(backendId)}"${nameAttr} status="done"-->`,
|
||||
`### ${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(
|
||||
`<!--copilot:agent id="${escapeMarkerAttr(backendId)}"${nameAttr}${statusAttr}${errorAttr}-->`,
|
||||
`### ${name}`,
|
||||
escapeFanoutMarkers(capPersistedAnswer(partial))
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
`<!--copilot:agent id="${escapeMarkerAttr(backendId)}"${nameAttr}${statusAttr}${errorAttr} note="${FANOUT_NO_ANSWER_NOTE}"-->`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 `<!--copilot:` is restored verbatim.
|
||||
*/
|
||||
export function parseFanoutComposite(body: string): FanoutTurn | null {
|
||||
// Require the COMPLETE wrapper (open + close), so a plain answer that merely
|
||||
// contains `<!--copilot:…` is left as-is, not hidden behind the fan-out card.
|
||||
if (!FANOUT_MARKER_OPEN_RE.test(body) || !body.includes(FANOUT_MARKER_CLOSE)) return null;
|
||||
|
||||
const answers: Record<BackendId, AgentAnswer> = {};
|
||||
let summaryText = "";
|
||||
|
||||
// Split on every section marker, tagging each chunk with its opening marker;
|
||||
// chunks before the open / after the end marker are framing chrome.
|
||||
const markerRe = /<!--copilot:(summary|agent[^>]*|multi-agent(?:-end)?[^>]*)-->/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("<!--copilot:multi-agent")) continue; // open/end chrome
|
||||
const inner = stripLeadingHeading(unescapeFanoutMarkers(section.body)).trim();
|
||||
if (section.marker === FANOUT_MARKER_SUMMARY) {
|
||||
summaryText = inner;
|
||||
continue;
|
||||
}
|
||||
// Agent section.
|
||||
const id = readMarkerAttr(section.marker, "id");
|
||||
if (!id) continue;
|
||||
const status = statusFromMarker(readMarkerAttr(section.marker, "status"));
|
||||
const note = readMarkerAttr(section.marker, "note");
|
||||
const errorReason = readMarkerAttr(section.marker, "error");
|
||||
answers[id] = {
|
||||
backendId: id,
|
||||
status,
|
||||
// A body-less "did not answer" marker (carries `note`) is an empty slot;
|
||||
// every other slot carries its body verbatim.
|
||||
text: note !== undefined ? "" : inner,
|
||||
...(errorReason !== undefined ? { error: errorReason } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// An empty wrapper (no summary AND no agent sections) is not a real turn.
|
||||
if (summaryText.length === 0 && Object.keys(answers).length === 0) return null;
|
||||
|
||||
return { answers, summary: { status: "done", text: summaryText } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the leading cosmetic `### Heading` line a section body opens with, if any.
|
||||
* Strips only that FIRST non-blank line and only when it's an ATX heading, so
|
||||
* answer prose using `###` further down is preserved.
|
||||
*/
|
||||
function stripLeadingHeading(sectionBody: string): string {
|
||||
const lines = sectionBody.split("\n");
|
||||
let i = 0;
|
||||
while (i < lines.length && lines[i].trim() === "") i += 1;
|
||||
if (i < lines.length && /^#{1,6}\s/.test(lines[i].trim())) {
|
||||
return lines.slice(i + 1).join("\n");
|
||||
}
|
||||
return sectionBody;
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import type React from "react";
|
||||
import type { FormattedDateTime, MessageContext } from "@/types/message";
|
||||
// `import type` keeps the cycle with `fanoutTypes` compile-time only.
|
||||
import type { FanoutTurn } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
|
||||
export type {
|
||||
BackendAuth,
|
||||
|
|
@ -61,6 +63,12 @@ export interface ModeMapping {
|
|||
/** Required when `kind === "configOption"`. Ignored for `setMode`. */
|
||||
configId?: string;
|
||||
canonical: Partial<Record<CopilotMode, string>>;
|
||||
/**
|
||||
* Native mode id of the backend's genuine READ-ONLY sandbox (Codex
|
||||
* `"read-only"`); unset when none exists. Distinct from `canonical.plan`, which
|
||||
* may write plan artifacts (Claude). The fan-out orchestrator applies this.
|
||||
*/
|
||||
readOnlyModeId?: string | null;
|
||||
}
|
||||
|
||||
/** One option in the mode picker — a Copilot-canonical mode the backend supports. */
|
||||
|
|
@ -670,6 +678,13 @@ export interface BackendProcess {
|
|||
setAskUserQuestionPrompter?(
|
||||
fn: (req: AskUserQuestionPrompt) => Promise<AgentQuestionAnswers>
|
||||
): void;
|
||||
/**
|
||||
* Optional: register a predicate telling the backend whether a session is an
|
||||
* ephemeral read-only fan-out sub-session. Backends with their own permission
|
||||
* gate (Claude SDK's `canUseTool`) use it to hard-deny write/exec for such
|
||||
* sessions; ACP backends governed by the shared prompter omit it.
|
||||
*/
|
||||
setReadOnlySessionPredicate?(fn: (sessionId: SessionId) => boolean): void;
|
||||
registerSessionHandler(sessionId: SessionId, handler: SessionUpdateHandler): () => void;
|
||||
newSession(params: OpenSessionInput): Promise<OpenSessionOutput>;
|
||||
prompt(params: PromptInput): Promise<PromptOutput>;
|
||||
|
|
@ -790,6 +805,13 @@ export interface AgentChatMessage {
|
|||
* turn ends.
|
||||
*/
|
||||
turnDurationMs?: number;
|
||||
/**
|
||||
* Per-agent fan-out state when this assistant message is a multi-agent QA turn.
|
||||
* LIVE in-memory only — persistence rides in the body as a composite
|
||||
* (`serializeFanoutComposite`) and is reconstructed here on load
|
||||
* (`parseFanoutComposite`). When present, the UI renders the tab row.
|
||||
*/
|
||||
fanout?: FanoutTurn;
|
||||
}
|
||||
|
||||
/** Creation shape — id is assigned by the store if absent. */
|
||||
|
|
|
|||
110
src/agentMode/ui/AgentChatInput.test.tsx
Normal file
110
src/agentMode/ui/AgentChatInput.test.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import { EMPTY_AGENT_MENTION_BRANDS } from "@/components/chat-components/hooks/useAtMentionCategories";
|
||||
import { render } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
// Entitlement gate — flipped per test.
|
||||
const mockUseCanUseMultiAgent = jest.fn<boolean, []>();
|
||||
const mockNavigateToPlusPage = jest.fn();
|
||||
jest.mock("@/plusUtils", () => ({
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useCanUseMultiAgent` hook; the name must match the export
|
||||
useCanUseMultiAgent: () => mockUseCanUseMultiAgent(),
|
||||
navigateToPlusPage: (...args: unknown[]) => mockNavigateToPlusPage(...args),
|
||||
}));
|
||||
|
||||
// Installed agents the gate either surfaces or suppresses.
|
||||
const FAKE_BRANDS = Object.freeze([{ id: "claude", displayName: "Claude", Icon: () => null }]);
|
||||
jest.mock("@/agentMode/ui/mentionedAgents", () => ({
|
||||
EMPTY_ANSWERERS: Object.freeze([]),
|
||||
isFanout: () => false,
|
||||
resolveAnswerers: () => [],
|
||||
listInstalledAgentBrands: () => FAKE_BRANDS,
|
||||
}));
|
||||
|
||||
// Capture the brands handed to the editor without rendering the heavy
|
||||
// Lexical-backed composer.
|
||||
let capturedAgentBrands: ReadonlyArray<unknown> | undefined;
|
||||
jest.mock("@/components/chat-components/ChatInput", () => ({
|
||||
__esModule: true,
|
||||
default: (props: { agentBrands?: ReadonlyArray<unknown> }) => {
|
||||
capturedAgentBrands = props.agentBrands;
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
|
||||
// Trim incidental module-level dependencies the composer pulls in.
|
||||
jest.mock("@/components/chat-components/hooks/useActiveWebTabState", () => ({
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useActiveWebTabState` hook; the name must match the export
|
||||
useActiveWebTabState: () => ({ activeWebTabForMentions: undefined }),
|
||||
}));
|
||||
jest.mock("@/aiParams", () => ({
|
||||
clearSelectedTextContexts: jest.fn(),
|
||||
removeSelectedTextContext: jest.fn(),
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useSelectedTextContexts` hook; the name must match the export
|
||||
useSelectedTextContexts: () => [[], jest.fn()],
|
||||
}));
|
||||
jest.mock("@/settings/model", () => ({
|
||||
// eslint-disable-next-line @eslint-react/hooks-extra/no-unnecessary-use-prefix -- mocks the real `useSettingsValue` hook; the name must match the export
|
||||
useSettingsValue: () => ({}),
|
||||
}));
|
||||
jest.mock("@/commands/customCommandManager", () => ({
|
||||
CustomCommandManager: { getInstance: () => ({ recordUsage: jest.fn() }) },
|
||||
}));
|
||||
jest.mock("@/commands/state", () => ({ getCachedCustomCommands: () => [] }));
|
||||
|
||||
import { AgentChatInput } from "@/agentMode/ui/AgentChatInput";
|
||||
|
||||
function renderComposer() {
|
||||
const draft = {
|
||||
input: "",
|
||||
images: [],
|
||||
contextNotes: [],
|
||||
includeActiveNote: false,
|
||||
includeActiveWebTab: false,
|
||||
loading: false,
|
||||
queue: [],
|
||||
setInput: jest.fn(),
|
||||
setContextNotes: jest.fn(),
|
||||
setSelectedImages: jest.fn(),
|
||||
addImages: jest.fn(),
|
||||
setIncludeActiveNote: jest.fn(),
|
||||
setIncludeActiveWebTab: jest.fn(),
|
||||
setLoading: jest.fn(),
|
||||
setQueue: jest.fn(),
|
||||
resetCompose: jest.fn(),
|
||||
};
|
||||
const app = { workspace: { getActiveFile: () => null } };
|
||||
const props = {
|
||||
backend: { sendMessage: jest.fn(), cancel: jest.fn() },
|
||||
sessionId: "s1",
|
||||
draft,
|
||||
app,
|
||||
mainAgentId: null,
|
||||
updateUserMessageHistory: jest.fn(),
|
||||
isStarting: false,
|
||||
hasPendingPlanPermission: false,
|
||||
modelPickerOverride: null,
|
||||
modePickerOverride: null,
|
||||
onCycleMode: jest.fn(),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any;
|
||||
render(<AgentChatInput {...props} />);
|
||||
}
|
||||
|
||||
describe("AgentChatInput agent-mention gate", () => {
|
||||
beforeEach(() => {
|
||||
capturedAgentBrands = undefined;
|
||||
mockNavigateToPlusPage.mockClear();
|
||||
});
|
||||
|
||||
it("passes the real installed-agent list when entitled", () => {
|
||||
mockUseCanUseMultiAgent.mockReturnValue(true);
|
||||
renderComposer();
|
||||
expect(capturedAgentBrands).toBe(FAKE_BRANDS);
|
||||
});
|
||||
|
||||
it("passes the frozen empty list (not a fresh []) when not entitled", () => {
|
||||
mockUseCanUseMultiAgent.mockReturnValue(false);
|
||||
renderComposer();
|
||||
expect(capturedAgentBrands).toBe(EMPTY_AGENT_MENTION_BRANDS);
|
||||
});
|
||||
});
|
||||
|
|
@ -14,11 +14,21 @@ import {
|
|||
import { CustomCommandManager } from "@/commands/customCommandManager";
|
||||
import { getCachedCustomCommands } from "@/commands/state";
|
||||
import ChatInput, { type ChatInputProps } from "@/components/chat-components/ChatInput";
|
||||
import { EMPTY_AGENT_MENTION_BRANDS } from "@/components/chat-components/hooks/useAtMentionCategories";
|
||||
import { useActiveWebTabState } from "@/components/chat-components/hooks/useActiveWebTabState";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ACTIVE_WEB_TAB_MARKER, EVENT_NAMES } from "@/constants";
|
||||
import { ACTIVE_WEB_TAB_MARKER, EVENT_NAMES, PLUS_UTM_MEDIUMS } from "@/constants";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { navigateToPlusPage, useCanUseMultiAgent } from "@/plusUtils";
|
||||
import { EventTargetContext } from "@/context";
|
||||
import { logError, logWarn } from "@/logger";
|
||||
import {
|
||||
isFanout,
|
||||
listInstalledAgentBrands,
|
||||
resolveAnswerers,
|
||||
} from "@/agentMode/ui/mentionedAgents";
|
||||
import type { BackendId } from "@/agentMode/session/types";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { buildWebTabsWithActiveSnapshot } from "@/services/webViewerService/activeWebTabSnapshot";
|
||||
import {
|
||||
isNoteSelectedTextContext,
|
||||
|
|
@ -28,9 +38,9 @@ import {
|
|||
} from "@/types/message";
|
||||
import { arrayBufferToBase64 } from "@/utils/base64";
|
||||
import { mergeWebTabContexts } from "@/utils/urlNormalization";
|
||||
import { Clock, X } from "lucide-react";
|
||||
import { Clock, Sparkles, X } from "lucide-react";
|
||||
import { App, Notice, TFile } from "obsidian";
|
||||
import React, { memo, useCallback, useContext, useEffect, useRef } from "react";
|
||||
import React, { memo, useCallback, useContext, useEffect, useMemo, useRef } from "react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
interface AgentChatInputProps {
|
||||
|
|
@ -45,6 +55,11 @@ interface AgentChatInputProps {
|
|||
*/
|
||||
draft: AgentInputDraftControls;
|
||||
app: App;
|
||||
/**
|
||||
* The session's main agent (the summarizer). Used by `isFanout` to collapse the
|
||||
* degenerate `[main]` selection to the single-agent path. `null` before a session lands.
|
||||
*/
|
||||
mainAgentId: BackendId | null;
|
||||
updateUserMessageHistory: (newMessage: string) => void;
|
||||
isStarting: boolean;
|
||||
hasPendingPlanPermission: boolean;
|
||||
|
|
@ -90,6 +105,11 @@ const combineQueuedMessages = (items: QueuedAgentMessage[]): QueuedAgentMessage
|
|||
const allSelected = items.flatMap((i) => i.context?.selectedTextContexts ?? []);
|
||||
const allWebTabs = items.flatMap((i) => i.context?.webTabs ?? []);
|
||||
const allPromptContent = items.flatMap((i) => i.promptContent ?? []);
|
||||
// Union the per-message answerer selections, preserving first-seen order.
|
||||
const mergedAgents = dedupeBy(
|
||||
items.flatMap((i) => i.mentionedAgents ?? []),
|
||||
(id) => id
|
||||
);
|
||||
|
||||
return {
|
||||
id: `queued-combined-${uuidv4()}`,
|
||||
|
|
@ -101,6 +121,7 @@ const combineQueuedMessages = (items: QueuedAgentMessage[]): QueuedAgentMessage
|
|||
mergeWebTabContexts(allWebTabs)
|
||||
),
|
||||
promptContent: allPromptContent.length > 0 ? allPromptContent : undefined,
|
||||
mentionedAgents: mergedAgents.length > 0 ? mergedAgents : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -132,6 +153,7 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
sessionId,
|
||||
draft,
|
||||
app,
|
||||
mainAgentId,
|
||||
updateUserMessageHistory,
|
||||
isStarting,
|
||||
hasPendingPlanPermission,
|
||||
|
|
@ -140,6 +162,7 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
onCycleMode,
|
||||
}: AgentChatInputProps) {
|
||||
const eventTarget = useContext(EventTargetContext);
|
||||
const settings = useSettingsValue();
|
||||
const [selectedTextContexts] = useSelectedTextContexts();
|
||||
// SSoT for the Active Web Tab; `activeWebTabForMentions` matches the send
|
||||
// snapshot (preserved only when focusing the chat panel). Drives the
|
||||
|
|
@ -150,6 +173,28 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
const isMountedRef = useRef(false);
|
||||
const previousSessionIdRef = useRef(sessionId);
|
||||
|
||||
// The `@agent` typeahead group + pills are paid-only. Reactive so a settings
|
||||
// change flips the gate live; the authoritative send-time check is separate.
|
||||
const canUseMultiAgent = useCanUseMultiAgent();
|
||||
|
||||
// Installed agents the user can `@`-mention, recomputed only on settings change.
|
||||
const installedAgentBrands = useMemo(() => listInstalledAgentBrands(settings), [settings]);
|
||||
// Entitlement-gated typeahead list: free users get the frozen empty list so the
|
||||
// "Agents" group never renders. Both operands are stable refs (no memo needed).
|
||||
const agentBrands = canUseMultiAgent ? installedAgentBrands : EMPTY_AGENT_MENTION_BRANDS;
|
||||
// The send-time allowlist is the REAL installed set, INDEPENDENT of the gated
|
||||
// typeahead list: a pasted pill (or a stale-false cache) must still resolve to a
|
||||
// real answerer so the turn fans out and hits the authoritative entitlement check.
|
||||
const installedAgentIds = useMemo(
|
||||
() => new Set(installedAgentBrands.map((b) => b.id)),
|
||||
[installedAgentBrands]
|
||||
);
|
||||
// Held in a ref (not state) so a mention edit never re-renders mid-stream; read at send time.
|
||||
const mentionedAgentIdsRef = useRef<string[]>([]);
|
||||
const handleMentionedAgentsChange = useCallback((backendIds: string[]) => {
|
||||
mentionedAgentIdsRef.current = backendIds;
|
||||
}, []);
|
||||
|
||||
// Draft state is owned by AgentHome (so it can read `loading`/feed the drop
|
||||
// overlay); this composer is the controlled consumer.
|
||||
const {
|
||||
|
|
@ -178,13 +223,14 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
};
|
||||
}, []);
|
||||
|
||||
// Selected-text contexts are a global ephemeral atom, not per-session draft.
|
||||
// Clear them when switching sessions so a selection made in one session
|
||||
// doesn't silently ride along into the next.
|
||||
// Clear cross-session ephemeral state on a session switch: the global
|
||||
// selected-text atom and the mentioned-agent ref (neither is reset by the
|
||||
// editor remount), so a selection or `@agent` pill can't ride into the next session.
|
||||
useEffect(() => {
|
||||
if (previousSessionIdRef.current === sessionId) return;
|
||||
previousSessionIdRef.current = sessionId;
|
||||
clearSelectedTextContexts();
|
||||
mentionedAgentIdsRef.current = [];
|
||||
}, [sessionId]);
|
||||
|
||||
const handleStopGenerating = useCallback(async () => {
|
||||
|
|
@ -203,7 +249,12 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
async (item: QueuedAgentMessage) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { turn } = backend.sendMessage(item.text, item.context, item.promptContent);
|
||||
const { turn } = backend.sendMessage(
|
||||
item.text,
|
||||
item.context,
|
||||
item.promptContent,
|
||||
item.mentionedAgents
|
||||
);
|
||||
if (item.rawInput) updateUserMessageHistory(item.rawInput);
|
||||
await turn;
|
||||
} catch (error) {
|
||||
|
|
@ -268,14 +319,28 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
if (block) content.push(block);
|
||||
}
|
||||
|
||||
// Resolve the `@`-mentions into the ANSWERER set (installed, deduped). Only
|
||||
// carried when it actually fans out; the single-agent path sends no
|
||||
// `mentionedAgents` and stays byte-for-byte the existing behavior.
|
||||
let mentionedAgents: ReadonlyArray<BackendId> | undefined;
|
||||
if (mainAgentId) {
|
||||
const answerers = resolveAnswerers({
|
||||
mentionedAgentIds: mentionedAgentIdsRef.current,
|
||||
installedAgentIds,
|
||||
});
|
||||
if (isFanout(answerers, mainAgentId)) mentionedAgents = answerers;
|
||||
}
|
||||
|
||||
const item: QueuedAgentMessage = {
|
||||
id: `queued-${uuidv4()}`,
|
||||
text: resolvedText,
|
||||
rawInput,
|
||||
context: buildMessageContext(notes, selectedTextContexts, resolvedWebTabs),
|
||||
promptContent: content.length > 0 ? content : undefined,
|
||||
mentionedAgents,
|
||||
};
|
||||
|
||||
mentionedAgentIdsRef.current = [];
|
||||
resetCompose();
|
||||
// The message context was already snapshotted above from this render's
|
||||
// captured `selectedTextContexts`, so clearing the global atom here is safe
|
||||
|
|
@ -306,6 +371,8 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
resetCompose,
|
||||
runSend,
|
||||
setQueuedMessages,
|
||||
mainAgentId,
|
||||
installedAgentIds,
|
||||
]
|
||||
);
|
||||
|
||||
|
|
@ -360,6 +427,7 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
{queuedMessages.length > 0 && (
|
||||
<QueuedMessageList messages={queuedMessages} onRemove={handleRemoveQueuedMessage} />
|
||||
)}
|
||||
{!canUseMultiAgent && <MultiAgentUpsellHint />}
|
||||
<div
|
||||
className={hasPendingPlanPermission ? "tw-pointer-events-none tw-opacity-50" : undefined}
|
||||
aria-disabled={hasPendingPlanPermission || undefined}
|
||||
|
|
@ -400,6 +468,8 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
modePickerOverride={modePickerOverride ?? undefined}
|
||||
selectedTextContexts={selectedTextContexts}
|
||||
onRemoveSelectedText={removeSelectedTextContext}
|
||||
agentBrands={agentBrands}
|
||||
onMentionedAgentsChange={handleMentionedAgentsChange}
|
||||
showProgressCard={NOOP}
|
||||
showIndexingCard={NOOP}
|
||||
/>
|
||||
|
|
@ -408,6 +478,26 @@ export const AgentChatInput = memo(function AgentChatInput({
|
|||
);
|
||||
});
|
||||
|
||||
/** Upsell shown to free users where the `@`-mention affordance would otherwise be. */
|
||||
const MultiAgentUpsellHint: React.FC = () => {
|
||||
return (
|
||||
<div className="tw-flex tw-justify-end tw-px-2 tw-pb-1">
|
||||
<Button
|
||||
variant="ghost2"
|
||||
size="fit"
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-text-ui-smaller tw-text-muted",
|
||||
"hover:tw-text-normal"
|
||||
)}
|
||||
onClick={() => navigateToPlusPage(PLUS_UTM_MEDIUMS.MULTI_AGENT)}
|
||||
>
|
||||
<Sparkles className="tw-size-3" />
|
||||
Mention multiple agents with Copilot Plus
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface QueuedMessageListProps {
|
||||
messages: QueuedAgentMessage[];
|
||||
onRemove: (id: string) => void;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { AgentTrail } from "@/agentMode/ui/AgentTrailView";
|
||||
import { AskUserQuestionCard } from "@/agentMode/ui/AskUserQuestionCard";
|
||||
import { FanoutMessageCard } from "@/agentMode/ui/FanoutMessageCard";
|
||||
import { PlanProposalCard } from "@/agentMode/ui/PlanProposalCard";
|
||||
import { ToolPermissionCard } from "@/agentMode/ui/ToolPermissionCard";
|
||||
import { BottomLoadingIndicator } from "@/components/chat-components/BottomLoadingIndicator";
|
||||
|
|
@ -47,6 +48,14 @@ function toChatMessageView(m: AgentChatMessage): ChatMessage {
|
|||
};
|
||||
}
|
||||
|
||||
/** The last non-user (assistant) message, or `undefined` if none. */
|
||||
function lastAssistant(visible: AgentChatMessage[]): AgentChatMessage | undefined {
|
||||
for (let i = visible.length - 1; i >= 0; i--) {
|
||||
if (visible[i].sender !== USER_SENDER) return visible[i];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const AgentChatMessages = memo(
|
||||
({
|
||||
messages,
|
||||
|
|
@ -92,13 +101,7 @@ const AgentChatMessages = memo(
|
|||
// in-place `isStreamingPlaceholder` spinner covers that).
|
||||
const { streamingMessageId, showBottomLoader } = useMemo(() => {
|
||||
if (!isLoading) return { streamingMessageId: undefined, showBottomLoader: false };
|
||||
let streaming: AgentChatMessage | undefined;
|
||||
for (let i = visible.length - 1; i >= 0; i--) {
|
||||
if (visible[i].sender !== USER_SENDER) {
|
||||
streaming = visible[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const streaming = lastAssistant(visible);
|
||||
if (!streaming) return { streamingMessageId: undefined, showBottomLoader: false };
|
||||
const parts = streaming.parts ?? [];
|
||||
const last = parts[parts.length - 1];
|
||||
|
|
@ -146,6 +149,12 @@ const AgentChatMessages = memo(
|
|||
// they hit send rather than an empty assistant bubble.
|
||||
const isStreamingPlaceholder =
|
||||
isAssistant && message.id === streamingMessageId && !hasParts && !message.message;
|
||||
// A multi-agent turn owns this message's body — the segmented tab
|
||||
// row replaces the plain assistant text and the streaming spinner
|
||||
// (its per-agent slots show their own live states). `message.fanout`
|
||||
// is present for BOTH the live in-flight turn and a reloaded
|
||||
// transcript whose composite body was parsed back into a turn.
|
||||
const fanoutTurn = isAssistant ? message.fanout : undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -156,7 +165,11 @@ const AgentChatMessages = memo(
|
|||
minHeight: shouldApplyMinHeight ? `${containerMinHeight}px` : "auto",
|
||||
}}
|
||||
>
|
||||
{isStreamingPlaceholder ? (
|
||||
{fanoutTurn ? (
|
||||
<div className="tw-px-3 tw-pt-2">
|
||||
<FanoutMessageCard message={message} turn={fanoutTurn} app={app} />
|
||||
</div>
|
||||
) : isStreamingPlaceholder ? (
|
||||
<div className="tw-px-3 tw-pt-2">
|
||||
<BottomLoadingIndicator />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -219,6 +219,15 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
// surface flips from the centered landing to the conversation layout.
|
||||
const isGlobalLanding = !manager.getActiveSession()?.hasUserVisibleMessages();
|
||||
|
||||
// The session's main agent — the summarizer and the dedup anchor for
|
||||
// `@`-mentions. The composer belongs to the ACTIVE session, so anchor to its
|
||||
// backend whenever one exists; fall back to the starting backend only for the
|
||||
// initial no-session startup. (Preferring the starting backend would mis-anchor
|
||||
// mentions to a backend cold-starting in another tab — e.g. `@opencode` from a
|
||||
// visible Claude chat would collapse to the degenerate single-agent path.)
|
||||
const mainAgentId =
|
||||
manager.getActiveSession()?.backendId ?? manager.getStartingBackendId() ?? null;
|
||||
|
||||
// Rotating landing greeting: re-rolled per session id (so each fresh chat /
|
||||
// landing open gets a new line) but stable across the stream re-renders within
|
||||
// a session, so it doesn't flicker as tokens arrive. sessionId is the
|
||||
|
|
@ -380,6 +389,7 @@ const AgentHomeInternal: React.FC<AgentHomeProps> = ({
|
|||
sessionId={sessionId}
|
||||
draft={draft}
|
||||
app={app}
|
||||
mainAgentId={mainAgentId}
|
||||
updateUserMessageHistory={updateUserMessageHistory}
|
||||
isStarting={isStarting}
|
||||
hasPendingPlanPermission={hasPendingPlanPermission}
|
||||
|
|
|
|||
87
src/agentMode/ui/FanoutMessageCard.tsx
Normal file
87
src/agentMode/ui/FanoutMessageCard.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { FanoutTurnView } from "@/agentMode/ui/FanoutTurnView";
|
||||
import {
|
||||
defaultFanoutOption,
|
||||
fanoutDisplayName,
|
||||
FANOUT_SUMMARY_OPTION,
|
||||
type FanoutOptionValue,
|
||||
} from "@/agentMode/ui/fanoutDropdown";
|
||||
import { ChatButtons } from "@/components/chat-components/ChatButtons";
|
||||
import type { FanoutTurn } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import { renderFanoutComposite } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import type { AgentChatMessage } from "@/agentMode/session/types";
|
||||
import type { ChatMessage } from "@/types/message";
|
||||
import { insertAtCursor } from "@/utils";
|
||||
import { App } from "obsidian";
|
||||
import React, { memo, useCallback, useMemo, useState } from "react";
|
||||
|
||||
interface FanoutMessageCardProps {
|
||||
/** The assistant message owning a multi-agent fan-out turn. */
|
||||
message: AgentChatMessage;
|
||||
turn: FanoutTurn;
|
||||
app: App;
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant card for a fan-out turn: the segmented tab row
|
||||
* ({@link FanoutTurnView}) plus the same action bar as the normal AI card. Its
|
||||
* one Copy/Insert affordance is context-aware — the WHOLE composite on the
|
||||
* Summary tab, just that agent's answer on an agent tab. The card owns the
|
||||
* selected tab so the action bar can target it.
|
||||
*/
|
||||
export const FanoutMessageCard: React.FC<FanoutMessageCardProps> = memo(
|
||||
({ message, turn, app }) => {
|
||||
const [selected, setSelected] = useState<FanoutOptionValue>(() => defaultFanoutOption(turn));
|
||||
|
||||
// Fall back to the summary if the selected slot disappears (defensive).
|
||||
const activeValue =
|
||||
selected !== FANOUT_SUMMARY_OPTION && !turn.answers[selected]
|
||||
? FANOUT_SUMMARY_OPTION
|
||||
: selected;
|
||||
|
||||
// What Copy/Insert operate on: the whole composite on the Summary tab, else
|
||||
// just the selected agent's answer.
|
||||
const currentText = useMemo(
|
||||
() =>
|
||||
activeValue === FANOUT_SUMMARY_OPTION
|
||||
? renderFanoutComposite(turn, fanoutDisplayName)
|
||||
: (turn.answers[activeValue]?.text ?? ""),
|
||||
[turn, activeValue]
|
||||
);
|
||||
|
||||
const handleInsert = useCallback(() => {
|
||||
void insertAtCursor(app, currentText);
|
||||
}, [app, currentText]);
|
||||
|
||||
// Reuse ChatButtons by handing it a view whose `message` is the selected tab's
|
||||
// text (its Copy reads `message.message`).
|
||||
const buttonsMessage = useMemo<ChatMessage>(
|
||||
() => ({
|
||||
id: message.id,
|
||||
sender: message.sender,
|
||||
message: currentText,
|
||||
timestamp: message.timestamp,
|
||||
isVisible: message.isVisible,
|
||||
}),
|
||||
[message.id, message.sender, message.timestamp, message.isVisible, currentText]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="tw-my-1 tw-flex tw-w-full tw-flex-col">
|
||||
<div className="tw-group tw-mx-2 tw-rounded-md tw-p-2">
|
||||
<div className="tw-flex tw-max-w-full tw-flex-col tw-gap-2 tw-overflow-hidden">
|
||||
<FanoutTurnView turn={turn} app={app} value={activeValue} onSelect={setSelected} />
|
||||
<div className="tw-flex tw-items-center tw-justify-between">
|
||||
<div className="tw-text-xs tw-text-faint">{message.timestamp?.display}</div>
|
||||
<ChatButtons
|
||||
message={buttonsMessage}
|
||||
onInsertIntoEditor={handleInsert}
|
||||
hasSources={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
FanoutMessageCard.displayName = "FanoutMessageCard";
|
||||
83
src/agentMode/ui/FanoutTurnView.test.tsx
Normal file
83
src/agentMode/ui/FanoutTurnView.test.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import React, { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import type {
|
||||
AgentAnswer,
|
||||
AgentAnswerStatus,
|
||||
FanoutTurn,
|
||||
} from "@/agentMode/session/fanout/fanoutTypes";
|
||||
|
||||
// Render markdown as plain text so the test doesn't pull in Obsidian's
|
||||
// renderer (mirrors AgentTrailView.test.tsx).
|
||||
jest.mock("@/agentMode/ui/AgentMarkdownText", () => ({
|
||||
AgentMarkdownText: ({ text }: { text: string }) => <div data-testid="agent-md">{text}</div>,
|
||||
}));
|
||||
|
||||
jest.mock("@/agentMode/backends/registry", () => {
|
||||
const Icon = () => null;
|
||||
return {
|
||||
backendRegistry: {
|
||||
opencode: { id: "opencode", displayName: "opencode", Icon },
|
||||
claude: { id: "claude", displayName: "Claude", Icon },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { FanoutTurnView } from "@/agentMode/ui/FanoutTurnView";
|
||||
import { defaultFanoutOption, type FanoutOptionValue } from "@/agentMode/ui/fanoutDropdown";
|
||||
|
||||
function answer(
|
||||
backendId: string,
|
||||
status: AgentAnswerStatus,
|
||||
text = "",
|
||||
error?: string
|
||||
): AgentAnswer {
|
||||
return { backendId, status, text, error };
|
||||
}
|
||||
|
||||
function turn(
|
||||
answers: AgentAnswer[],
|
||||
summaryText = "",
|
||||
summaryStatus: FanoutTurn["summary"]["status"] = "done"
|
||||
): FanoutTurn {
|
||||
const map: Record<string, AgentAnswer> = {};
|
||||
for (const a of answers) map[a.backendId] = a;
|
||||
return { answers: map, summary: { status: summaryStatus, text: summaryText } };
|
||||
}
|
||||
|
||||
const app = { workspace: { getActiveFile: () => null } } as never;
|
||||
|
||||
// FanoutTurnView is controlled (the card owns the selected tab); a tiny stateful
|
||||
// harness supplies value/onSelect so a tab click still switches the body.
|
||||
const Harness: React.FC<{ t: FanoutTurn }> = ({ t }) => {
|
||||
const [value, setValue] = useState<FanoutOptionValue>(() => defaultFanoutOption(t));
|
||||
return <FanoutTurnView turn={t} app={app} value={value} onSelect={setValue} />;
|
||||
};
|
||||
|
||||
const renderView = (t: FanoutTurn) => render(<Harness t={t} />);
|
||||
|
||||
describe("FanoutTurnView", () => {
|
||||
it("defaults to the summary view (summary-first)", () => {
|
||||
const t = turn([answer("opencode", "done", "main")], "the narrative summary");
|
||||
renderView(t);
|
||||
expect(screen.getByTestId("agent-md").textContent).toBe("the narrative summary");
|
||||
});
|
||||
|
||||
it("shows a pending placeholder when the summary has no text yet", () => {
|
||||
const t = turn([answer("opencode", "running")], "", "pending");
|
||||
renderView(t);
|
||||
expect(screen.queryByTestId("agent-md")).toBeNull();
|
||||
expect(screen.getByText(/Waiting for answers/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("switches to the selected agent's answer when its tab is clicked", () => {
|
||||
const t = turn(
|
||||
[answer("opencode", "done", "OPENCODE_BODY"), answer("claude", "done", "CLAUDE_BODY")],
|
||||
"the narrative summary"
|
||||
);
|
||||
renderView(t);
|
||||
// Summary first.
|
||||
expect(screen.getByTestId("agent-md").textContent).toBe("the narrative summary");
|
||||
fireEvent.click(screen.getByRole("tab", { name: /opencode/ }));
|
||||
expect(screen.getByTestId("agent-md").textContent).toBe("OPENCODE_BODY");
|
||||
});
|
||||
});
|
||||
245
src/agentMode/ui/FanoutTurnView.tsx
Normal file
245
src/agentMode/ui/FanoutTurnView.tsx
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { AgentMarkdownText } from "@/agentMode/ui/AgentMarkdownText";
|
||||
import {
|
||||
buildFanoutOptions,
|
||||
FANOUT_SUMMARY_OPTION,
|
||||
selectedAnswer,
|
||||
summaryDisplayState,
|
||||
type FanoutAgentState,
|
||||
type FanoutOption,
|
||||
type FanoutOptionValue,
|
||||
} from "@/agentMode/ui/fanoutDropdown";
|
||||
import { CopilotSpinner } from "@/components/chat-components/CopilotSpinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FanoutTurn } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import { App } from "obsidian";
|
||||
import { AlertTriangle, Check, CircleSlash, Loader2 } from "lucide-react";
|
||||
import React, { memo, useCallback, useMemo } from "react";
|
||||
|
||||
/** The app's animated sigma spinner, sized to sit inline with a status label. */
|
||||
const ThinkingSpinner: React.FC = () => (
|
||||
<span className="tw-flex tw-size-4 tw-shrink-0 tw-items-center tw-justify-center">
|
||||
<CopilotSpinner />
|
||||
</span>
|
||||
);
|
||||
|
||||
interface FanoutTurnViewProps {
|
||||
/** Fan-out turn for a multi-agent assistant message (live or reloaded). */
|
||||
turn: FanoutTurn;
|
||||
app: App;
|
||||
/** Selected tab — controlled by the card so its action bar can copy/insert it. */
|
||||
value: FanoutOptionValue;
|
||||
onSelect: (value: FanoutOptionValue) => void;
|
||||
}
|
||||
|
||||
interface FanoutTabProps {
|
||||
option: FanoutOption;
|
||||
selected: boolean;
|
||||
onSelect: (value: FanoutOptionValue) => void;
|
||||
}
|
||||
|
||||
/** One segmented-row tab: brand icon, label, and live status dot. */
|
||||
const FanoutTab: React.FC<FanoutTabProps> = ({ option, selected, onSelect }) => {
|
||||
const { value, Icon, label, state } = option;
|
||||
const handleClick = useCallback(() => onSelect(value), [onSelect, value]);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-1.5 tw-rounded-md tw-border tw-border-solid tw-border-transparent tw-px-2 tw-py-1 tw-text-sm tw-transition-colors",
|
||||
selected
|
||||
? "tw-bg-interactive-accent tw-text-on-accent"
|
||||
: "tw-text-muted hover:tw-bg-interactive-hover"
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="tw-size-4 tw-shrink-0" /> : null}
|
||||
<span className="tw-max-w-32 tw-truncate">{label}</span>
|
||||
<FanoutStatusDot state={state} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface FanoutStatusDotProps {
|
||||
/** Agent live state; `undefined` for the summary tab (it has its own state). */
|
||||
state: FanoutAgentState | undefined;
|
||||
}
|
||||
|
||||
/** The trailing status indicator on an agent tab; the summary tab renders nothing. */
|
||||
const FanoutStatusDot: React.FC<FanoutStatusDotProps> = ({ state }) => {
|
||||
if (state === "streaming") {
|
||||
return <Loader2 className="tw-size-3 tw-shrink-0 tw-animate-spin tw-text-loading" />;
|
||||
}
|
||||
if (state === "answer") {
|
||||
return <Check className="tw-size-3 tw-shrink-0 tw-text-success" />;
|
||||
}
|
||||
if (state === "error") {
|
||||
return <AlertTriangle className="tw-size-3 tw-shrink-0 tw-text-error" />;
|
||||
}
|
||||
if (state === "cancelled") {
|
||||
return <CircleSlash className="tw-size-3 tw-shrink-0 tw-text-muted" />;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Render a fan-out turn as one assistant turn: a segmented tab row (Summary
|
||||
* first, default) switching between the summary and each agent's answer, each
|
||||
* tab reflecting its live state. Renders for BOTH a live turn and a reloaded
|
||||
* composite. Controlled — the owning card holds the selected tab so its action
|
||||
* bar can Copy/Insert the tab in view.
|
||||
*/
|
||||
export const FanoutTurnView: React.FC<FanoutTurnViewProps> = memo(
|
||||
({ turn, app, value, onSelect }) => {
|
||||
const options = useMemo(() => buildFanoutOptions(turn), [turn]);
|
||||
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-2">
|
||||
<div role="tablist" aria-label="Agent answers" className="tw-flex tw-flex-wrap tw-gap-1">
|
||||
{options.map((option) => (
|
||||
<FanoutTab
|
||||
key={option.value}
|
||||
option={option}
|
||||
selected={option.value === value}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FanoutTurnBody turn={turn} value={value} app={app} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
FanoutTurnView.displayName = "FanoutTurnView";
|
||||
|
||||
interface FanoutTurnBodyProps {
|
||||
turn: FanoutTurn;
|
||||
value: FanoutOptionValue;
|
||||
app: App;
|
||||
}
|
||||
|
||||
/**
|
||||
* The body for the current selection: the summary (or its placeholder), else the
|
||||
* chosen agent's answer — streaming, finished, an error chip, or a cancelled
|
||||
* state. Partial text that streamed before a failure/cancel is shown above the chip.
|
||||
*/
|
||||
const FanoutTurnBody: React.FC<FanoutTurnBodyProps> = ({ turn, value, app }) => {
|
||||
if (value === FANOUT_SUMMARY_OPTION) {
|
||||
if (turn.summary.text) {
|
||||
return <FanoutSlotBody text={turn.summary.text} app={app} />;
|
||||
}
|
||||
switch (summaryDisplayState(turn)) {
|
||||
case "writing":
|
||||
return <FanoutStatusLine icon={<ThinkingSpinner />} text="Writing summary…" />;
|
||||
case "waiting":
|
||||
return <FanoutStatusLine icon={<ThinkingSpinner />} text="Waiting for answers…" />;
|
||||
case "cancelled":
|
||||
return (
|
||||
<FanoutStatusLine
|
||||
icon={<CircleSlash className="tw-size-4 tw-text-muted" />}
|
||||
text="Summary cancelled"
|
||||
/>
|
||||
);
|
||||
case "unavailable":
|
||||
return (
|
||||
<FanoutStatusLine
|
||||
icon={<AlertTriangle className="tw-size-4 tw-text-error" />}
|
||||
text="Summary unavailable"
|
||||
tone="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const answer = selectedAnswer(turn, value);
|
||||
if (!answer) return null;
|
||||
|
||||
if (answer.status === "error" || answer.status === "cancelled") {
|
||||
const isError = answer.status === "error";
|
||||
return (
|
||||
<FanoutTerminalState app={app} partialText={answer.text}>
|
||||
<FanoutStatusLine
|
||||
icon={
|
||||
isError ? (
|
||||
<AlertTriangle className="tw-size-4 tw-text-error" />
|
||||
) : (
|
||||
<CircleSlash className="tw-size-4 tw-text-muted" />
|
||||
)
|
||||
}
|
||||
text={isError ? answer.error?.trim() || "This agent failed to answer." : "Cancelled"}
|
||||
tone={isError ? "error" : undefined}
|
||||
/>
|
||||
</FanoutTerminalState>
|
||||
);
|
||||
}
|
||||
|
||||
if (answer.text) {
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<FanoutSlotBody text={answer.text} app={app} />
|
||||
{answer.status === "running" ? (
|
||||
<FanoutStatusLine icon={<ThinkingSpinner />} text="Streaming…" />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Running with no text yet — the in-place thinking spinner.
|
||||
return <FanoutStatusLine icon={<ThinkingSpinner />} text="Thinking…" />;
|
||||
};
|
||||
|
||||
interface FanoutSlotBodyProps {
|
||||
/** The selected slot's markdown text. */
|
||||
text: string;
|
||||
app: App;
|
||||
}
|
||||
|
||||
/** The selected slot's rendered markdown; Copy/Insert lives on the card's action bar. */
|
||||
const FanoutSlotBody: React.FC<FanoutSlotBodyProps> = ({ text, app }) => (
|
||||
<AgentMarkdownText text={text} app={app} />
|
||||
);
|
||||
|
||||
interface FanoutTerminalStateProps {
|
||||
/** Whatever prose streamed before the agent errored or was cancelled. */
|
||||
partialText: string;
|
||||
app: App;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A terminal (error/cancelled) agent body: any partial answer that streamed
|
||||
* before stopping, then the status chip, so a mid-stream stop discards no tokens.
|
||||
*/
|
||||
const FanoutTerminalState: React.FC<FanoutTerminalStateProps> = ({
|
||||
partialText,
|
||||
app,
|
||||
children,
|
||||
}) => {
|
||||
if (!partialText.trim()) return <>{children}</>;
|
||||
return (
|
||||
<div className="tw-flex tw-flex-col tw-gap-1">
|
||||
<AgentMarkdownText text={partialText} app={app} />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FanoutStatusLineProps {
|
||||
icon: React.ReactNode;
|
||||
text: string;
|
||||
tone?: "error";
|
||||
}
|
||||
|
||||
/** A small icon + label line used for streaming / pending / error states. */
|
||||
const FanoutStatusLine: React.FC<FanoutStatusLineProps> = ({ icon, text, tone }) => (
|
||||
<div
|
||||
className={cn(
|
||||
"tw-flex tw-items-center tw-gap-2 tw-p-1 tw-text-sm",
|
||||
tone === "error" ? "tw-text-error" : "tw-text-muted"
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
114
src/agentMode/ui/fanoutDropdown.test.ts
Normal file
114
src/agentMode/ui/fanoutDropdown.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import type { FanoutTurn } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import type { AgentAnswer, AgentAnswerStatus } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
|
||||
// Mock the registry so the helper resolves brands without dragging in the
|
||||
// heavy real backend descriptors (each pulls its ACP/permission chain).
|
||||
jest.mock("@/agentMode/backends/registry", () => {
|
||||
const Icon = () => null;
|
||||
return {
|
||||
backendRegistry: {
|
||||
opencode: { id: "opencode", displayName: "opencode", Icon },
|
||||
claude: { id: "claude", displayName: "Claude", Icon },
|
||||
codex: { id: "codex", displayName: "Codex", Icon },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import type { FanoutSummaryStatus } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import {
|
||||
agentStateForStatus,
|
||||
buildFanoutOptions,
|
||||
defaultFanoutOption,
|
||||
FANOUT_SUMMARY_OPTION,
|
||||
selectedAnswer,
|
||||
summaryDisplayState,
|
||||
} from "@/agentMode/ui/fanoutDropdown";
|
||||
|
||||
function answer(
|
||||
backendId: string,
|
||||
status: AgentAnswerStatus,
|
||||
text = "",
|
||||
error?: string
|
||||
): AgentAnswer {
|
||||
return { backendId, status, text, error };
|
||||
}
|
||||
|
||||
function turn(
|
||||
answers: AgentAnswer[],
|
||||
summaryText = "",
|
||||
summaryStatus: FanoutSummaryStatus = "done"
|
||||
): FanoutTurn {
|
||||
const map: Record<string, AgentAnswer> = {};
|
||||
for (const a of answers) map[a.backendId] = a;
|
||||
return { answers: map, summary: { status: summaryStatus, text: summaryText } };
|
||||
}
|
||||
|
||||
describe("agentStateForStatus", () => {
|
||||
it("maps each slot status to its display state", () => {
|
||||
expect(agentStateForStatus("running")).toBe("streaming");
|
||||
expect(agentStateForStatus("done")).toBe("answer");
|
||||
expect(agentStateForStatus("error")).toBe("error");
|
||||
expect(agentStateForStatus("cancelled")).toBe("cancelled");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildFanoutOptions", () => {
|
||||
it("lists the summary first then each agent in slot order, resolving name/icon and live state", () => {
|
||||
const t = turn([
|
||||
answer("opencode", "done", "main answer"),
|
||||
answer("claude", "running"),
|
||||
answer("codex", "error", "", "boom"),
|
||||
]);
|
||||
const options = buildFanoutOptions(t);
|
||||
|
||||
expect(options.map((o) => o.value)).toEqual([
|
||||
FANOUT_SUMMARY_OPTION,
|
||||
"opencode",
|
||||
"claude",
|
||||
"codex",
|
||||
]);
|
||||
expect(options[0].label).toBe("Summary");
|
||||
expect(options[0].Icon).toBeUndefined();
|
||||
// Brand name + icon resolved from the registry; state mirrors slot status.
|
||||
const claude = options.find((o) => o.value === "claude");
|
||||
expect(claude?.label).toBe("Claude");
|
||||
expect(claude?.Icon).toBeDefined();
|
||||
expect(claude?.state).toBe("streaming");
|
||||
expect(options.find((o) => o.value === "opencode")?.state).toBe("answer");
|
||||
expect(options.find((o) => o.value === "codex")?.state).toBe("error");
|
||||
});
|
||||
|
||||
it("falls back to the backend id when the registry has no entry", () => {
|
||||
const options = buildFanoutOptions(turn([answer("mystery", "done", "x")]));
|
||||
const entry = options.find((o) => o.value === "mystery");
|
||||
expect(entry?.label).toBe("mystery");
|
||||
expect(entry?.Icon).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultFanoutOption", () => {
|
||||
it("defaults to the summary (summary-first, D8)", () => {
|
||||
const t = turn([answer("opencode", "done", "a"), answer("claude", "done", "b")]);
|
||||
expect(defaultFanoutOption(t)).toBe(FANOUT_SUMMARY_OPTION);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectedAnswer", () => {
|
||||
it("returns null for the summary value and the agent's slot for an agent value", () => {
|
||||
const t = turn([answer("opencode", "done", "a")]);
|
||||
expect(selectedAnswer(t, FANOUT_SUMMARY_OPTION)).toBeNull();
|
||||
expect(selectedAnswer(t, "opencode")?.text).toBe("a");
|
||||
expect(selectedAnswer(t, "ghost")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("summaryDisplayState", () => {
|
||||
it("is cancelled when pending but every agent is terminal (turn aborted before summary)", () => {
|
||||
const t = turn([answer("opencode", "cancelled"), answer("claude", "done", "b")], "", "pending");
|
||||
expect(summaryDisplayState(t)).toBe("cancelled");
|
||||
});
|
||||
it("is unavailable when done with no text (summary generation failed)", () => {
|
||||
const t = turn([answer("opencode", "done", "a")], "", "done");
|
||||
expect(summaryDisplayState(t)).toBe("unavailable");
|
||||
});
|
||||
});
|
||||
115
src/agentMode/ui/fanoutDropdown.ts
Normal file
115
src/agentMode/ui/fanoutDropdown.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
import { backendRegistry } from "@/agentMode/backends/registry";
|
||||
import type {
|
||||
AgentAnswer,
|
||||
AgentAnswerStatus,
|
||||
FanoutTurn,
|
||||
} from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import type { AgentBrand, BackendId } from "@/agentMode/session/types";
|
||||
|
||||
/** The summary entry's reserved option value — never a valid `BackendId`. */
|
||||
export const FANOUT_SUMMARY_OPTION = "__summary__";
|
||||
|
||||
/** A selectable value: {@link FANOUT_SUMMARY_OPTION} or an agent's `BackendId`. */
|
||||
export type FanoutOptionValue = BackendId;
|
||||
|
||||
/**
|
||||
* Presentational state of one agent's slot, derived from its live status.
|
||||
* Decoupled from {@link AgentAnswerStatus} so the renderer switches on intent.
|
||||
*/
|
||||
export type FanoutAgentState = "streaming" | "answer" | "error" | "cancelled";
|
||||
|
||||
/** Map an agent answer's live status to its presentational state. */
|
||||
export function agentStateForStatus(status: AgentAnswerStatus): FanoutAgentState {
|
||||
switch (status) {
|
||||
case "running":
|
||||
return "streaming";
|
||||
case "error":
|
||||
return "error";
|
||||
case "cancelled":
|
||||
return "cancelled";
|
||||
case "done":
|
||||
return "answer";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presentational state of an empty summary slot. `writing`/`waiting` are the
|
||||
* genuine in-progress spinners; `cancelled`/`unavailable` are terminal and must
|
||||
* not animate forever.
|
||||
*/
|
||||
export type FanoutSummaryState = "writing" | "waiting" | "cancelled" | "unavailable";
|
||||
|
||||
/**
|
||||
* Classify an empty summary slot for rendering. `streaming` → writing; `pending`
|
||||
* with an agent running → waiting; `pending` all-terminal → cancelled before
|
||||
* summary; `done` empty → summary failed.
|
||||
*/
|
||||
export function summaryDisplayState(turn: FanoutTurn): FanoutSummaryState {
|
||||
if (turn.summary.status === "streaming") return "writing";
|
||||
if (turn.summary.status === "pending") {
|
||||
const anyRunning = Object.values(turn.answers).some((a) => a.status === "running");
|
||||
return anyRunning ? "waiting" : "cancelled";
|
||||
}
|
||||
return "unavailable";
|
||||
}
|
||||
|
||||
/**
|
||||
* One entry in the dropdown switcher. `label` + `Icon` render the row
|
||||
* (registry-driven). The summary entry carries no icon/state.
|
||||
*/
|
||||
export interface FanoutOption {
|
||||
value: FanoutOptionValue;
|
||||
label: string;
|
||||
/** Brand icon for an agent entry; `undefined` for the summary entry. */
|
||||
Icon?: AgentBrand["Icon"];
|
||||
/** Live state for an agent entry; `undefined` for the summary entry. */
|
||||
state?: FanoutAgentState;
|
||||
}
|
||||
|
||||
/** Resolve a `BackendId` to its registry brand (display name + icon); id fallback if unknown. */
|
||||
function brandFor(backendId: BackendId): { displayName: string; Icon?: AgentBrand["Icon"] } {
|
||||
const descriptor = backendRegistry[backendId];
|
||||
if (!descriptor) return { displayName: backendId };
|
||||
return { displayName: descriptor.displayName, Icon: descriptor.Icon };
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a `BackendId` to its display name. Shared by the clean-composite
|
||||
* renderer so copied/inserted headings match the rendered tab labels.
|
||||
*/
|
||||
export function fanoutDisplayName(backendId: BackendId): string {
|
||||
return brandFor(backendId).displayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the dropdown options: the summary first (the default view), then one
|
||||
* entry per agent in slot order (insertion order preserved).
|
||||
*/
|
||||
export function buildFanoutOptions(turn: FanoutTurn): FanoutOption[] {
|
||||
const options: FanoutOption[] = [{ value: FANOUT_SUMMARY_OPTION, label: "Summary" }];
|
||||
for (const backendId of Object.keys(turn.answers)) {
|
||||
const answer = turn.answers[backendId];
|
||||
const { displayName, Icon } = brandFor(backendId);
|
||||
options.push({
|
||||
value: backendId,
|
||||
label: displayName,
|
||||
Icon,
|
||||
state: agentStateForStatus(answer.status),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The default selected option: always the summary. A function for a single future seam. */
|
||||
export function defaultFanoutOption(_turn: FanoutTurn): FanoutOptionValue {
|
||||
return FANOUT_SUMMARY_OPTION;
|
||||
}
|
||||
|
||||
/**
|
||||
* The summary slot (`null`), or the answer slot for the selection. `null` too
|
||||
* when the value names an agent with no slot (defensive).
|
||||
*/
|
||||
export function selectedAnswer(turn: FanoutTurn, value: FanoutOptionValue): AgentAnswer | null {
|
||||
if (value === FANOUT_SUMMARY_OPTION) return null;
|
||||
return turn.answers[value] ?? null;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { PromptContent } from "@/agentMode/session/types";
|
||||
import type { BackendId, PromptContent } from "@/agentMode/session/types";
|
||||
import type { MessageContext } from "@/types/message";
|
||||
import { TFile } from "obsidian";
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
|
@ -12,6 +12,13 @@ export interface QueuedAgentMessage {
|
|||
context?: MessageContext;
|
||||
/** Image blocks for the backend prompt. */
|
||||
promptContent?: PromptContent[];
|
||||
/**
|
||||
* Resolved answerer selection (the deduped `@`-mentioned installed agents).
|
||||
* Present only when the turn fans out; absent for the single-agent path (no
|
||||
* qualifying mentions, or only the main agent `@`-ed). Snapshotted at enqueue
|
||||
* time alongside the rest.
|
||||
*/
|
||||
mentionedAgents?: ReadonlyArray<BackendId>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
src/agentMode/ui/mentionedAgents.test.ts
Normal file
90
src/agentMode/ui/mentionedAgents.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import {
|
||||
EMPTY_AGENT_BRANDS,
|
||||
EMPTY_ANSWERERS,
|
||||
isFanout,
|
||||
listInstalledAgentBrands,
|
||||
resolveAnswerers,
|
||||
} from "@/agentMode/ui/mentionedAgents";
|
||||
import type { BackendDescriptor, InstallState } from "@/agentMode/session/types";
|
||||
import type { CopilotSettings } from "@/settings/model";
|
||||
|
||||
const Icon = () => null;
|
||||
|
||||
jest.mock("@/agentMode/backends/registry", () => ({
|
||||
listBackendDescriptors: jest.fn(),
|
||||
}));
|
||||
|
||||
import { listBackendDescriptors } from "@/agentMode/backends/registry";
|
||||
|
||||
const mockedList = listBackendDescriptors as jest.MockedFunction<typeof listBackendDescriptors>;
|
||||
|
||||
function descriptor(id: string, install: InstallState): BackendDescriptor {
|
||||
return {
|
||||
id,
|
||||
displayName: id[0].toUpperCase() + id.slice(1),
|
||||
Icon,
|
||||
getInstallState: () => install,
|
||||
} as unknown as BackendDescriptor;
|
||||
}
|
||||
|
||||
const settings = {} as CopilotSettings;
|
||||
|
||||
describe("listInstalledAgentBrands", () => {
|
||||
it("offers only installed (ready) backends projected to brands; excludes absent/errored", () => {
|
||||
mockedList.mockReturnValue([
|
||||
descriptor("opencode", { kind: "ready", source: "managed" }),
|
||||
descriptor("claude", { kind: "absent" }),
|
||||
descriptor("codex", { kind: "error", message: "boom" }),
|
||||
]);
|
||||
|
||||
const brands = listInstalledAgentBrands(settings);
|
||||
expect(brands.map((b) => b.id)).toEqual(["opencode"]);
|
||||
expect(brands[0]).toMatchObject({ id: "opencode", displayName: "Opencode", Icon });
|
||||
});
|
||||
|
||||
it("returns the frozen empty constant when nothing is installed", () => {
|
||||
mockedList.mockReturnValue([descriptor("opencode", { kind: "absent" })]);
|
||||
expect(listInstalledAgentBrands(settings)).toBe(EMPTY_AGENT_BRANDS);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAnswerers", () => {
|
||||
const installed = new Set(["opencode", "claude", "codex"]);
|
||||
|
||||
it("returns the frozen empty constant when nothing is mentioned (main is NOT auto-included)", () => {
|
||||
expect(resolveAnswerers({ mentionedAgentIds: [], installedAgentIds: installed })).toBe(
|
||||
EMPTY_ANSWERERS
|
||||
);
|
||||
});
|
||||
|
||||
it("returns mentions in order (keeping an explicitly-mentioned main), dedup'd", () => {
|
||||
expect(
|
||||
resolveAnswerers({
|
||||
mentionedAgentIds: ["claude", "opencode", "claude"],
|
||||
installedAgentIds: installed,
|
||||
})
|
||||
).toEqual(["claude", "opencode"]);
|
||||
});
|
||||
|
||||
it("drops mentions of uninstalled agents", () => {
|
||||
expect(
|
||||
resolveAnswerers({
|
||||
mentionedAgentIds: ["claude", "ghost"],
|
||||
installedAgentIds: new Set(["opencode", "claude"]),
|
||||
})
|
||||
).toEqual(["claude"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isFanout", () => {
|
||||
// Claude is the session main agent in these cases.
|
||||
it("routes single-vs-fan-out: collapses to single-agent only when no non-main answerer exists", () => {
|
||||
// No answerers, or the only answerer IS the main agent → single-agent.
|
||||
expect(isFanout([], "claude")).toBe(false);
|
||||
expect(isFanout(["claude"], "claude")).toBe(false);
|
||||
// A non-main answerer (alone or with others) → fan-out, main summarizes.
|
||||
expect(isFanout(["opencode"], "claude")).toBe(true);
|
||||
expect(isFanout(["opencode", "codex"], "claude")).toBe(true);
|
||||
expect(isFanout(["claude", "opencode"], "claude")).toBe(true);
|
||||
});
|
||||
});
|
||||
21
src/agentMode/ui/mentionedAgents.ts
Normal file
21
src/agentMode/ui/mentionedAgents.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { listBackendDescriptors } from "@/agentMode/backends/registry";
|
||||
import type { AgentBrand } from "@/agentMode/session/types";
|
||||
import type { CopilotSettings } from "@/settings/model";
|
||||
|
||||
// Fan-out routing lives in session/fanout so the session layer can share it
|
||||
// without depending on the UI. Re-exported here for the composer.
|
||||
export { EMPTY_ANSWERERS, isFanout, resolveAnswerers } from "@/agentMode/session/fanout/answerers";
|
||||
|
||||
/** Frozen empty brand list — referential stability for the "no installed agents" case. */
|
||||
export const EMPTY_AGENT_BRANDS: ReadonlyArray<AgentBrand> = Object.freeze([]);
|
||||
|
||||
/**
|
||||
* Brand projections of every installed (`ready`) backend, mentionable in the
|
||||
* composer. Registry-driven, so a new backend becomes mentionable automatically.
|
||||
*/
|
||||
export function listInstalledAgentBrands(settings: CopilotSettings): ReadonlyArray<AgentBrand> {
|
||||
const brands = listBackendDescriptors()
|
||||
.filter((descriptor) => descriptor.getInstallState(settings).kind === "ready")
|
||||
.map(({ id, displayName, Icon }) => ({ id, displayName, Icon }) satisfies AgentBrand);
|
||||
return brands.length > 0 ? brands : EMPTY_AGENT_BRANDS;
|
||||
}
|
||||
58
src/agentMode/ui/permissionPrompter.test.ts
Normal file
58
src/agentMode/ui/permissionPrompter.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { AgentSession } from "@/agentMode/session/AgentSession";
|
||||
import {
|
||||
PERMISSION_OPTION_KINDS,
|
||||
type AgentToolKind,
|
||||
type PermissionPrompt,
|
||||
} from "@/agentMode/session/types";
|
||||
import { createDefaultPermissionPrompter } from "./permissionPrompter";
|
||||
|
||||
function promptFor(sessionId: string, kind: AgentToolKind): PermissionPrompt {
|
||||
return {
|
||||
sessionId,
|
||||
toolCall: { toolCallId: "t1", title: "tool", kind, status: "pending" },
|
||||
options: PERMISSION_OPTION_KINDS.map((k) => ({ optionId: k, name: k, kind: k })),
|
||||
};
|
||||
}
|
||||
|
||||
describe("createDefaultPermissionPrompter — read-only fan-out policy", () => {
|
||||
it("allows read/search/fetch tools for a read-only fan-out sub-session without a card", async () => {
|
||||
const handleToolPermission = jest.fn();
|
||||
const session = { handleToolPermission } as unknown as AgentSession;
|
||||
const prompter = createDefaultPermissionPrompter(
|
||||
() => session,
|
||||
(id) => id === "ro-session"
|
||||
);
|
||||
|
||||
for (const kind of ["read", "search", "fetch"] as AgentToolKind[]) {
|
||||
const decision = await prompter(promptFor("ro-session", kind));
|
||||
expect(decision.outcome).toEqual({ outcome: "selected", optionId: "allow_once" });
|
||||
}
|
||||
// Never routed to a visible session card.
|
||||
expect(handleToolPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("denies write/exec tools for a read-only fan-out sub-session", async () => {
|
||||
const prompter = createDefaultPermissionPrompter(
|
||||
() => null,
|
||||
() => true
|
||||
);
|
||||
// `other` is an unknown/MCP tool that can't be verified read-only, so it
|
||||
// is denied too (fail-safe), alongside the write/exec kinds.
|
||||
for (const kind of ["edit", "delete", "move", "execute", "other"] as AgentToolKind[]) {
|
||||
const decision = await prompter(promptFor("ro-session", kind));
|
||||
expect(decision.outcome).toEqual({ outcome: "selected", optionId: "reject_once" });
|
||||
expect(decision.denyMessage).toContain("Read-only");
|
||||
}
|
||||
});
|
||||
|
||||
it("routes a normal (non-fan-out) session to its inline permission card", async () => {
|
||||
const handleToolPermission = jest.fn().mockResolvedValue({ outcome: { outcome: "cancelled" } });
|
||||
const session = { handleToolPermission } as unknown as AgentSession;
|
||||
const prompter = createDefaultPermissionPrompter(
|
||||
() => session,
|
||||
() => false
|
||||
);
|
||||
await prompter(promptFor("normal", "edit"));
|
||||
expect(handleToolPermission).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -3,7 +3,35 @@ import type {
|
|||
AskUserQuestionPrompter,
|
||||
PermissionPrompter,
|
||||
} from "@/agentMode/session/AgentSessionManager";
|
||||
import type { SessionId } from "@/agentMode/session/types";
|
||||
import { isWriteOrExecToolKind } from "@/agentMode/session/fanout/fanoutTypes";
|
||||
import {
|
||||
PERMISSION_ALLOW_KINDS,
|
||||
PERMISSION_REJECT_KINDS,
|
||||
type PermissionDecision,
|
||||
type PermissionPrompt,
|
||||
type SessionId,
|
||||
} from "@/agentMode/session/types";
|
||||
|
||||
/**
|
||||
* Decide a `PermissionPrompt` for a read-only fan-out sub-session: allow
|
||||
* read/search/fetch, hard-deny write/exec. Auto-decided — these ephemeral
|
||||
* sub-sessions have no visible tab to surface a card on. The per-backend
|
||||
* enforcement layer behind the orchestrator's read-only guarantee.
|
||||
*/
|
||||
function decideReadOnly(req: PermissionPrompt): PermissionDecision {
|
||||
// `other` is an unknown/MCP tool we can't verify is read-only, so fail safe
|
||||
// and deny it here too (mirrors the Claude SDK bridge's unknown-MCP denial);
|
||||
// read/search/fetch/think/switch_mode still pass.
|
||||
const kind = req.toolCall.kind;
|
||||
const deny = isWriteOrExecToolKind(kind) || kind === "other";
|
||||
const kinds = deny ? PERMISSION_REJECT_KINDS : PERMISSION_ALLOW_KINDS;
|
||||
const opt = req.options.find((o) => kinds.includes(o.kind));
|
||||
if (!opt) return { outcome: { outcome: "cancelled" } };
|
||||
const decision: PermissionDecision = { outcome: { outcome: "selected", optionId: opt.optionId } };
|
||||
return deny
|
||||
? { ...decision, denyMessage: "Read-only QA turn: write and exec tools are disabled." }
|
||||
: decision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Permission prompts route into the owning session so the user sees an inline
|
||||
|
|
@ -11,11 +39,18 @@ import type { SessionId } from "@/agentMode/session/types";
|
|||
* `handlePlanProposalPermission` (which also publishes the plan body); every
|
||||
* other tool call flows through `handleToolPermission`. Returns `cancelled`
|
||||
* when no session owns the request — without that the SDK turn would hang.
|
||||
*
|
||||
* `isReadOnlySession`, when supplied, is consulted first: a fan-out sub-session
|
||||
* request is decided by {@link decideReadOnly} rather than routed to a tab.
|
||||
*/
|
||||
export function createDefaultPermissionPrompter(
|
||||
resolveSession: (backendSessionId: SessionId) => AgentSession | null
|
||||
resolveSession: (backendSessionId: SessionId) => AgentSession | null,
|
||||
isReadOnlySession?: (backendSessionId: SessionId) => boolean
|
||||
): PermissionPrompter {
|
||||
return (req) => {
|
||||
if (isReadOnlySession?.(req.sessionId)) {
|
||||
return Promise.resolve(decideReadOnly(req));
|
||||
}
|
||||
const session = resolveSession(req.sessionId);
|
||||
if (!session) return Promise.resolve({ outcome: { outcome: "cancelled" } });
|
||||
if (req.toolCall.isPlanProposal) {
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ import { $removeActiveWebTabPills } from "./pills/ActiveWebTabPillNode";
|
|||
import { $findWebTabPills, $removeWebTabPillsByUrl } from "./pills/WebTabPillNode";
|
||||
import LexicalEditor from "./LexicalEditor";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type AgentMentionBrand, EMPTY_AGENT_MENTION_BRANDS } from "./hooks/useAtMentionCategories";
|
||||
import { $createAgentPillNode } from "./pills/AgentPillNode";
|
||||
|
||||
const ACCENT_CIRCLE_BUTTON_CLASS =
|
||||
"tw-rounded-full tw-bg-interactive-accent tw-text-on-accent hover:tw-bg-interactive-accent-hover";
|
||||
|
|
@ -173,6 +175,19 @@ export interface ChatInputProps {
|
|||
* tool runner.
|
||||
*/
|
||||
isAgentMode?: boolean;
|
||||
|
||||
/**
|
||||
* Installed coding agents the user can `@`-mention this turn (Agent Mode
|
||||
* only). Non-empty enables the "Agents" typeahead group and agent pills.
|
||||
*/
|
||||
agentBrands?: ReadonlyArray<AgentMentionBrand>;
|
||||
|
||||
/**
|
||||
* Fires with the backend ids of the agent pills currently in the editor,
|
||||
* whenever that set changes. The Agent Mode wrapper resolves these into the
|
||||
* structured `mentionedAgents` selection at send time.
|
||||
*/
|
||||
onMentionedAgentsChange?: (backendIds: string[]) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -219,6 +234,8 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
|
|||
onEditCancel,
|
||||
initialContext,
|
||||
isAgentMode = false,
|
||||
agentBrands = EMPTY_AGENT_MENTION_BRANDS,
|
||||
onMentionedAgentsChange,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
|
|
@ -346,6 +363,15 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
|
|||
});
|
||||
};
|
||||
|
||||
// Forward the full current set of mentioned backend ids to the Agent Mode
|
||||
// wrapper, which resolves the structured selection at send time.
|
||||
const handleAgentsChange = useCallback(
|
||||
(backendIds: string[]) => {
|
||||
onMentionedAgentsChange?.(backendIds);
|
||||
},
|
||||
[onMentionedAgentsChange]
|
||||
);
|
||||
|
||||
// Handle when URLs are removed from pills (when pills are deleted in editor)
|
||||
const handleURLPillsRemoved = (removedUrls: string[]) => {
|
||||
const removedUrlSet = new Set(removedUrls);
|
||||
|
|
@ -441,6 +467,19 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
|
|||
// Note: toolsFromPills will be updated automatically via ToolPillSyncPlugin
|
||||
}
|
||||
break;
|
||||
case "agents":
|
||||
// Insert an agent pill at the cursor; agentsFromPills syncs via
|
||||
// AgentPillSyncPlugin. `data` is the backend id.
|
||||
if (typeof data === "string" && lexicalEditorRef.current) {
|
||||
const label = agentBrands.find((b) => b.id === data)?.displayName ?? data;
|
||||
lexicalEditorRef.current.update(() => {
|
||||
const selection = $getSelection();
|
||||
if ($isRangeSelection(selection)) {
|
||||
selection.insertNodes([$createAgentPillNode(data, label)]);
|
||||
}
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "folders":
|
||||
// For folders from context menu, update contextFolders directly (no pills in editor)
|
||||
if (data && typeof (data as { path?: unknown }).path === "string") {
|
||||
|
|
@ -806,6 +845,8 @@ const ChatInput = React.forwardRef<ChatInputHandle, ChatInputProps>(function Cha
|
|||
onWebTabsChange={setWebTabsFromPills}
|
||||
onActiveWebTabAdded={handleActiveWebTabAdded}
|
||||
onActiveWebTabRemoved={handleActiveWebTabRemoved}
|
||||
agentBrands={agentBrands}
|
||||
onAgentsChange={handleAgentsChange}
|
||||
onEditorReady={onEditorReady}
|
||||
onImagePaste={onAddImage}
|
||||
onTagSelected={onTagSelected}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { FolderPillNode } from "./pills/FolderPillNode";
|
|||
import { ActiveNotePillNode } from "./pills/ActiveNotePillNode";
|
||||
import { WebTabPillNode } from "./pills/WebTabPillNode";
|
||||
import { ActiveWebTabPillNode } from "./pills/ActiveWebTabPillNode";
|
||||
import { AgentPillNode } from "./pills/AgentPillNode";
|
||||
import { PillDeletionPlugin } from "./plugins/PillDeletionPlugin";
|
||||
import { KeyboardPlugin } from "./plugins/KeyboardPlugin";
|
||||
import { ValueSyncPlugin } from "./plugins/ValueSyncPlugin";
|
||||
|
|
@ -29,6 +30,7 @@ import { ToolPillSyncPlugin } from "./plugins/ToolPillSyncPlugin";
|
|||
import { FolderPillSyncPlugin } from "./plugins/FolderPillSyncPlugin";
|
||||
import { ActiveNotePillSyncPlugin } from "./plugins/ActiveNotePillSyncPlugin";
|
||||
import { WebTabPillSyncPlugin } from "./plugins/WebTabPillSyncPlugin";
|
||||
import { AgentPillSyncPlugin } from "./plugins/AgentPillSyncPlugin";
|
||||
import { PastePlugin } from "./plugins/PastePlugin";
|
||||
import { TextInsertionPlugin } from "./plugins/TextInsertionPlugin";
|
||||
import { useChatInput } from "@/context/ChatInputContext";
|
||||
|
|
@ -37,6 +39,7 @@ import { logError } from "@/logger";
|
|||
import { ActiveFileProvider } from "./context/ActiveFileContext";
|
||||
import { ChainType } from "@/chainType";
|
||||
import { useSettingsValue } from "@/settings/model";
|
||||
import { type AgentMentionBrand, EMPTY_AGENT_MENTION_BRANDS } from "./hooks/useAtMentionCategories";
|
||||
|
||||
interface LexicalEditorProps {
|
||||
value: string;
|
||||
|
|
@ -59,6 +62,9 @@ interface LexicalEditorProps {
|
|||
onWebTabsRemoved?: (removedWebTabs: WebTabContext[]) => void;
|
||||
onActiveWebTabAdded?: () => void;
|
||||
onActiveWebTabRemoved?: () => void;
|
||||
onAgentsChange?: (backendIds: string[]) => void;
|
||||
/** Installed coding agents mentionable in the composer (Agent Mode only). */
|
||||
agentBrands?: ReadonlyArray<AgentMentionBrand>;
|
||||
onEditorReady?: (editor: LexicalEditorType) => void;
|
||||
onImagePaste?: (files: File[]) => void;
|
||||
onTagSelected?: () => void;
|
||||
|
|
@ -92,6 +98,8 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
|
|||
onWebTabsRemoved,
|
||||
onActiveWebTabAdded,
|
||||
onActiveWebTabRemoved,
|
||||
onAgentsChange,
|
||||
agentBrands = EMPTY_AGENT_MENTION_BRANDS,
|
||||
onEditorReady,
|
||||
onImagePaste,
|
||||
onTagSelected,
|
||||
|
|
@ -139,6 +147,7 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
|
|||
FolderPillNode,
|
||||
WebTabPillNode,
|
||||
ActiveWebTabPillNode,
|
||||
AgentPillNode,
|
||||
...(onURLsChange ? [URLPillNode] : []),
|
||||
],
|
||||
onError: (error: Error) => {
|
||||
|
|
@ -215,6 +224,7 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
|
|||
onActiveWebTabAdded={onActiveWebTabAdded}
|
||||
onActiveWebTabRemoved={onActiveWebTabRemoved}
|
||||
/>
|
||||
<AgentPillSyncPlugin onAgentsChange={onAgentsChange} />
|
||||
<PillDeletionPlugin />
|
||||
<PastePlugin enableURLPills={!!onURLsChange} onImagePaste={onImagePaste} />
|
||||
<SlashCommandPlugin />
|
||||
|
|
@ -226,6 +236,7 @@ const LexicalEditor: React.FC<LexicalEditorProps> = ({
|
|||
isCopilotPlus={isCopilotPlus}
|
||||
showTools={showTools}
|
||||
currentActiveFile={currentActiveFile}
|
||||
agentBrands={agentBrands}
|
||||
/>
|
||||
<TextInsertionPlugin />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import React, { useMemo } from "react";
|
||||
import { TFile, TFolder } from "obsidian";
|
||||
import { isDesktopRuntime } from "@/utils/desktopRuntime";
|
||||
import { FileText, Wrench, Folder, Globe, Image } from "lucide-react";
|
||||
import { FileText, Wrench, Folder, Globe, Image, Bot } from "lucide-react";
|
||||
import { TypeaheadOption } from "@/components/chat-components/TypeaheadMenuContent";
|
||||
import type { WebTabContext } from "@/types/message";
|
||||
|
||||
export type AtMentionCategory =
|
||||
| "agents"
|
||||
| "notes"
|
||||
| "tools"
|
||||
| "folders"
|
||||
|
|
@ -20,12 +21,35 @@ export interface AtMentionOption extends TypeaheadOption {
|
|||
isAction?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Minimal brand shape for a mentionable coding agent. Local to chat-components so
|
||||
* the generic composer never imports Agent Mode internals; Agent Mode passes its
|
||||
* structurally-compatible `AgentBrand` down as props.
|
||||
*/
|
||||
export interface AgentMentionBrand {
|
||||
readonly id: string;
|
||||
readonly displayName: string;
|
||||
readonly Icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
/** Frozen empty brand list — referential stability for the no-agents default. */
|
||||
export const EMPTY_AGENT_MENTION_BRANDS: ReadonlyArray<AgentMentionBrand> = Object.freeze([]);
|
||||
|
||||
export interface CategoryOption extends TypeaheadOption {
|
||||
category: AtMentionCategory;
|
||||
icon: React.ReactNode;
|
||||
isAction?: boolean;
|
||||
}
|
||||
|
||||
/** "Agents" typeahead group — surfaced only in Agent Mode with a backend installed, rendered first. */
|
||||
const AGENTS_CATEGORY: CategoryOption = {
|
||||
key: "agents",
|
||||
title: "Agents",
|
||||
subtitle: "Ask another coding agent this turn",
|
||||
category: "agents",
|
||||
icon: <Bot className="tw-size-4" />,
|
||||
};
|
||||
|
||||
const CATEGORY_OPTIONS: CategoryOption[] = [
|
||||
{
|
||||
key: "notes",
|
||||
|
|
@ -85,11 +109,16 @@ export function shouldShowAtMentionTools(args: {
|
|||
* @param showTools - Whether to include the Copilot Tools category. Compute
|
||||
* via {@link shouldShowAtMentionTools} from the caller's higher-level
|
||||
* signals (e.g. Copilot Plus on, Agent Mode off).
|
||||
* @param showAgents - Whether to include the Agents category (Agent Mode with
|
||||
* at least one installed backend). Rendered first when present.
|
||||
* @returns Array of CategoryOption objects
|
||||
*/
|
||||
export function useAtMentionCategories(showTools: boolean = false): CategoryOption[] {
|
||||
export function useAtMentionCategories(
|
||||
showTools: boolean = false,
|
||||
showAgents: boolean = false
|
||||
): CategoryOption[] {
|
||||
return useMemo(() => {
|
||||
return CATEGORY_OPTIONS.filter((cat) => {
|
||||
const base = CATEGORY_OPTIONS.filter((cat) => {
|
||||
if (cat.category === "tools") {
|
||||
return showTools;
|
||||
}
|
||||
|
|
@ -98,5 +127,6 @@ export function useAtMentionCategories(showTools: boolean = false): CategoryOpti
|
|||
}
|
||||
return true;
|
||||
});
|
||||
}, [showTools]);
|
||||
return showAgents ? [AGENTS_CATEGORY, ...base] : base;
|
||||
}, [showTools, showAgents]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,13 @@ import { useAllNotes } from "./useAllNotes";
|
|||
import { useAllFolders } from "./useAllFolders";
|
||||
import { useOpenWebTabs } from "./useOpenWebTabs";
|
||||
import { useActiveWebTabState } from "./useActiveWebTabState";
|
||||
import { AtMentionCategory, AtMentionOption, CategoryOption } from "./useAtMentionCategories";
|
||||
import {
|
||||
AgentMentionBrand,
|
||||
AtMentionCategory,
|
||||
AtMentionOption,
|
||||
CategoryOption,
|
||||
EMPTY_AGENT_MENTION_BRANDS,
|
||||
} from "./useAtMentionCategories";
|
||||
import { getSettings } from "@/settings/model";
|
||||
|
||||
// Maximum number of results to show in @ mention search
|
||||
|
|
@ -25,7 +31,8 @@ export function useAtMentionSearch(
|
|||
isCopilotPlus: boolean,
|
||||
showTools: boolean,
|
||||
availableCategoryOptions: CategoryOption[],
|
||||
currentActiveFile: TFile | null = null
|
||||
currentActiveFile: TFile | null = null,
|
||||
agentBrands: ReadonlyArray<AgentMentionBrand> = EMPTY_AGENT_MENTION_BRANDS
|
||||
): (CategoryOption | AtMentionOption)[] {
|
||||
// Get raw data without pre-filtering
|
||||
const allNotes = useAllNotes(isCopilotPlus);
|
||||
|
|
@ -114,6 +121,21 @@ export function useAtMentionSearch(
|
|||
[openWebTabs]
|
||||
);
|
||||
|
||||
const agentItems: AtMentionOption[] = useMemo(
|
||||
() =>
|
||||
agentBrands.map((brand) => ({
|
||||
key: `agent-${brand.id}`,
|
||||
title: brand.displayName,
|
||||
subtitle: undefined,
|
||||
category: "agents",
|
||||
data: brand.id,
|
||||
content: undefined,
|
||||
icon: React.createElement(brand.Icon, { className: "tw-size-4" }),
|
||||
searchKeyword: `${brand.displayName} ${brand.id}`,
|
||||
})),
|
||||
[agentBrands]
|
||||
);
|
||||
|
||||
return useMemo(() => {
|
||||
if (mode === "category") {
|
||||
// Show category options when no query
|
||||
|
|
@ -161,6 +183,13 @@ export function useAtMentionSearch(
|
|||
return tool.title.toLowerCase().includes(queryLower);
|
||||
});
|
||||
|
||||
// Agents rank first: match on display name or backend id (case-insensitive).
|
||||
const matchingAgents = agentItems.filter(
|
||||
(agent) =>
|
||||
agent.title.toLowerCase().includes(queryLower) ||
|
||||
(typeof agent.data === "string" && agent.data.toLowerCase().includes(queryLower))
|
||||
);
|
||||
|
||||
// Check if "active note" contains the query as a substring (case-insensitive)
|
||||
const activeNoteTitle = "active note";
|
||||
const activeNoteMatches = activeNoteTitle.includes(queryLower);
|
||||
|
|
@ -203,8 +232,10 @@ export function useAtMentionSearch(
|
|||
|
||||
const rankedNonToolItems = fuzzySearchResults.map((result) => result.obj);
|
||||
|
||||
// Tools first, then Active Web Tab / Active Note (if matches), then everything else
|
||||
// Agents first, then Tools, then Active Web Tab / Active Note (if matches),
|
||||
// then everything else
|
||||
return [
|
||||
...matchingAgents,
|
||||
...matchingTools,
|
||||
...(activeWebTabOption ? [activeWebTabOption] : []),
|
||||
...(activeNoteOption ? [activeNoteOption] : []),
|
||||
|
|
@ -215,6 +246,9 @@ export function useAtMentionSearch(
|
|||
let items: AtMentionOption[] = [];
|
||||
|
||||
switch (selectedCategory) {
|
||||
case "agents":
|
||||
items = agentItems;
|
||||
break;
|
||||
case "notes":
|
||||
items = noteItems;
|
||||
break;
|
||||
|
|
@ -271,6 +305,7 @@ export function useAtMentionSearch(
|
|||
toolItems,
|
||||
folderItems,
|
||||
webTabItems,
|
||||
agentItems,
|
||||
availableCategoryOptions,
|
||||
activeWebTab,
|
||||
currentActiveFile,
|
||||
|
|
|
|||
50
src/components/chat-components/pills/AgentPillNode.test.ts
Normal file
50
src/components/chat-components/pills/AgentPillNode.test.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
$createParagraphNode,
|
||||
$createTextNode,
|
||||
$getRoot,
|
||||
createEditor,
|
||||
type LexicalEditor,
|
||||
} from "lexical";
|
||||
import { $createAgentPillNode, AgentPillNode } from "./AgentPillNode";
|
||||
|
||||
function makeEditor(): LexicalEditor {
|
||||
return createEditor({
|
||||
namespace: "agent-pill-test",
|
||||
nodes: [AgentPillNode],
|
||||
onError: (e) => {
|
||||
throw e;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("AgentPillNode", () => {
|
||||
it("contributes empty text content so the backend id never reaches the prompt", () => {
|
||||
const editor = makeEditor();
|
||||
editor.update(
|
||||
() => {
|
||||
const node = $createAgentPillNode("claude", "Claude");
|
||||
expect(node.getTextContent()).toBe("");
|
||||
// The id is still available structurally for routing.
|
||||
expect(node.getBackendId()).toBe("claude");
|
||||
},
|
||||
{ discrete: true }
|
||||
);
|
||||
});
|
||||
|
||||
it("serializes a prompt with an agent pill + text without leaking the backend id", () => {
|
||||
const editor = makeEditor();
|
||||
editor.update(
|
||||
() => {
|
||||
const paragraph = $createParagraphNode();
|
||||
paragraph.append($createAgentPillNode("claude", "Claude"));
|
||||
paragraph.append($createTextNode(" should we use X?"));
|
||||
$getRoot().clear().append(paragraph);
|
||||
},
|
||||
{ discrete: true }
|
||||
);
|
||||
|
||||
const promptText = editor.getEditorState().read(() => $getRoot().getTextContent());
|
||||
expect(promptText).toBe(" should we use X?");
|
||||
expect(promptText).not.toContain("claude");
|
||||
});
|
||||
});
|
||||
125
src/components/chat-components/pills/AgentPillNode.tsx
Normal file
125
src/components/chat-components/pills/AgentPillNode.tsx
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
DOMConversionMap,
|
||||
DOMConversionOutput,
|
||||
DOMExportOutput,
|
||||
LexicalEditor,
|
||||
LexicalNode,
|
||||
NodeKey,
|
||||
} from "lexical";
|
||||
import { Bot } from "lucide-react";
|
||||
import React from "react";
|
||||
import { BasePillNode, SerializedBasePillNode } from "./BasePillNode";
|
||||
import { PillBadge } from "./PillBadge";
|
||||
|
||||
export interface SerializedAgentPillNode extends SerializedBasePillNode {
|
||||
type: "agent-pill";
|
||||
/** Display label captured at insert time, so render needs no registry. */
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent pill node: a coding agent `@`-mentioned in the composer. Value is the
|
||||
* backend id, `label` the display name captured at insert time. Registry-agnostic
|
||||
* so the generic chat editor never depends on Agent Mode internals.
|
||||
*/
|
||||
export class AgentPillNode extends BasePillNode {
|
||||
__label: string;
|
||||
|
||||
static getType(): string {
|
||||
return "agent-pill";
|
||||
}
|
||||
|
||||
static clone(node: AgentPillNode): AgentPillNode {
|
||||
return new AgentPillNode(node.__value, node.__label, node.__key);
|
||||
}
|
||||
|
||||
constructor(backendId: string, label: string, key?: NodeKey) {
|
||||
super(backendId, key);
|
||||
this.__label = label;
|
||||
}
|
||||
|
||||
getClassName(): string {
|
||||
return "agent-pill-wrapper";
|
||||
}
|
||||
|
||||
getDataAttribute(): string {
|
||||
return "data-lexical-agent-pill";
|
||||
}
|
||||
|
||||
static importDOM(): DOMConversionMap | null {
|
||||
return {
|
||||
span: (node: HTMLElement) => {
|
||||
if (node.hasAttribute("data-lexical-agent-pill")) {
|
||||
return {
|
||||
conversion: convertAgentPillElement,
|
||||
priority: 1,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedAgentPillNode): AgentPillNode {
|
||||
return $createAgentPillNode(serializedNode.value, serializedNode.label);
|
||||
}
|
||||
|
||||
exportJSON(): SerializedAgentPillNode {
|
||||
return {
|
||||
...super.exportJSON(),
|
||||
type: "agent-pill",
|
||||
label: this.__label,
|
||||
};
|
||||
}
|
||||
|
||||
/** The mentioned backend id. */
|
||||
getBackendId(): string {
|
||||
return this.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* Contribute nothing to the serialized text. The backend id is pure routing
|
||||
* metadata (the mention feeds `mentionedAgents` structurally via the sync
|
||||
* plugin); emitting it here would leak the raw id into the prompt. The visible
|
||||
* pill comes from `decorate()`.
|
||||
*/
|
||||
getTextContent(): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
exportDOM(editor: LexicalEditor): DOMExportOutput {
|
||||
// Base writes data-attribute/value/textContent; layer on the label so it
|
||||
// round-trips through DOM import.
|
||||
const out = super.exportDOM(editor);
|
||||
if (out.element instanceof HTMLElement) {
|
||||
out.element.setAttribute("data-pill-label", this.__label);
|
||||
out.element.textContent = this.__label || this.__value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
decorate(): JSX.Element {
|
||||
return (
|
||||
<PillBadge>
|
||||
<Bot className="tw-size-3" />
|
||||
{this.__label || this.__value}
|
||||
</PillBadge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function convertAgentPillElement(domNode: HTMLElement): DOMConversionOutput | null {
|
||||
const value = domNode.getAttribute("data-pill-value");
|
||||
if (value !== null) {
|
||||
return { node: $createAgentPillNode(value, domNode.getAttribute("data-pill-label") ?? value) };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function $createAgentPillNode(backendId: string, label: string): AgentPillNode {
|
||||
return new AgentPillNode(backendId, label);
|
||||
}
|
||||
|
||||
export function $isAgentPillNode(node: LexicalNode): node is AgentPillNode {
|
||||
return node instanceof AgentPillNode;
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import React from "react";
|
||||
import { $isAgentPillNode, AgentPillNode } from "@/components/chat-components/pills/AgentPillNode";
|
||||
import { GenericPillSyncPlugin, PillSyncConfig } from "./GenericPillSyncPlugin";
|
||||
import type { LexicalNode } from "lexical";
|
||||
|
||||
interface AgentPillSyncPluginProps {
|
||||
/** Backend ids of the agent pills currently in the editor. */
|
||||
onAgentsChange?: (backendIds: string[]) => void;
|
||||
}
|
||||
|
||||
const agentPillConfig: PillSyncConfig<string> = {
|
||||
isPillNode: $isAgentPillNode,
|
||||
extractData: (node: LexicalNode) => (node as AgentPillNode).getBackendId(),
|
||||
};
|
||||
|
||||
/**
|
||||
* Syncs the set of `@`-mentioned agent pills out to the composer so it can
|
||||
* resolve the structured `mentionedAgents` selection at send time.
|
||||
*/
|
||||
export function AgentPillSyncPlugin({ onAgentsChange }: AgentPillSyncPluginProps) {
|
||||
return <GenericPillSyncPlugin config={agentPillConfig} onChange={onAgentsChange} />;
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@ import {
|
|||
} from "@/components/chat-components/utils/lexicalTextUtils";
|
||||
import {
|
||||
useAtMentionCategories,
|
||||
AgentMentionBrand,
|
||||
AtMentionCategory,
|
||||
AtMentionOption,
|
||||
CategoryOption,
|
||||
EMPTY_AGENT_MENTION_BRANDS,
|
||||
} from "@/components/chat-components/hooks/useAtMentionCategories";
|
||||
import { useAtMentionSearch } from "@/components/chat-components/hooks/useAtMentionSearch";
|
||||
|
||||
|
|
@ -21,12 +23,18 @@ interface AtMentionCommandPluginProps {
|
|||
/** Whether to surface Copilot built-in `@` tools (category + search hits). */
|
||||
showTools?: boolean;
|
||||
currentActiveFile?: TFile | null;
|
||||
/**
|
||||
* Installed coding agents mentionable in this composer. Non-empty only in
|
||||
* Agent Mode; surfaces the "Agents" typeahead group and inserts agent pills.
|
||||
*/
|
||||
agentBrands?: ReadonlyArray<AgentMentionBrand>;
|
||||
}
|
||||
|
||||
export function AtMentionCommandPlugin({
|
||||
isCopilotPlus = false,
|
||||
showTools = false,
|
||||
currentActiveFile = null,
|
||||
agentBrands = EMPTY_AGENT_MENTION_BRANDS,
|
||||
}: AtMentionCommandPluginProps): JSX.Element {
|
||||
const app = useApp();
|
||||
const [editor] = useLexicalComposerContext();
|
||||
|
|
@ -43,7 +51,7 @@ export function AtMentionCommandPlugin({
|
|||
// Use the shared at-mention categories hook. Action categories (e.g. Images,
|
||||
// which opens a file picker) are not supported inline because the editor has
|
||||
// no pill representation for them — they only appear in the `+` popover.
|
||||
const allCategoryOptions = useAtMentionCategories(showTools);
|
||||
const allCategoryOptions = useAtMentionCategories(showTools, agentBrands.length > 0);
|
||||
const availableCategoryOptions = useMemo(
|
||||
() => allCategoryOptions.filter((c) => !c.isAction),
|
||||
[allCategoryOptions]
|
||||
|
|
@ -91,7 +99,8 @@ export function AtMentionCommandPlugin({
|
|||
isCopilotPlus,
|
||||
showTools,
|
||||
availableCategoryOptions,
|
||||
currentActiveFile
|
||||
currentActiveFile,
|
||||
agentBrands
|
||||
);
|
||||
|
||||
// Type guard functions
|
||||
|
|
|
|||
|
|
@ -18,10 +18,18 @@ import { $createToolPillNode } from "@/components/chat-components/pills/ToolPill
|
|||
import { $createFolderPillNode } from "@/components/chat-components/pills/FolderPillNode";
|
||||
import { $createWebTabPillNode } from "@/components/chat-components/pills/WebTabPillNode";
|
||||
import { $createActiveWebTabPillNode } from "@/components/chat-components/pills/ActiveWebTabPillNode";
|
||||
import { $createAgentPillNode } from "@/components/chat-components/pills/AgentPillNode";
|
||||
import { logInfo } from "@/logger";
|
||||
import { AVAILABLE_TOOLS } from "@/components/chat-components/constants/tools";
|
||||
|
||||
export type PillType = "notes" | "tools" | "folders" | "active-note" | "webTabs" | "activeWebTab";
|
||||
export type PillType =
|
||||
| "notes"
|
||||
| "tools"
|
||||
| "folders"
|
||||
| "active-note"
|
||||
| "webTabs"
|
||||
| "activeWebTab"
|
||||
| "agents";
|
||||
|
||||
// Type representing different kinds of parsed content segments
|
||||
export type ParsedContentType =
|
||||
|
|
@ -77,6 +85,11 @@ function $createPillNode(pillData: PillData) {
|
|||
break;
|
||||
case "activeWebTab":
|
||||
return $createActiveWebTabPillNode();
|
||||
case "agents":
|
||||
if (typeof data === "string") {
|
||||
return $createAgentPillNode(data, title ?? data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid pill data: ${JSON.stringify(pillData)}`);
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export const PLUS_UTM_MEDIUMS = {
|
|||
EXPIRED_MODAL: "expired_modal",
|
||||
CHAT_MODE_SELECT: "chat_mode_select",
|
||||
MODE_SELECT_TOOLTIP: "mode_select_tooltip",
|
||||
MULTI_AGENT: "multi_agent",
|
||||
};
|
||||
export type PlusUtmMedium = (typeof PLUS_UTM_MEDIUMS)[keyof typeof PLUS_UTM_MEDIUMS];
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ jest.mock("@/settings/model", () => ({
|
|||
getSettings: () => mockGetSettings(),
|
||||
}));
|
||||
|
||||
import { isSelfHostAccessValid, isSelfHostModeValid } from "@/plusUtils";
|
||||
import { canUseMultiAgent, isSelfHostAccessValid, isSelfHostModeValid } from "@/plusUtils";
|
||||
|
||||
const SELF_HOST_GRACE_PERIOD_MS = 15 * 24 * 60 * 60 * 1000;
|
||||
|
||||
|
|
@ -51,6 +51,25 @@ describe("isSelfHostAccessValid", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("canUseMultiAgent", () => {
|
||||
it("returns false for a free user (no Plus, no self-host)", () => {
|
||||
mockGetSettings.mockReturnValue(
|
||||
buildSettings({ isPlusUser: false, enableSelfHostMode: false })
|
||||
);
|
||||
expect(canUseMultiAgent()).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for a Plus user", () => {
|
||||
mockGetSettings.mockReturnValue(buildSettings({ isPlusUser: true, enableSelfHostMode: false }));
|
||||
expect(canUseMultiAgent()).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true when self-host mode is on (believer/supporter offline path)", () => {
|
||||
mockGetSettings.mockReturnValue(buildSettings({ isPlusUser: false, enableSelfHostMode: true }));
|
||||
expect(canUseMultiAgent()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSelfHostModeValid", () => {
|
||||
it("returns false when the toggle is off, regardless of any receipt", () => {
|
||||
mockGetSettings.mockReturnValue(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
ChatModels,
|
||||
EmbeddingModelProviders,
|
||||
EmbeddingModels,
|
||||
PLUS_UTM_MEDIUMS,
|
||||
PlusUtmMedium,
|
||||
} from "@/constants";
|
||||
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
|
||||
|
|
@ -131,6 +132,61 @@ export function useIsPlusUser(): boolean | undefined {
|
|||
return settings.isPlusUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronous entitlement check for the multi-agent fan-out feature. Reuses the
|
||||
* Plus entitlement (any paid plan) rather than a parallel notion of "paid".
|
||||
*/
|
||||
export function canUseMultiAgent(): boolean {
|
||||
return isPlusEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative send-boundary entitlement check for the fan-out feature — the
|
||||
* single source of truth the non-React session calls before dispatching, so a UI
|
||||
* bypass can't evade the paywall.
|
||||
*
|
||||
* Fast path: a paying user (cached `isPlusEnabled()`) is allowed with no network
|
||||
* call. Slow path: re-verify against `/license` so a stale-false cache still gets
|
||||
* through; anything not confirmed valid is a HARD block (no single-agent fallback).
|
||||
*/
|
||||
export async function ensureMultiAgentEntitlement(
|
||||
app?: App,
|
||||
context?: Record<string, unknown>
|
||||
): Promise<boolean> {
|
||||
if (isPlusEnabled()) {
|
||||
return true;
|
||||
}
|
||||
// Re-verify so a stale-false cache for a real paid user still gets through;
|
||||
// `validateLicenseKey` flips the cached flag itself.
|
||||
const result = await BrevilabsClient.getInstance().validateLicenseKey(app, {
|
||||
feature: "multi_agent_per_turn",
|
||||
...context,
|
||||
});
|
||||
return result.isValid === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface the multi-agent-is-Plus upgrade prompt, reusing the shared Plus CTA
|
||||
* (`navigateToPlusPage`) so callers and tests share one copy + action.
|
||||
*/
|
||||
export function showMultiAgentUpgradePrompt(): void {
|
||||
new Notice(
|
||||
"Multi-agent QA (@-mentioning more than one agent in a turn) is a Copilot Plus feature. Opening the upgrade page…",
|
||||
8000
|
||||
);
|
||||
navigateToPlusPage(PLUS_UTM_MEDIUMS.MULTI_AGENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive form of {@link canUseMultiAgent} for React render gates; subscribes to
|
||||
* settings so the gate flips live. Mirrors `useIsPlusUser` (not `isPlusEnabled`),
|
||||
* so it can differ from the sync form for a self-host user with the toggle on but
|
||||
* no license receipt yet. `undefined` (still resolving) reads as not entitled.
|
||||
*/
|
||||
export function useCanUseMultiAgent(): boolean {
|
||||
return useIsPlusUser() === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user is a Plus user.
|
||||
* When self-host mode is valid, this returns true to allow offline usage.
|
||||
|
|
|
|||
Loading…
Reference in a new issue