From 02b9ac31c9aa0318304e34d7dc9f87b80742447a Mon Sep 17 00:00:00 2001 From: Zero Liu Date: Tue, 19 May 2026 17:10:33 -0700 Subject: [PATCH] Add agent chat autosave resume support (#2482) --- src/agentMode/sdk/ClaudeSdkBackendProcess.ts | 47 +++++- .../session/AgentChatPersistenceManager.ts | 25 ++- src/agentMode/session/AgentSessionManager.ts | 158 ++++++++++++++++-- src/agentMode/ui/AgentChat.tsx | 25 ++- src/agentMode/ui/AgentChatControls.tsx | 31 +++- 5 files changed, 260 insertions(+), 26 deletions(-) diff --git a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts index 8b65d419..96ad6a4d 100644 --- a/src/agentMode/sdk/ClaudeSdkBackendProcess.ts +++ b/src/agentMode/sdk/ClaudeSdkBackendProcess.ts @@ -446,11 +446,54 @@ export class ClaudeSdkBackendProcess implements BackendProcess { throw new MethodUnsupportedError("session/list"); } - async resumeSession(_params: ResumeSessionInput): Promise { - throw new MethodUnsupportedError("session/resume"); + /** + * Rehydrate a previously-persisted SDK session. Registers a `SessionState` + * entry keyed by `params.sessionId` with `firstPromptStarted: true` so the + * next `prompt()` passes `resume: ` to the SDK, which loads the + * prior conversation from `~/.claude/projects/.../.jsonl`. + * + * No SDK roundtrip happens here — the SDK only reads the on-disk transcript + * lazily on the next `query()`. If the file is missing (Claude wiped state, + * different machine), the next prompt fails; we let that surface as a normal + * turn error rather than blocking the load. + */ + async resumeSession(params: ResumeSessionInput): Promise { + logSdkOutbound( + "resumeSession", + { cwd: params.cwd, mcpServers: params.mcpServers }, + params.sessionId + ); + const cwd = params.cwd ?? null; + const mcp: Record = {}; + for (const server of params.mcpServers ?? []) { + const cfg = mcpServerSpecToSdkConfig(server); + if (cfg) mcp[server.name] = cfg; + } + const catalog = await this.ensureModelCatalog(); + const defaultId = this.opts.getDefaultModelId?.(); + const seedModelId = resolveSeedModelId(catalog, defaultId); + + this.sessions.set(params.sessionId, { + cwd, + firstPromptStarted: true, + mcpServers: mcp, + model: seedModelId, + systemPromptAppend: this.opts.getSkillCreationDirective?.() ?? "", + }); + + const state = this.computeState(params.sessionId); + logSdkOutboundResult( + "resumeSession", + { currentModelId: seedModelId ?? null, hasEffort: state.model !== null }, + params.sessionId + ); + return { sessionId: params.sessionId, state }; } async loadSession(_params: LoadSessionInput): Promise { + // The Claude SDK has no equivalent of ACP's `session/load` (which replays + // a transcript provided by the caller). The loader falls back to + // `resumeSession`, which reads the SDK's own on-disk transcript. throw new MethodUnsupportedError("session/load"); } diff --git a/src/agentMode/session/AgentChatPersistenceManager.ts b/src/agentMode/session/AgentChatPersistenceManager.ts index 0ac20657..2ab636fe 100644 --- a/src/agentMode/session/AgentChatPersistenceManager.ts +++ b/src/agentMode/session/AgentChatPersistenceManager.ts @@ -34,12 +34,20 @@ export interface LoadedAgentChat { backendId: BackendId; topic?: string; label?: string; + /** + * Backend-side session id captured at save time. When present, the loader + * can ask the backend to resume/load that session so the agent regains + * conversation context on the next turn. Absent for chats saved before + * resume was wired up — those fall back to a fresh session. + */ + sessionId?: string; } interface ExistingMeta { topic?: string; label?: string; lastAccessedAt?: number; + sessionId?: string; } /** @@ -107,7 +115,12 @@ export class AgentChatPersistenceManager { async saveSession( messages: AgentChatMessage[], backendId: BackendId, - options?: { label?: string | null; modelKey?: string; existingPath?: string } + options?: { + label?: string | null; + modelKey?: string; + existingPath?: string; + sessionId?: string | null; + } ): Promise<{ path: string } | null> { if (messages.length === 0) return null; @@ -135,6 +148,7 @@ export class AgentChatPersistenceManager { label: options?.label ?? existingMeta.label, modelKey: options?.modelKey, lastAccessedAt: existingMeta.lastAccessedAt, + sessionId: options?.sessionId ?? existingMeta.sessionId, }); if (existingFile && isInVaultCache(this.app, existingFile.path)) { @@ -200,12 +214,13 @@ export class AgentChatPersistenceManager { } const topic = frontmatter.topic?.trim() || undefined; const label = frontmatter.agentLabel?.trim() || undefined; + const sessionId = frontmatter.sessionId?.trim() || undefined; const messages = this.parseChatBody(body); logInfo( - `[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId})` + `[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path} (backend=${backendId}, sessionId=${sessionId ?? "none"})` ); - return { messages, backendId, topic, label }; + return { messages, backendId, topic, label, sessionId }; } /** @@ -252,6 +267,7 @@ export class AgentChatPersistenceManager { label: cached.agentLabel, lastAccessedAt: typeof cached.lastAccessedAt === "number" ? cached.lastAccessedAt : undefined, + sessionId: typeof cached.sessionId === "string" ? cached.sessionId : undefined, }; } try { @@ -262,6 +278,7 @@ export class AgentChatPersistenceManager { topic: fm.topic, label: fm.agentLabel, lastAccessedAt: lastAccessed && Number.isFinite(lastAccessed) ? lastAccessed : undefined, + sessionId: typeof fm.sessionId === "string" ? fm.sessionId : undefined, }; } catch { return {}; @@ -431,6 +448,7 @@ export class AgentChatPersistenceManager { label?: string | null; modelKey?: string; lastAccessedAt?: number; + sessionId?: string | null; }): string { const settings = getSettings(); const lines: string[] = [ @@ -439,6 +457,7 @@ export class AgentChatPersistenceManager { `mode: ${AGENT_CHAT_MODE}`, `backendId: ${args.backendId}`, ]; + if (args.sessionId) lines.push(`sessionId: "${escapeYamlString(args.sessionId)}"`); if (args.topic) lines.push(`topic: "${escapeYamlString(args.topic)}"`); if (args.label) lines.push(`agentLabel: "${escapeYamlString(args.label)}"`); if (args.modelKey) lines.push(`modelKey: "${escapeYamlString(args.modelKey)}"`); diff --git a/src/agentMode/session/AgentSessionManager.ts b/src/agentMode/session/AgentSessionManager.ts index 3ebc2a75..56c00a39 100644 --- a/src/agentMode/session/AgentSessionManager.ts +++ b/src/agentMode/session/AgentSessionManager.ts @@ -10,6 +10,8 @@ import { v4 as uuidv4 } from "uuid"; import { AgentSession, ATTENTION_TRIGGER_STATUSES } from "./AgentSession"; import type { AgentChatPersistenceManager } from "./AgentChatPersistenceManager"; import type { AgentModelPreloader } from "./AgentModelPreloader"; +import { MethodUnsupportedError } from "./errors"; +import { resolveMcpServers } from "./mcpResolver"; import type { BackendDescriptor, BackendId, @@ -20,6 +22,7 @@ import type { ModelSelection, PermissionDecision, PermissionPrompt, + SessionId, } from "./types"; const AUTOSAVE_DEBOUNCE_MS = 500; @@ -671,10 +674,11 @@ export class AgentSessionManager { /** * Open a previously-saved Agent Mode chat. If a live session is already * bound to that file (because the user opened it earlier this run), focus - * its tab instead of spawning a duplicate. Otherwise, spin up a fresh - * session on the saved backend, seed its store with the persisted display - * messages, and pin the persisted-path map so subsequent turns update the - * same file. + * its tab instead of spawning a duplicate. Otherwise, if the saved file + * carries a backend `sessionId` and the backend supports resume, rehydrate + * the prior backend session so the agent retains conversation context on + * the next turn. Falls back to a fresh session (UI-only history) when no + * sessionId was saved or the backend can't resume. */ async loadSessionFromHistory(file: TFile): Promise { if (!this.opts.persistenceManager) { @@ -695,7 +699,15 @@ export class AgentSessionManager { } const loaded = await this.opts.persistenceManager.loadFile(file); - const session = await this.createSession(loaded.backendId); + + let session: AgentSession | null = null; + if (loaded.sessionId) { + session = await this.tryResumeSessionFromHistory(loaded.backendId, loaded.sessionId); + } + if (!session) { + session = await this.createSession(loaded.backendId); + } + session.store.loadMessages(loaded.messages); if (loaded.label) session.setLabel(loaded.label); this.getSessionState(session.internalId).path = file.path; @@ -703,6 +715,101 @@ export class AgentSessionManager { return session; } + /** + * Spin up an `AgentSession` bound to an existing backend session id. Prefers + * `loadSession` (ACP replays the transcript through the backend) over + * `resumeSession` (Claude SDK reads its own on-disk transcript). Returns + * `null` when the backend supports neither — the caller falls back to a + * fresh session. + */ + private async tryResumeSessionFromHistory( + backendId: BackendId, + sessionId: SessionId + ): Promise { + const adapter = this.app.vault.adapter; + if (!(adapter instanceof FileSystemAdapter)) return null; + const vaultBasePath = adapter.getBasePath(); + const descriptor = this.resolveDescriptor(backendId); + + this.pendingCreates++; + this.startingBackendId = backendId; + this.notify(); + + let backend: BackendProcess; + try { + backend = await this.ensureBackend(backendId, descriptor); + } catch (err) { + this.lastError = err2String(err); + this.finishPendingCreate(); + return null; + } + + if (this.disposed) { + this.finishPendingCreate(); + return null; + } + + const mcpServers = resolveMcpServers(backend, getSettings().agentMode?.mcpServers); + + let resumeResult: { sessionId: SessionId; state: BackendState } | null = null; + try { + resumeResult = await backend.loadSession({ sessionId, cwd: vaultBasePath, mcpServers }); + } catch (err) { + if (!(err instanceof MethodUnsupportedError)) { + logWarn(`[AgentMode] loadSession failed for ${sessionId}`, err); + this.finishPendingCreate(); + return null; + } + } + + if (!resumeResult) { + try { + resumeResult = await backend.resumeSession({ + sessionId, + cwd: vaultBasePath, + mcpServers, + }); + } catch (err) { + if (err instanceof MethodUnsupportedError) { + logInfo( + `[AgentMode] backend ${backendId} does not support session resume; falling back to fresh session` + ); + } else { + logWarn(`[AgentMode] resumeSession failed for ${sessionId}`, err); + } + this.finishPendingCreate(); + return null; + } + } + + if (this.disposed) { + this.finishPendingCreate(); + return null; + } + + const session = new AgentSession({ + backend, + backendSessionId: resumeResult.sessionId, + internalId: uuidv4(), + backendId, + initialState: resumeResult.state, + cwd: vaultBasePath, + getDescriptor: () => this.opts.resolveDescriptor(backendId), + }); + this.sessions.set(session.internalId, session); + this.chatUIStates.set(session.internalId, new AgentChatUIState(session)); + this.activeSessionId = session.internalId; + this.attachAutoSave(session); + this.attachModelCacheSync(session); + this.attachAttentionTracking(session); + this.lastError = null; + this.finishPendingCreate(); + logInfo( + `[AgentMode] resumed session (internal=${session.internalId} backend-id=${sessionId} backend=${backendId})` + ); + return session; + } + private attachAutoSave(session: AgentSession): void { const persistence = this.opts.persistenceManager; if (!persistence) return; @@ -717,6 +824,7 @@ export class AgentSessionManager { } private scheduleAutoSave(session: AgentSession): void { + if (!getSettings().autosaveChat) return; const state = this.getSessionState(session.internalId); if (state.timer) window.clearTimeout(state.timer); state.timer = window.setTimeout(() => { @@ -727,32 +835,58 @@ export class AgentSessionManager { }, AUTOSAVE_DEBOUNCE_MS); } - private async flushAutoSave(session: AgentSession): Promise { + /** + * Manual save entry point. Writes the active session via the same code path + * auto-save uses, but ignores `settings.autosaveChat`. Returns the on-disk + * path on success, or `null` when there was nothing to save (no active + * session, no messages, persistence not configured, or signature unchanged). + */ + async saveActiveSession(): Promise<{ path: string } | null> { + const session = this.getActiveSession(); + if (!session) return null; + // Drain any pending debounced write first so it doesn't race with us. + const state = this.sessionState.get(session.internalId); + if (state?.timer) { + window.clearTimeout(state.timer); + state.timer = undefined; + } + return this.flushAutoSave(session); + } + + private async flushAutoSave(session: AgentSession): Promise<{ path: string } | null> { const persistence = this.opts.persistenceManager; - if (!persistence) return; - if (!this.sessions.has(session.internalId)) return; + if (!persistence) return null; + if (!this.sessions.has(session.internalId)) return null; const messages = session.store.getDisplayMessages(); - if (messages.length === 0) return; + if (messages.length === 0) return null; const label = session.getLabel(); + const sessionId = session.getBackendSessionId(); // Skip the write when nothing user-visible has changed since the last // save. Streaming token updates and idempotent label notifications - // otherwise rewrite the entire file on every debounce tick. - const signature = `${label ?? ""}-${messages.length}-${ + // otherwise rewrite the entire file on every debounce tick. Include + // the backend session id in the signature so the first save after the + // session finishes starting (when sessionId flips from null → real) + // always writes through, even if the message list hasn't changed yet. + const signature = `${label ?? ""}-${sessionId ?? ""}-${messages.length}-${ messages[messages.length - 1]?.message ?? "" }`; const state = this.getSessionState(session.internalId); - if (state.signature === signature) return; + if (state.signature === signature) { + return state.path ? { path: state.path } : null; + } const result = await persistence.saveSession(messages, session.backendId, { label, existingPath: state.path, + sessionId, }); if (result) { state.path = result.path; state.signature = signature; } + return result; } /** diff --git a/src/agentMode/ui/AgentChat.tsx b/src/agentMode/ui/AgentChat.tsx index 348193df..e693360b 100644 --- a/src/agentMode/ui/AgentChat.tsx +++ b/src/agentMode/ui/AgentChat.tsx @@ -162,13 +162,29 @@ const AgentChatInternal: React.FC = ({ }, [backend]); /* eslint-enable @eslint-react/hooks-extra/no-direct-set-state-in-use-effect */ - // Register a no-op save handler so CopilotView.saveChat() doesn't break. - // Agent Mode persistence is not yet implemented. + // External callers (CopilotPlugin.autosaveCurrentChat → CopilotAgentView.saveChat) + // already gate on `settings.autosaveChat`, so this handler is the autosave-on + // path — silent on success. The manual Save button uses `handleSaveAsNote` + // below, which surfaces a Notice on completion. useEffect(() => { onSaveChat(async () => { - // Intentionally a no-op: agent chat persistence is out of scope for Phase 2. + await manager.saveActiveSession(); }); - }, [onSaveChat]); + }, [onSaveChat, manager]); + + const handleSaveAsNote = useCallback(async () => { + try { + const result = await manager.saveActiveSession(); + if (result) { + new Notice("Chat saved as note."); + } else { + new Notice("Nothing to save yet."); + } + } catch (error) { + logError("[AgentMode] manual save failed", error); + new Notice("Failed to save chat as note. Check console for details."); + } + }, [manager]); const handleAddImage = useCallback( (files: File[]) => setSelectedImages((prev) => [...prev, ...files]), @@ -453,6 +469,7 @@ const AgentChatInternal: React.FC = ({ )} @@ -16,6 +17,9 @@ interface AgentChatControlsProps { * button is hidden — clicking it would be a no-op since there's nothing to * clear. */ onNewChat?: () => void; + /** Manual save handler. Surfaced as a Download button when + * `settings.autosaveChat` is off, mirroring the regular chat. */ + onSaveAsNote?: () => void | Promise; /** Items rendered inside the chat-history popover. */ chatHistoryItems?: ChatHistoryItem[]; /** Refresh the popover items (called when the user opens the button). */ @@ -29,13 +33,14 @@ interface AgentChatControlsProps { /** * Minimal control bar for the Agent Chat view. The agent view stands alone - * (no chain switcher needed), so this only renders New Chat and the chat - * history popover. Intentionally omits the model picker, project picker, - * save-as-note, and settings popover — Agent Mode owns its own model/conversation - * state via ACP. + * (no chain switcher needed), so this only renders New Chat, an optional + * Save Chat button (when autosave is off), and the chat history popover. + * Intentionally omits the model picker, project picker, and settings popover + * — Agent Mode owns its own model/conversation state via ACP. */ export const AgentChatControls: React.FC = ({ onNewChat, + onSaveAsNote, chatHistoryItems, onLoadHistory, onLoadChat, @@ -43,6 +48,7 @@ export const AgentChatControls: React.FC = ({ onDeleteChat, onOpenSourceFile, }) => { + const settings = useSettingsValue(); const historyAvailable = Boolean( chatHistoryItems && onLoadChat && onUpdateChatTitle && onDeleteChat ); @@ -64,6 +70,21 @@ export const AgentChatControls: React.FC = ({ New Chat )} + {!settings.autosaveChat && onSaveAsNote && ( + + + + + Save Chat as Note + + )} {historyAvailable && (