Add agent chat autosave resume support (#2482)

This commit is contained in:
Zero Liu 2026-05-19 17:10:33 -07:00 committed by Logan Yang
parent e0c166ac0f
commit 02b9ac31c9
No known key found for this signature in database
5 changed files with 260 additions and 26 deletions

View file

@ -446,11 +446,54 @@ export class ClaudeSdkBackendProcess implements BackendProcess {
throw new MethodUnsupportedError("session/list");
}
async resumeSession(_params: ResumeSessionInput): Promise<ResumeSessionOutput> {
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: <sessionId>` to the SDK, which loads the
* prior conversation from `~/.claude/projects/.../<sessionId>.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<ResumeSessionOutput> {
logSdkOutbound(
"resumeSession",
{ cwd: params.cwd, mcpServers: params.mcpServers },
params.sessionId
);
const cwd = params.cwd ?? null;
const mcp: Record<string, McpServerConfig> = {};
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<LoadSessionOutput> {
// 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");
}

View file

@ -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)}"`);

View file

@ -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<AgentSession> {
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<AgentSession | null> {
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<void> {
/**
* 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;
}
/**

View file

@ -162,13 +162,29 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
}, [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<AgentChatProps> = ({
)}
<AgentChatControls
onNewChat={handleNewChat}
onSaveAsNote={handleSaveAsNote}
chatHistoryItems={chatHistoryItems}
onLoadHistory={handleLoadChatHistory}
onLoadChat={handleLoadChat}

View file

@ -5,7 +5,8 @@ import {
} from "@/components/chat-components/ChatHistoryPopover";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Bot, History, MessageCirclePlus } from "lucide-react";
import { useSettingsValue } from "@/settings/model";
import { Bot, Download, History, MessageCirclePlus } from "lucide-react";
import React from "react";
const resolveHistoryIcon = (item: ChatHistoryItem) =>
@ -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<void>;
/** 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<AgentChatControlsProps> = ({
onNewChat,
onSaveAsNote,
chatHistoryItems,
onLoadHistory,
onLoadChat,
@ -43,6 +48,7 @@ export const AgentChatControls: React.FC<AgentChatControlsProps> = ({
onDeleteChat,
onOpenSourceFile,
}) => {
const settings = useSettingsValue();
const historyAvailable = Boolean(
chatHistoryItems && onLoadChat && onUpdateChatTitle && onDeleteChat
);
@ -64,6 +70,21 @@ export const AgentChatControls: React.FC<AgentChatControlsProps> = ({
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
)}
{!settings.autosaveChat && onSaveAsNote && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost2"
size="icon"
title="Save Chat as Note"
onClick={() => void onSaveAsNote()}
>
<Download className="tw-size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Save Chat as Note</TooltipContent>
</Tooltip>
)}
{historyAvailable && (
<Tooltip>
<ChatHistoryPopover