mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Add agent chat autosave resume support (#2482)
This commit is contained in:
parent
e0c166ac0f
commit
02b9ac31c9
5 changed files with 260 additions and 26 deletions
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}"`);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue