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>
209 lines
7.8 KiB
TypeScript
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);
|
|
});
|
|
});
|