feat(agent-mode): add chat history with multi-backend persistence

Persist Agent Mode sessions to markdown files in the configured save
folder using an `agent__` filename prefix and `mode: agent` /
`backendId: <id>` frontmatter. Empty sessions never persist.

Add the same History button (and popover UI) to `AgentChatControls`
that legacy Copilot Plus mode uses, wired to a new
`AgentChatPersistenceManager` and plugin methods on `main.ts`. The
history list is shared across all registered backends — backendId is
stored in frontmatter for routing, not surfaced in the row.

Loading a saved chat switches to the matching live tab if one exists;
otherwise spawns a new session on the saved backend and seeds its
message store. Subsequent turns update the same file.

Update the legacy `getChatHistoryFiles` filter to exclude `agent__`
files so the two history lists never overlap.

https://claude.ai/code/session_01CCLHubxusHSDTcbc5aWtH6
This commit is contained in:
Claude 2026-04-26 16:28:09 +00:00
parent fe25ed9e16
commit a1d49f4b2e
No known key found for this signature in database
10 changed files with 1113 additions and 18 deletions

View file

@ -4,6 +4,7 @@ import { logError } from "@/logger";
import { getSettings } from "@/settings/model";
import { backendRegistry, listBackendDescriptors } from "./backends/registry";
import { AgentModelPreloader } from "./session/AgentModelPreloader";
import { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
import { AgentSessionManager } from "./session/AgentSessionManager";
import { createDefaultPermissionPrompter } from "./ui/permissionPrompter";
@ -19,6 +20,8 @@ export {
export { useAgentModelPicker } from "./ui/useAgentModelPicker";
export type { AgentModelPickerOverride } from "./ui/useAgentModelPicker";
export type { AgentSessionManager } from "./session/AgentSessionManager";
export { AgentChatPersistenceManager } from "./session/AgentChatPersistenceManager";
export { AGENT_CHAT_FILE_PREFIX } from "./session/AgentChatPersistenceManager";
export type { BackendDescriptor, BackendId, InstallState } from "./session/types";
export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/registry";
@ -33,9 +36,11 @@ export { getActiveBackendDescriptor, listBackendDescriptors } from "./backends/r
* the existing manager and call this again.
*/
export function createAgentSessionManager(app: App, plugin: CopilotPlugin): AgentSessionManager {
const persistence = new AgentChatPersistenceManager(app);
const manager = new AgentSessionManager(app, plugin, {
permissionPrompter: createDefaultPermissionPrompter(app),
resolveDescriptor: (id) => backendRegistry[id],
persistence,
});
// Non-blocking — plugin load should not wait on disk reconcile.
for (const descriptor of listBackendDescriptors()) {

View file

@ -0,0 +1,310 @@
import { TFile, TFolder } from "obsidian";
import { AGENT_CHAT_FILE_PREFIX, AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
import { AI_SENDER, USER_SENDER } from "@/constants";
import type { AgentChatMessage } from "./types";
const SAVE_FOLDER = "copilot-conversations";
jest.mock("@/logger", () => ({
logInfo: jest.fn(),
logWarn: jest.fn(),
logError: jest.fn(),
}));
jest.mock("@/settings/model", () => ({
getSettings: () => ({
defaultSaveFolder: "copilot-conversations",
defaultConversationTag: "conversation",
defaultConversationNoteName: "{$date}_{$time}__{$topic}",
}),
}));
jest.mock("@/utils", () => {
const actual = jest.requireActual("@/utils");
return {
...actual,
ensureFolderExists: jest.fn().mockResolvedValue(undefined),
};
});
interface FakeFile {
path: string;
basename: string;
body: string;
frontmatter: Record<string, any>;
ctime: number;
}
/**
* Minimal in-memory `App` shim that captures vault writes and lets the test
* inspect what landed on "disk". Keeps the test free of jest mocks for every
* vault method by routing the persistence manager's calls through a small
* map.
*/
function toTFile(f: FakeFile): TFile {
// The obsidian mock's TFile is a constructable function — `instanceof TFile`
// checks downstream rely on this prototype chain.
const tfile = new (TFile as any)(f.path);
tfile.stat = { ctime: f.ctime, mtime: f.ctime, size: f.body.length };
return tfile;
}
function makeApp(initialFiles: FakeFile[] = []) {
const files = new Map<string, FakeFile>();
for (const f of initialFiles) files.set(f.path, f);
const folder = new (TFolder as any)(SAVE_FOLDER);
const vault = {
getAbstractFileByPath: (path: string) => {
if (path === SAVE_FOLDER) return folder;
const f = files.get(path);
return f ? toTFile(f) : null;
},
create: jest.fn(async (path: string, content: string) => {
if (files.has(path)) {
const err = new Error("File already exists.");
throw err;
}
const basename = path.split("/").pop()!.replace(/\.md$/, "");
const file: FakeFile = { path, basename, body: content, frontmatter: {}, ctime: Date.now() };
files.set(path, file);
return toTFile(file);
}),
modify: jest.fn(async (file: TFile, content: string) => {
const f = files.get(file.path);
if (f) f.body = content;
}),
read: jest.fn(async (file: TFile) => files.get(file.path)?.body ?? ""),
delete: jest.fn(async (file: TFile) => {
files.delete(file.path);
}),
getMarkdownFiles: () => Array.from(files.values()).map(toTFile),
adapter: {
exists: jest.fn(async (path: string) => path === SAVE_FOLDER || files.has(path)),
read: jest.fn(async (path: string) => files.get(path)?.body ?? ""),
write: jest.fn(async (path: string, content: string) => {
const f = files.get(path);
if (f) f.body = content;
}),
remove: jest.fn(async (path: string) => {
files.delete(path);
}),
list: jest.fn(async (path: string) => ({
files:
path === SAVE_FOLDER
? Array.from(files.keys()).filter((p) => p.startsWith(`${SAVE_FOLDER}/`))
: [],
folders: [],
})),
stat: jest.fn(),
mkdir: jest.fn(),
},
};
const metadataCache = {
getFileCache: (file: TFile) => {
const f = files.get(file.path);
return f ? { frontmatter: f.frontmatter } : null;
},
};
const fileManager = {
processFrontMatter: jest.fn(async (file: TFile, mutator: (fm: any) => void) => {
const f = files.get(file.path);
if (!f) return;
mutator(f.frontmatter);
}),
};
return {
app: { vault, metadataCache, fileManager } as any,
files,
};
}
function makeMessages(): AgentChatMessage[] {
return [
{
id: "u1",
sender: USER_SENDER,
message: "Hello agent",
isVisible: true,
timestamp: {
epoch: 1700000000000,
display: "2023/11/14 22:13:20",
fileName: "20231114_221320",
},
},
{
id: "a1",
sender: AI_SENDER,
message: "Hi there!",
isVisible: true,
timestamp: {
epoch: 1700000005000,
display: "2023/11/14 22:13:25",
fileName: "20231114_221325",
},
},
];
}
describe("AgentChatPersistenceManager", () => {
it("save() returns null when there are no messages", async () => {
const { app } = makeApp();
const mgr = new AgentChatPersistenceManager(app);
const result = await mgr.save({ messages: [], backendId: "opencode" });
expect(result).toBeNull();
expect(app.vault.create).not.toHaveBeenCalled();
});
it("save() creates a file with the agent__ prefix and mode/backendId frontmatter", async () => {
const { app, files } = makeApp();
const mgr = new AgentChatPersistenceManager(app);
await mgr.save({ messages: makeMessages(), backendId: "opencode", label: "My session" });
const created = Array.from(files.values()).find((f) =>
f.basename.startsWith(AGENT_CHAT_FILE_PREFIX)
);
expect(created).toBeDefined();
expect(created!.body).toMatch(/mode: agent/);
expect(created!.body).toMatch(/backendId: opencode/);
expect(created!.body).toMatch(/agentLabel: "My session"/);
expect(created!.body).toMatch(/\*\*user\*\*: Hello agent/);
expect(created!.body).toMatch(/\*\*ai\*\*: Hi there!/);
});
it("save() updates the existing file in place when given its path", async () => {
const { app, files } = makeApp();
const mgr = new AgentChatPersistenceManager(app);
const first = await mgr.save({ messages: makeMessages(), backendId: "opencode" });
expect(first).not.toBeNull();
const path = first!.path;
const fm = files.get(path)!.frontmatter;
fm.epoch = 1700000000000;
const more = [
...makeMessages(),
{
id: "u2",
sender: USER_SENDER,
message: "Another turn",
isVisible: true,
timestamp: {
epoch: 1700000010000,
display: "2023/11/14 22:13:30",
fileName: "20231114_221330",
},
},
] as AgentChatMessage[];
await mgr.save({ messages: more, backendId: "opencode" }, path);
expect(app.vault.create).toHaveBeenCalledTimes(1); // no new file
expect(files.get(path)!.body).toMatch(/Another turn/);
});
it("listFiles() filters to agent__ prefix only", async () => {
const { app } = makeApp([
{
path: "copilot-conversations/agent__abc.md",
basename: "agent__abc",
body: "",
frontmatter: {},
ctime: 0,
},
{
path: "copilot-conversations/regular_chat.md",
basename: "regular_chat",
body: "",
frontmatter: {},
ctime: 0,
},
{
path: "copilot-conversations/projectId__chat.md",
basename: "projectId__chat",
body: "",
frontmatter: {},
ctime: 0,
},
]);
const mgr = new AgentChatPersistenceManager(app);
const result = await mgr.listFiles();
expect(result.map((f) => f.basename)).toEqual(["agent__abc"]);
});
it("loadFile() round-trips a saved chat", async () => {
const { app, files } = makeApp();
const mgr = new AgentChatPersistenceManager(app);
const file = await mgr.save({ messages: makeMessages(), backendId: "claude-code" });
expect(file).not.toBeNull();
// Mirror the frontmatter the metadataCache would have picked up after save.
const stored = files.get(file!.path)!;
stored.frontmatter = parseFrontmatter(stored.body);
const loaded = await mgr.loadFile(file!);
expect(loaded).not.toBeNull();
expect(loaded!.backendId).toBe("claude-code");
expect(loaded!.messages).toHaveLength(2);
expect(loaded!.messages[0].sender).toBe(USER_SENDER);
expect(loaded!.messages[0].message).toBe("Hello agent");
expect(loaded!.messages[1].sender).toBe(AI_SENDER);
expect(loaded!.messages[1].message).toBe("Hi there!");
});
it("loadFile() rejects files whose mode is not 'agent'", async () => {
const file: FakeFile = {
path: "copilot-conversations/agent__legacy.md",
basename: "agent__legacy",
body: `---\nmode: copilot\n---\n\n**user**: not for agent`,
frontmatter: { mode: "copilot" },
ctime: 0,
};
const { app } = makeApp([file]);
const mgr = new AgentChatPersistenceManager(app);
const tFile = app.vault.getAbstractFileByPath(file.path);
const loaded = await mgr.loadFile(tFile);
expect(loaded).toBeNull();
});
it("updateTopic() patches frontmatter via fileManager", async () => {
const file: FakeFile = {
path: "copilot-conversations/agent__abc.md",
basename: "agent__abc",
body: `---\nmode: agent\nbackendId: opencode\n---\n\n**user**: hi`,
frontmatter: { mode: "agent", backendId: "opencode" },
ctime: 0,
};
const { app, files } = makeApp([file]);
const mgr = new AgentChatPersistenceManager(app);
await mgr.updateTopic(file.path, "Renamed");
expect(files.get(file.path)!.frontmatter.topic).toBe("Renamed");
});
it("deleteFile() removes the file from the vault", async () => {
const file: FakeFile = {
path: "copilot-conversations/agent__abc.md",
basename: "agent__abc",
body: "",
frontmatter: {},
ctime: 0,
};
const { app, files } = makeApp([file]);
const mgr = new AgentChatPersistenceManager(app);
await mgr.deleteFile(file.path);
expect(files.has(file.path)).toBe(false);
});
});
/** Tiny YAML extractor — just enough to feed the round-trip test. */
function parseFrontmatter(body: string): Record<string, any> {
const match = body.match(/^---\n([\s\S]*?)\n---/);
if (!match) return {};
const result: Record<string, any> = {};
for (const line of match[1].split("\n")) {
const m = line.match(/^(\w+):\s*(.+)/);
if (m) result[m[1]] = m[2].replace(/^"|"$/g, "");
}
return result;
}

View file

@ -0,0 +1,440 @@
import { AI_SENDER, USER_SENDER } from "@/constants";
import { logError, logInfo } from "@/logger";
import { getSettings } from "@/settings/model";
import {
ensureFolderExists,
formatDateTime,
getUtf8ByteLength,
truncateToByteLimit,
} from "@/utils";
import {
isInVaultCache,
listMarkdownFiles,
patchFrontmatter,
readFrontmatterViaAdapter,
} from "@/utils/vaultAdapterUtils";
import { App, Notice, TFile } from "obsidian";
import type { AgentChatMessage, NewAgentChatMessage } from "./types";
import type { BackendId } from "@/agentMode/acp/types";
const SAFE_FILENAME_BYTE_LIMIT = 100;
/** Filename prefix that marks a markdown file as an Agent Mode chat. */
export const AGENT_CHAT_FILE_PREFIX = "agent__";
/** Frontmatter discriminator value for `mode`. */
const AGENT_MODE_FRONTMATTER_VALUE = "agent";
function escapeYamlString(str: string): string {
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
/** Parsed payload returned by `loadFile` — matches what `AgentSessionManager` needs to seed a new session. */
export interface LoadedAgentChat {
/** Parsed messages without ids — the consuming `AgentMessageStore` assigns ids on load. */
messages: NewAgentChatMessage[];
backendId: BackendId | null;
topic?: string;
label?: string;
modelKey?: string;
epoch: number;
}
/** Snapshot the persistence manager needs to write a session to disk. */
export interface AgentChatSaveSnapshot {
/** Visible messages in display order. Persistence is skipped when this is empty. */
messages: AgentChatMessage[];
backendId: BackendId;
/** Optional sticky tab label set by the user or pushed by the agent. */
label?: string | null;
/** Optional agent-native model id, recorded for debugging — not used on load. */
modelKey?: string | null;
}
/**
* Persists Agent Mode chat sessions to markdown files in the configured save
* folder. Files are tagged with an `agent__` filename prefix and a `mode: agent`
* frontmatter discriminator so they can be filtered out of the legacy chat
* history list.
*
* Designed to be backend-agnostic: the `backendId` is recorded in frontmatter
* for routing on load, but listing is unified across all registered backends.
*/
export class AgentChatPersistenceManager {
constructor(private readonly app: App) {}
/**
* Save a session snapshot to disk. Returns the resulting `TFile` (or `null`
* when the snapshot has no visible messages empty sessions never persist).
*
* If `existingPath` points to a previously written file, that file is
* updated in place. Otherwise a fresh filename is generated based on the
* snapshot's first user message.
*/
async save(snapshot: AgentChatSaveSnapshot, existingPath?: string): Promise<TFile | null> {
if (snapshot.messages.length === 0) return null;
try {
const settings = getSettings();
await ensureFolderExists(settings.defaultSaveFolder);
const firstEpoch = this.firstMessageEpoch(snapshot.messages);
const existingFile = existingPath ? this.app.vault.getAbstractFileByPath(existingPath) : null;
const existingTopic = await this.readExistingTopic(existingPath, existingFile);
const existingLastAccessedAt = await this.readExistingLastAccessedAt(
existingPath,
existingFile
);
const targetPath =
existingFile instanceof TFile
? existingFile.path
: existingPath && (await this.app.vault.adapter.exists(existingPath))
? existingPath
: this.generateFileName(snapshot.messages, firstEpoch, existingTopic);
const noteContent = this.generateNoteContent(snapshot, firstEpoch, {
topic: existingTopic,
lastAccessedAt: existingLastAccessedAt,
});
if (existingFile instanceof TFile && isInVaultCache(this.app, existingFile.path)) {
await this.app.vault.modify(existingFile, noteContent);
return existingFile;
}
if (
!isInVaultCache(this.app, targetPath) &&
(await this.app.vault.adapter.exists(targetPath))
) {
await this.app.vault.adapter.write(targetPath, noteContent);
return null;
}
try {
const created = await this.app.vault.create(targetPath, noteContent);
return created;
} catch (error) {
if (this.isFileAlreadyExistsError(error)) {
const existing = this.app.vault.getAbstractFileByPath(targetPath);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, noteContent);
return existing;
}
await this.app.vault.adapter.write(targetPath, noteContent);
return null;
}
if (this.isNameTooLongError(error)) {
const fallback = `${settings.defaultSaveFolder}/${AGENT_CHAT_FILE_PREFIX}chat-${firstEpoch}.md`;
try {
return await this.app.vault.create(fallback, noteContent);
} catch (fallbackError) {
if (this.isFileAlreadyExistsError(fallbackError)) {
const existing = this.app.vault.getAbstractFileByPath(fallback);
if (existing instanceof TFile) {
await this.app.vault.modify(existing, noteContent);
return existing;
}
await this.app.vault.adapter.write(fallback, noteContent);
return null;
}
throw fallbackError;
}
}
throw error;
}
} catch (error) {
logError("[AgentChatPersistenceManager] Error saving session:", error);
new Notice("Failed to save agent chat. Check console for details.");
return null;
}
}
/**
* Read a saved agent chat file and return the parsed payload. Returns `null`
* when the file isn't an Agent Mode file (no `mode: agent` frontmatter).
*/
async loadFile(file: TFile): Promise<LoadedAgentChat | null> {
try {
let content: string;
try {
content = await this.app.vault.read(file);
} catch {
content = await this.app.vault.adapter.read(file.path);
}
const frontmatter = await this.readFrontmatter(file);
if (frontmatter?.mode && frontmatter.mode !== AGENT_MODE_FRONTMATTER_VALUE) return null;
const messages = this.parseChatBody(content);
const epoch = Number(frontmatter?.epoch);
logInfo(`[AgentChatPersistenceManager] Loaded ${messages.length} messages from ${file.path}`);
return {
messages,
backendId: (frontmatter?.backendId as BackendId | undefined) ?? null,
topic: frontmatter?.topic,
label: frontmatter?.agentLabel,
modelKey: frontmatter?.modelKey,
epoch: Number.isFinite(epoch) && epoch > 0 ? epoch : file.stat.ctime,
};
} catch (error) {
logError("[AgentChatPersistenceManager] Error loading file:", error);
new Notice("Failed to load agent chat. Check console for details.");
return null;
}
}
/**
* List all agent chat files in the configured save folder, regardless of
* which backend produced them. Filtering is filename-prefix based to avoid
* reading every file's frontmatter just to build the list.
*/
async listFiles(): Promise<TFile[]> {
const settings = getSettings();
const files = await listMarkdownFiles(this.app, settings.defaultSaveFolder);
return files.filter((f) => f.basename.startsWith(AGENT_CHAT_FILE_PREFIX));
}
private async readFrontmatter(file: TFile): Promise<Record<string, any> | null> {
const cached = this.app.metadataCache.getFileCache(file)?.frontmatter;
if (cached) return cached;
try {
return await readFrontmatterViaAdapter(this.app, file.path);
} catch {
return null;
}
}
private async readExistingTopic(
path: string | undefined,
file: ReturnType<App["vault"]["getAbstractFileByPath"]>
): Promise<string | undefined> {
if (!path) return undefined;
if (file instanceof TFile) {
const fm = await this.readFrontmatter(file);
return fm?.topic;
}
try {
const fm = await readFrontmatterViaAdapter(this.app, path);
return fm?.topic;
} catch {
return undefined;
}
}
private async readExistingLastAccessedAt(
path: string | undefined,
file: ReturnType<App["vault"]["getAbstractFileByPath"]>
): Promise<number | undefined> {
if (!path) return undefined;
if (file instanceof TFile) {
const fm = await this.readFrontmatter(file);
const value = Number(fm?.lastAccessedAt);
return Number.isFinite(value) && value > 0 ? value : undefined;
}
try {
const fm = await readFrontmatterViaAdapter(this.app, path);
const value = Number(fm?.lastAccessedAt);
return Number.isFinite(value) && value > 0 ? value : undefined;
} catch {
return undefined;
}
}
private firstMessageEpoch(messages: AgentChatMessage[]): number {
for (const msg of messages) {
if (msg.timestamp?.epoch) return msg.timestamp.epoch;
}
return Date.now();
}
private generateNoteContent(
snapshot: AgentChatSaveSnapshot,
firstEpoch: number,
preserved: { topic?: string; lastAccessedAt?: number }
): string {
const settings = getSettings();
const topic = preserved.topic;
const label = snapshot.label?.trim();
const modelKey = snapshot.modelKey?.trim();
const lastAccessedAt = preserved.lastAccessedAt;
const frontmatterLines = [
`epoch: ${firstEpoch}`,
`mode: ${AGENT_MODE_FRONTMATTER_VALUE}`,
`backendId: ${snapshot.backendId}`,
];
if (topic) frontmatterLines.push(`topic: "${escapeYamlString(topic)}"`);
if (label) frontmatterLines.push(`agentLabel: "${escapeYamlString(label)}"`);
if (modelKey) frontmatterLines.push(`modelKey: "${escapeYamlString(modelKey)}"`);
if (lastAccessedAt) frontmatterLines.push(`lastAccessedAt: ${lastAccessedAt}`);
frontmatterLines.push("tags:");
frontmatterLines.push(` - ${settings.defaultConversationTag}`);
const body = this.formatChatBody(snapshot.messages);
return `---\n${frontmatterLines.join("\n")}\n---\n\n${body}`;
}
private formatChatBody(messages: AgentChatMessage[]): string {
return messages
.map((message) => {
const timestamp = message.timestamp?.display ?? "Unknown time";
const sender = message.sender;
return `**${sender}**: ${message.message}\n[Timestamp: ${timestamp}]`;
})
.join("\n\n");
}
/**
* Parse the markdown body into `AgentChatMessage` objects. Tool / plan /
* thought parts are not restored because they're display-only and don't
* survive a serialize/parse round-trip; the saved view is plain text.
*/
private parseChatBody(content: string): NewAgentChatMessage[] {
const stripped = content.replace(/^---\n[\s\S]*?\n---/, "").trim();
const messages: NewAgentChatMessage[] = [];
const pattern = /\*\*(user|ai)\*\*: ([\s\S]*?)(?=(?:\n\*\*(?:user|ai)\*\*: )|$)/g;
let match: RegExpExecArray | null;
while ((match = pattern.exec(stripped)) !== null) {
const sender = match[1] === "user" ? USER_SENDER : AI_SENDER;
const lines = match[2].trim().split("\n");
let timestamp: string | undefined;
let endIndex = lines.length;
const last = lines[endIndex - 1];
if (last?.startsWith("[Timestamp: ")) {
const ts = last.match(/\[Timestamp: (.*?)\]/);
if (ts) {
timestamp = ts[1];
endIndex--;
}
}
const messageText = lines.slice(0, endIndex).join("\n").trim();
const epoch = timestamp ? Date.parse(timestamp) : NaN;
messages.push({
message: messageText,
sender,
isVisible: true,
timestamp:
timestamp && Number.isFinite(epoch) ? { epoch, display: timestamp, fileName: "" } : null,
});
}
return messages;
}
/**
* Build a fresh filename from the first user message. Mirrors the budget
* logic in `ChatPersistenceManager.generateFileName` same `defaultSaveFolder`,
* same `defaultConversationNoteName` template, but with `agent__` prefix
* instead of a project id prefix.
*/
private generateFileName(
messages: AgentChatMessage[],
firstEpoch: number,
topic?: string
): string {
const settings = getSettings();
const formatted = formatDateTime(new Date(firstEpoch));
const [date, time] = formatted.fileName.split("_");
let topicForFilename: string;
if (topic) {
topicForFilename = topic;
} else {
const firstUser = messages.find((m) => m.sender === USER_SENDER);
topicForFilename = firstUser
? firstUser.message
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[{}[\]]/g, "")
.split(/\s+/)
.slice(0, 10)
.join(" ")
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "")
.trim() || "Untitled Chat"
: "Untitled Chat";
}
let template = settings.defaultConversationNoteName || "{$date}_{$time}__{$topic}";
const filePrefix = AGENT_CHAT_FILE_PREFIX;
const extensionBytes = getUtf8ByteLength(".md");
const filePrefixBytes = getUtf8ByteLength(filePrefix);
const overhead = template
.replace("{$topic}", "")
.replace("{$date}", date)
.replace("{$time}", time);
const overheadBytes = getUtf8ByteLength(overhead);
const topicBudget = Math.max(
20,
SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes - overheadBytes
);
const truncatedTopic = truncateToByteLimit(topicForFilename.replace(/\s+/g, "_"), topicBudget);
template = template
.replace("{$topic}", truncatedTopic)
.replace("{$date}", date)
.replace("{$time}", time);
const sanitized = template
.replace(/\[\[([^\]]+)\]\]/g, "$1")
.replace(/[{}[\]]/g, "_")
// eslint-disable-next-line no-control-regex
.replace(/[\\/:*?"<>|\x00-\x1F]/g, "_");
const basename = `${filePrefix}${sanitized}.md`;
if (getUtf8ByteLength(basename) > SAFE_FILENAME_BYTE_LIMIT) {
const available = SAFE_FILENAME_BYTE_LIMIT - extensionBytes - filePrefixBytes;
const truncated = truncateToByteLimit(sanitized, available);
return `${settings.defaultSaveFolder}/${filePrefix}${truncated}.md`;
}
return `${settings.defaultSaveFolder}/${basename}`;
}
/**
* Update the `topic` frontmatter field. Used by the inline rename in the
* history popover. Falls back to the adapter when the file lives in a
* hidden directory not indexed by Obsidian's metadata cache.
*/
async updateTopic(filePath: string, topic: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile && this.app.fileManager?.processFrontMatter) {
await this.app.fileManager.processFrontMatter(file, (fm) => {
fm.topic = topic;
});
return;
}
if (await this.app.vault.adapter.exists(filePath)) {
await patchFrontmatter(this.app, filePath, { topic: topic.trim() });
return;
}
throw new Error("Agent chat file not found.");
}
/** Delete a saved agent chat file. */
async deleteFile(filePath: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(filePath);
if (file instanceof TFile) {
await this.app.vault.delete(file);
return;
}
if (await this.app.vault.adapter.exists(filePath)) {
await this.app.vault.adapter.remove(filePath);
return;
}
throw new Error("Agent chat file not found.");
}
private isFileAlreadyExistsError(error: unknown): boolean {
if (!error) return false;
const msg = error instanceof Error ? error.message : String(error);
return msg.toLowerCase().includes("already exists");
}
private isNameTooLongError(error: unknown): boolean {
if (!error) return false;
const msg = error instanceof Error ? error.message : String(error);
const normalized = msg.toLowerCase();
return normalized.includes("enametoolong") || normalized.includes("name too long");
}
}

View file

@ -172,20 +172,10 @@ export class AgentMessageStore {
return msg ? this.toAgentChatMessage(msg) : undefined;
}
loadMessages(messages: AgentChatMessage[]): void {
loadMessages(messages: NewAgentChatMessage[]): void {
this.clear();
for (const msg of messages) {
this.messages.push({
id: msg.id || this.generateId(),
displayText: msg.message,
sender: msg.sender,
timestamp: msg.timestamp || formatDateTime(new Date()),
context: msg.context,
isVisible: msg.isVisible !== false,
isErrorMessage: msg.isErrorMessage,
content: msg.content,
parts: msg.parts,
});
this.addMessage(msg);
}
logInfo(`[AgentMessageStore] Loaded ${messages.length} messages`);
}

View file

@ -318,6 +318,17 @@ export class AgentSession {
this.abortController?.abort();
}
/**
* Replace the session's message history with `messages` and notify
* subscribers. Used by the history-load flow: the underlying ACP session is
* fresh (no prior context), but the user-visible transcript is restored from
* the saved file.
*/
loadHistoryMessages(messages: NewAgentChatMessage[]): void {
this.store.loadMessages(messages);
this.notifyMessages();
}
/** Detach from the backend. Does not cancel — call `cancel()` first. */
async dispose(): Promise<void> {
this.unregisterSessionHandler?.();

View file

@ -92,6 +92,13 @@ function buildManager(): AgentSessionManager {
{
permissionPrompter: jest.fn(),
resolveDescriptor: (id) => (id === descriptor.id ? descriptor : undefined),
persistence: {
save: jest.fn().mockResolvedValue(null),
loadFile: jest.fn().mockResolvedValue(null),
listFiles: jest.fn().mockResolvedValue([]),
updateTopic: jest.fn().mockResolvedValue(undefined),
deleteFile: jest.fn().mockResolvedValue(undefined),
} as unknown as ConstructorParameters<typeof AgentSessionManager>[2]["persistence"],
}
);
}

View file

@ -1,16 +1,20 @@
import { logError, logInfo, logWarn } from "@/logger";
import type CopilotPlugin from "@/main";
import { AgentChatUIState } from "@/agentMode/session/AgentChatUIState";
import type { AgentChatPersistenceManager } from "@/agentMode/session/AgentChatPersistenceManager";
import { getSettings, setSettings } from "@/settings/model";
import { err2String } from "@/utils";
import type { RequestPermissionRequest, RequestPermissionResponse } from "@agentclientprotocol/sdk";
import { App, FileSystemAdapter, Platform } from "obsidian";
import { App, FileSystemAdapter, Platform, TFile } from "obsidian";
import { v4 as uuidv4 } from "uuid";
import { AcpBackendProcess } from "@/agentMode/acp/AcpBackendProcess";
import { AgentSession } from "./AgentSession";
import type { AgentModelPreloader } from "./AgentModelPreloader";
import type { BackendDescriptor, BackendId } from "./types";
/** Trailing-edge debounce window for persisting message changes to disk. */
const SAVE_DEBOUNCE_MS = 500;
export type PermissionPrompter = (
req: RequestPermissionRequest
) => Promise<RequestPermissionResponse>;
@ -22,6 +26,8 @@ export type DescriptorResolver = (id: BackendId) => BackendDescriptor | undefine
export interface AgentSessionManagerOptions {
permissionPrompter: PermissionPrompter;
resolveDescriptor: DescriptorResolver;
/** Persistence sink for session message changes. Required so the history list reflects live sessions. */
persistence: AgentChatPersistenceManager;
}
/**
@ -51,6 +57,12 @@ export class AgentSessionManager {
private isStarting = false;
private lastError: string | null = null;
private preloader: AgentModelPreloader | null = null;
/** File path each session is currently persisted to. Unset until first save lands. */
private sessionFilePaths = new Map<string, string>();
/** Pending debounced save handle per session. */
private saveTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** Per-session unsubscribe handles for the persistence listener. */
private persistenceUnsubs = new Map<string, () => void>();
constructor(
private readonly app: App,
@ -128,6 +140,7 @@ export class AgentSessionManager {
this.sessions.set(session.internalId, session);
this.chatUIStates.set(session.internalId, new AgentChatUIState(session));
this.activeSessionId = session.internalId;
this.attachPersistence(session);
// Clear lastError on success — but not eagerly at the start of every
// create. Eager clearing would let a second concurrent create wipe the
// first's error before the user (or a retry handler) has seen it.
@ -181,6 +194,13 @@ export class AgentSessionManager {
return this.preloader;
}
/** The persistence manager handed in at construction time. Exposed so the
* plugin host can call list/delete/rename without deep-importing the
* session layer. */
getPersistenceManager(): AgentChatPersistenceManager {
return this.opts.persistence;
}
/**
* Cancel any in-flight turn, dispose the session, and remove it from the
* pool. If the closed session was active, picks the right neighbor (or the
@ -204,6 +224,7 @@ export class AgentSessionManager {
} catch (e) {
logWarn(`[AgentMode] dispose during closeSession failed`, e);
}
this.detachPersistence(id);
this.sessions.delete(id);
this.chatUIStates.delete(id);
if (this.activeSessionId === id) {
@ -321,6 +342,12 @@ export class AgentSessionManager {
`[AgentMode] shutdown (pool size=${this.sessions.size}, backends=${this.backends.size})`
);
// Flush pending debounced saves first so the last turn isn't lost when the
// user closes Obsidian within the debounce window. Done before dispose so
// the sessions can still report messages.
await Promise.allSettled(
Array.from(this.sessions.keys()).map((id) => this.flushPersistence(id))
);
const allSessions = Array.from(this.sessions.values());
for (const session of allSessions) {
try {
@ -333,10 +360,12 @@ export class AgentSessionManager {
} catch (e) {
logError("[AgentMode] dispose during shutdown failed", e);
}
this.detachPersistence(session.internalId);
}
this.sessions.clear();
this.chatUIStates.clear();
this.activeSessionId = null;
this.sessionFilePaths.clear();
const allBackends = Array.from(this.backends.values());
for (const proc of allBackends) {
@ -379,6 +408,7 @@ export class AgentSessionManager {
const dead = Array.from(this.sessions.values()).filter((s) => s.backendId === backendId);
if (dead.length === 0) return;
for (const s of dead) {
this.detachPersistence(s.internalId);
this.sessions.delete(s.internalId);
this.chatUIStates.delete(s.internalId);
s.cancel().catch(() => {});
@ -405,4 +435,115 @@ export class AgentSessionManager {
this.starting.delete(backendId);
}
}
/**
* Subscribe to a session's message and label changes and trigger debounced
* persistence on each notification. Sessions with zero visible messages are
* skipped at save time, so empty tabs never produce a file.
*/
private attachPersistence(session: AgentSession): void {
const id = session.internalId;
const unsub = session.subscribe({
onMessagesChanged: () => this.scheduleSave(id),
onStatusChanged: () => {},
onLabelChanged: () => this.scheduleSave(id),
});
this.persistenceUnsubs.set(id, unsub);
}
/**
* Cancel any pending save and unsubscribe from the session's notifications.
* Called when a session is closed, when its backend exits, and on shutdown.
* Note: this does NOT delete the persisted file that requires an explicit
* delete via the history popover.
*/
private detachPersistence(id: string): void {
const timer = this.saveTimers.get(id);
if (timer) {
clearTimeout(timer);
this.saveTimers.delete(id);
}
const unsub = this.persistenceUnsubs.get(id);
if (unsub) {
unsub();
this.persistenceUnsubs.delete(id);
}
}
private scheduleSave(id: string): void {
const existing = this.saveTimers.get(id);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
this.saveTimers.delete(id);
void this.runSave(id);
}, SAVE_DEBOUNCE_MS);
this.saveTimers.set(id, timer);
}
/** Cancel the debounced timer (if any) and run the save synchronously. */
private async flushPersistence(id: string): Promise<void> {
const timer = this.saveTimers.get(id);
if (timer) {
clearTimeout(timer);
this.saveTimers.delete(id);
}
await this.runSave(id);
}
private async runSave(id: string): Promise<void> {
const session = this.sessions.get(id);
if (!session) return;
const messages = session.store.getDisplayMessages();
if (messages.length === 0) return;
const existingPath = this.sessionFilePaths.get(id);
try {
const file = await this.opts.persistence.save(
{
messages,
backendId: session.backendId,
label: session.getLabel(),
modelKey: session.getModelState()?.currentModelId ?? null,
},
existingPath
);
if (file) this.sessionFilePaths.set(id, file.path);
} catch (e) {
logWarn(`[AgentMode] persistence save failed for session ${id}`, e);
}
}
/**
* Load a saved agent chat into a session. If a live session is already bound
* to this file (e.g. the user clicked an item that's already an open tab),
* just activate that tab. Otherwise spawn a new session on the file's
* backend, seed its message store from the file, and bind the new session
* to the same path so subsequent saves update the same file.
*/
async loadSessionFromHistory(file: TFile): Promise<AgentSession | null> {
if (this.disposed) {
throw new Error("AgentSessionManager has been shut down");
}
// Switch if already open.
for (const [id, path] of this.sessionFilePaths) {
if (path === file.path) {
this.setActiveSession(id);
return this.sessions.get(id) ?? null;
}
}
const loaded = await this.opts.persistence.loadFile(file);
if (!loaded) {
throw new Error(`File "${file.path}" is not a saved agent chat.`);
}
const backendId = loaded.backendId ?? getSettings().agentMode?.activeBackend ?? "opencode";
const session = await this.createSession(backendId);
// Bind the persistence path BEFORE seeding messages so the debounced save
// triggered by `loadHistoryMessages` updates the existing file instead of
// generating a fresh filename.
this.sessionFilePaths.set(session.internalId, file.path);
if (loaded.label) session.setLabel(loaded.label);
session.loadHistoryMessages(loaded.messages);
return session;
}
}

View file

@ -3,6 +3,7 @@ import AgentChatMessages from "@/agentMode/ui/AgentChatMessages";
import { AgentModeStatus } from "@/agentMode/ui/AgentModeStatus";
import { useSessionBackendDescriptor } from "@/agentMode/ui/useBackendDescriptor";
import { useAgentModelPicker } from "@/agentMode/ui/useAgentModelPicker";
import { ChatHistoryItem } from "@/components/chat-components/ChatHistoryPopover";
import ChatInput from "@/components/chat-components/ChatInput";
import { EVENT_NAMES } from "@/constants";
import { AppContext, EventTargetContext } from "@/context";
@ -47,6 +48,7 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
const [includeActiveNote, setIncludeActiveNote] = useState(false);
const [includeActiveWebTab, setIncludeActiveWebTab] = useState(false);
const [loading, setLoading] = useState(false);
const [chatHistoryItems, setChatHistoryItems] = useState<ChatHistoryItem[]>([]);
const isMountedRef = useRef(false);
const chatContainerRef = useRef<HTMLDivElement>(null);
@ -148,6 +150,68 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
setContextNotes([]);
}, [backend, handleStopGenerating]);
const handleLoadChatHistory = useCallback(async () => {
try {
const items = await plugin.getAgentChatHistoryItems();
setChatHistoryItems(items);
} catch (error) {
logError("[AgentMode] failed to load chat history", error);
new Notice("Failed to load agent chat history.");
}
}, [plugin]);
const handleLoadChat = useCallback(
async (id: string) => {
try {
await plugin.loadAgentChatById(id);
} catch (error) {
logError("[AgentMode] failed to load chat", error);
new Notice("Failed to load agent chat.");
}
},
[plugin]
);
const handleUpdateChatTitle = useCallback(
async (id: string, newTitle: string) => {
try {
await plugin.updateAgentChatTitle(id, newTitle);
setChatHistoryItems((prev) =>
prev.map((it) => (it.id === id ? { ...it, title: newTitle } : it))
);
} catch (error) {
logError("[AgentMode] failed to rename chat", error);
new Notice("Failed to rename agent chat.");
}
},
[plugin]
);
const handleDeleteChat = useCallback(
async (id: string) => {
try {
await plugin.deleteAgentChatHistory(id);
setChatHistoryItems((prev) => prev.filter((it) => it.id !== id));
} catch (error) {
logError("[AgentMode] failed to delete chat", error);
new Notice("Failed to delete agent chat.");
}
},
[plugin]
);
const handleOpenSourceFile = useCallback(
async (id: string) => {
try {
await plugin.openAgentChatSourceFile(id);
} catch (error) {
logError("[AgentMode] failed to open chat source file", error);
new Notice("Failed to open chat source file.");
}
},
[plugin]
);
const handleDelete = useCallback(
async (messageId: string) => {
try {
@ -191,7 +255,15 @@ const AgentChatInternal: React.FC<AgentChatProps> = ({
<div className="tw-flex tw-size-full tw-flex-col tw-overflow-hidden">
<AgentModeStatus manager={manager} onInstallClick={handleInstall} />
<AgentChatMessages messages={messages} app={app} onDelete={handleDelete} />
<AgentChatControls onNewChat={handleNewChat} />
<AgentChatControls
onNewChat={handleNewChat}
chatHistoryItems={chatHistoryItems}
onLoadHistory={handleLoadChatHistory}
onLoadChat={handleLoadChat}
onUpdateChatTitle={handleUpdateChatTitle}
onDeleteChat={handleDeleteChat}
onOpenSourceFile={handleOpenSourceFile}
/>
<ChatInput
inputMessage={inputMessage}
setInputMessage={setInputMessage}

View file

@ -1,5 +1,9 @@
import { useChainType } from "@/aiParams";
import { ChainType } from "@/chainFactory";
import {
ChatHistoryItem,
ChatHistoryPopover,
} from "@/components/chat-components/ChatHistoryPopover";
import { Button } from "@/components/ui/button";
import { DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@ -10,6 +14,7 @@ import { DropdownMenu, DropdownMenuTrigger } from "@radix-ui/react-dropdown-menu
import {
Bot,
ChevronDown,
History,
LibraryBig,
MessageCirclePlus,
Sparkles,
@ -22,6 +27,16 @@ interface AgentChatControlsProps {
* button is hidden clicking it would be a no-op since there's nothing to
* clear. */
onNewChat?: () => void;
/** Items currently loaded into the history popover. Empty until the user
* opens the popover and `onLoadHistory` populates it. */
chatHistoryItems?: ChatHistoryItem[];
/** Triggered when the user clicks the History button should populate
* `chatHistoryItems`. Omit to hide the button entirely. */
onLoadHistory?: () => void;
onLoadChat?: (id: string) => Promise<void>;
onUpdateChatTitle?: (id: string, newTitle: string) => Promise<void>;
onDeleteChat?: (id: string) => Promise<void>;
onOpenSourceFile?: (id: string) => Promise<void>;
}
/**
@ -31,10 +46,19 @@ interface AgentChatControlsProps {
* Agent Mode does not honor those (ACP owns model/conversation state, no
* persistence yet).
*/
export const AgentChatControls: React.FC<AgentChatControlsProps> = ({ onNewChat }) => {
export const AgentChatControls: React.FC<AgentChatControlsProps> = ({
onNewChat,
chatHistoryItems,
onLoadHistory,
onLoadChat,
onUpdateChatTitle,
onDeleteChat,
onOpenSourceFile,
}) => {
const settings = useSettingsValue();
const [, setSelectedChain] = useChainType();
const isPlusUser = useIsPlusUser();
const showHistory = !!(onLoadHistory && onUpdateChatTitle && onDeleteChat);
return (
<div className="tw-flex tw-w-full tw-items-center tw-justify-between tw-p-1">
@ -103,6 +127,24 @@ export const AgentChatControls: React.FC<AgentChatControlsProps> = ({ onNewChat
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
)}
{showHistory && (
<Tooltip>
<ChatHistoryPopover
chatHistory={chatHistoryItems ?? []}
onUpdateTitle={onUpdateChatTitle!}
onDeleteChat={onDeleteChat!}
onLoadChat={onLoadChat}
onOpenSourceFile={onOpenSourceFile}
>
<TooltipTrigger asChild>
<Button variant="ghost2" size="icon" title="Chat History" onClick={onLoadHistory}>
<History className="tw-size-4" />
</Button>
</TooltipTrigger>
</ChatHistoryPopover>
<TooltipContent>Chat History</TooltipContent>
</Tooltip>
)}
</div>
</div>
);

View file

@ -1,4 +1,8 @@
import { createAgentSessionManager, type AgentSessionManager } from "@/agentMode";
import {
AGENT_CHAT_FILE_PREFIX,
createAgentSessionManager,
type AgentSessionManager,
} from "@/agentMode";
import { BrevilabsClient } from "@/LLMProviders/brevilabsClient";
import ProjectManager from "@/LLMProviders/projectManager";
import {
@ -645,6 +649,12 @@ export default class CopilotPlugin extends Plugin {
const folderFiles = await listMarkdownFiles(this.app, getSettings().defaultSaveFolder);
if (folderFiles.length === 0) return [];
// Always exclude Agent Mode files — they have their own history list and
// share the same save folder via the `agent__` filename prefix.
const nonAgentFiles = folderFiles.filter(
(file) => !file.basename.startsWith(AGENT_CHAT_FILE_PREFIX)
);
// Get current project ID if in a project
const currentProject = getCurrentProject();
const currentProjectId = currentProject?.id;
@ -652,11 +662,11 @@ export default class CopilotPlugin extends Plugin {
if (currentProjectId) {
// In project mode: return only files with this project's ID prefix
const projectPrefix = `${currentProjectId}__`;
return folderFiles.filter((file) => file.basename.startsWith(projectPrefix));
return nonAgentFiles.filter((file) => file.basename.startsWith(projectPrefix));
} else {
// In non-project mode: return only files without any project ID prefix
// This assumes project IDs always use the format projectId__ as prefix
return folderFiles.filter((file) => {
return nonAgentFiles.filter((file) => {
// Check if the filename has any projectId__ prefix pattern
return !file.basename.match(/^[a-zA-Z0-9-]+__/);
});
@ -845,6 +855,73 @@ export default class CopilotPlugin extends Plugin {
}
}
/**
* List Agent Mode chat history items across all backends. Empty sessions
* never persist, so they never appear here. Backends share the same on-disk
* folder via the `agent__` filename prefix.
*/
async getAgentChatHistoryItems(): Promise<ChatHistoryItem[]> {
if (!this.agentSessionManager) return [];
const persistence = this.agentSessionManager.getPersistenceManager();
const files = await persistence.listFiles();
return files.map((file) => {
const createdAt = extractChatDate(file);
const persistedLastAccessedAtMs = extractChatLastAccessedAtMs(file);
const effectiveLastAccessedAtMs =
this.chatHistoryLastAccessedAtManager.getEffectiveLastUsedAt(
file.path,
persistedLastAccessedAtMs ?? createdAt.getTime()
);
return {
id: file.path,
title: extractChatTitle(file),
createdAt,
lastAccessedAt: new Date(effectiveLastAccessedAtMs),
};
});
}
async loadAgentChatById(fileId: string): Promise<void> {
if (!this.agentSessionManager) {
throw new Error("Agent Mode is not initialized.");
}
const file = await resolveFileByPath(this.app, fileId);
if (!file) throw new Error("Agent chat file not found.");
await this.agentSessionManager.loadSessionFromHistory(file);
this.chatHistoryLastAccessedAtManager.touch(fileId);
}
async openAgentChatSourceFile(fileId: string): Promise<void> {
const file = this.app.vault.getAbstractFileByPath(fileId);
if (file instanceof TFile) {
await this.app.workspace.getLeaf(true).openFile(file);
return;
}
if (await this.app.vault.adapter.exists(fileId)) {
new Notice(
"Cannot open source files from hidden directories. To open chat notes in the editor, save them to a non-hidden folder in settings."
);
return;
}
throw new Error("Agent chat file not found.");
}
async updateAgentChatTitle(fileId: string, newTitle: string): Promise<void> {
if (!this.agentSessionManager) {
throw new Error("Agent Mode is not initialized.");
}
await this.agentSessionManager.getPersistenceManager().updateTopic(fileId, newTitle);
new Notice("Chat title updated.");
}
async deleteAgentChatHistory(fileId: string): Promise<void> {
if (!this.agentSessionManager) {
throw new Error("Agent Mode is not initialized.");
}
await this.agentSessionManager.getPersistenceManager().deleteFile(fileId);
new Notice("Chat deleted.");
}
async handleNewChat() {
clearRecordedPromptPayload();
await logFileManager.clear();