logancyang_obsidian-copilot/src/agentMode/session/AgentChatPersistenceManager.test.ts
Logan Yang 2cf80bc411
feat(agent-mode): multi-agent per-turn QA (#57) (#2628)
* 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>
2026-06-21 20:40:30 -07:00

209 lines
7.8 KiB
TypeScript

/* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */
import { AI_SENDER, USER_SENDER } from "@/constants";
import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
import type { AgentChatMessage } from "./types";
import type { App, TFile } from "obsidian";
jest.mock("obsidian", () => ({
Notice: jest.fn(),
TFile: jest.fn(),
}));
jest.mock("@/logger");
jest.mock("@/settings/model", () => ({
getSettings: jest.fn().mockReturnValue({
defaultSaveFolder: "test-folder",
defaultConversationTag: "copilot-conversation",
defaultConversationNoteName: "{$date}_{$time}__{$topic}",
}),
}));
jest.mock("@/utils", () => ({
ensureFolderExists: jest.fn(async () => {}),
formatDateTime: jest.fn(() => ({
fileName: "20260101_120000",
display: "2026/01/01 12:00:00",
})),
getUtf8ByteLength: jest.fn((s: string) => new TextEncoder().encode(s).length),
truncateToByteLimit: jest.fn((s: string, n: number) => {
const bytes = new TextEncoder().encode(s);
if (bytes.length <= n) return s;
return new TextDecoder().decode(bytes.slice(0, n));
}),
}));
jest.mock("@/utils/vaultAdapterUtils", () => ({
isInVaultCache: jest.fn(() => false),
listMarkdownFiles: jest.fn().mockResolvedValue([]),
readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null),
}));
interface FakeFile {
path: string;
basename: string;
contents?: string;
}
/**
* Build a minimal in-memory `app` mock that records files written via
* `vault.create` / `vault.adapter.write` so a round-trip save/load test can
* read what the previous step wrote without wiring real disk I/O.
*/
function makeApp() {
const files = new Map<string, FakeFile>();
return {
files,
vault: {
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
create: jest.fn(async (path: string, content: string) => {
const basename = path.split("/").pop()!.replace(/\.md$/, "");
const file = { path, basename, contents: content };
files.set(path, file);
return file;
}),
modify: jest.fn(async (file: FakeFile, content: string) => {
file.contents = content;
}),
read: jest.fn(async (file: FakeFile) => file.contents ?? ""),
delete: jest.fn(async (file: FakeFile) => {
files.delete(file.path);
}),
adapter: {
exists: jest.fn(async (path: string) => files.has(path)),
read: jest.fn(async (path: string) => files.get(path)?.contents ?? ""),
write: jest.fn(async (path: string, content: string) => {
const existing = files.get(path);
if (existing) {
existing.contents = content;
} else {
const basename = path.split("/").pop()!.replace(/\.md$/, "");
files.set(path, { path, basename, contents: content });
}
}),
remove: jest.fn(async (path: string) => {
files.delete(path);
}),
},
},
metadataCache: {
getFileCache: jest.fn(() => undefined),
},
fileManager: {
processFrontMatter: jest.fn(),
},
};
}
function makeMessage(sender: string, message: string, epoch = 1735732800000): AgentChatMessage {
return {
id: `msg-${epoch}`,
sender,
message,
isVisible: true,
timestamp: { epoch, display: "2026/01/01 12:00:00", fileName: "20260101_120000" },
};
}
describe("AgentChatPersistenceManager", () => {
let app: ReturnType<typeof makeApp>;
let manager: AgentChatPersistenceManager;
beforeEach(() => {
app = makeApp();
manager = new AgentChatPersistenceManager(app as unknown as App);
});
it("round-trips messages, backendId, and label", async () => {
const messages = [makeMessage(USER_SENDER, "hello world"), makeMessage(AI_SENDER, "hi back")];
const saved = await manager.saveSession(messages, "claude", { label: "My chat" });
expect(saved).not.toBeNull();
const file = app.files.get(saved!.path)!;
const loaded = await manager.loadFile(file as unknown as TFile);
expect(loaded.backendId).toBe("claude");
expect(loaded.label).toBe("My chat");
expect(loaded.messages).toHaveLength(2);
expect(loaded.messages[0].sender).toBe(USER_SENDER);
expect(loaded.messages[0].message).toBe("hello world");
expect(loaded.messages[1].sender).toBe(AI_SENDER);
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")];
const saved = await manager.saveSession(messages, "opencode", { label: tricky });
expect(saved).not.toBeNull();
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
expect(loaded.label).toBe(tricky);
});
it("strips control characters from labels so they can't break frontmatter", async () => {
const messages = [makeMessage(USER_SENDER, "hi")];
const saved = await manager.saveSession(messages, "opencode", {
label: "first\nsecond\rthird",
});
const raw = app.files.get(saved!.path)!.contents!;
// The label line must remain a single key:value entry.
const labelLines = raw.split("\n").filter((l) => l.startsWith("agentLabel:"));
expect(labelLines).toHaveLength(1);
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
expect(loaded.label).toBe("first second third");
});
it("throws on missing backendId instead of silently defaulting", async () => {
const path = "test-folder/agent__broken.md";
await app.vault.adapter.write(
path,
["---", "epoch: 1735732800000", "mode: agent", "---", "", "**user**: hi"].join("\n")
);
await expect(
manager.loadFile({ path, basename: "agent__broken" } as unknown as TFile)
).rejects.toThrow(/Missing backendId/);
});
it("assigns deterministic ids that depend only on message timestamp", async () => {
const messages = [
makeMessage(USER_SENDER, "first", 1700000000000),
makeMessage(AI_SENDER, "second", 1700000000001),
];
const saved = await manager.saveSession(messages, "claude");
const file = app.files.get(saved!.path)!;
const loadedA = await manager.loadFile(file as unknown as TFile);
const loadedB = await manager.loadFile(file as unknown as TFile);
// The key contract: same file + same content → same ids across reloads.
expect(loadedA.messages.map((m) => m.id)).toEqual(loadedB.messages.map((m) => m.id));
expect(loadedA.messages[0].id.startsWith("loaded-0-")).toBe(true);
});
it("returns null when given zero messages instead of writing an empty file", async () => {
const result = await manager.saveSession([], "opencode");
expect(result).toBeNull();
expect(app.files.size).toBe(0);
});
});