mirror of
https://github.com/logancyang/obsidian-copilot.git
synced 2026-07-22 07:50:24 +00:00
Squashed from 8 commits on zero/acp-test: - feat(agent-mode): introduce Agent Mode feature (squashed) - Fix eslint - hide add context button - chore: ban parent-relative imports and rewrite existing ones to @/ aliases - wip(agent-mode): skills management + slash command revamp - fix(agent-mode): initialize SkillManager before preload probes - fix(build): align svgr jsxRuntime with tsconfig classic jsx - fix(test): stub ItemView/WorkspaceLeaf in ChatSingleMessage obsidian mock
182 lines
6.8 KiB
TypeScript
182 lines
6.8 KiB
TypeScript
/* eslint-disable obsidianmd/no-tfile-tfolder-cast -- test fixtures; not real TFiles */
|
|
import { AI_SENDER, USER_SENDER } from "@/constants";
|
|
import { AgentChatPersistenceManager } from "./AgentChatPersistenceManager";
|
|
import type { AgentChatMessage } from "./types";
|
|
import type { App, TFile } from "obsidian";
|
|
|
|
jest.mock("obsidian", () => ({
|
|
Notice: jest.fn(),
|
|
TFile: jest.fn(),
|
|
}));
|
|
jest.mock("@/logger");
|
|
jest.mock("@/settings/model", () => ({
|
|
getSettings: jest.fn().mockReturnValue({
|
|
defaultSaveFolder: "test-folder",
|
|
defaultConversationTag: "copilot-conversation",
|
|
defaultConversationNoteName: "{$date}_{$time}__{$topic}",
|
|
}),
|
|
}));
|
|
jest.mock("@/utils", () => ({
|
|
ensureFolderExists: jest.fn(async () => {}),
|
|
formatDateTime: jest.fn(() => ({
|
|
fileName: "20260101_120000",
|
|
display: "2026/01/01 12:00:00",
|
|
})),
|
|
getUtf8ByteLength: jest.fn((s: string) => new TextEncoder().encode(s).length),
|
|
truncateToByteLimit: jest.fn((s: string, n: number) => {
|
|
const bytes = new TextEncoder().encode(s);
|
|
if (bytes.length <= n) return s;
|
|
return new TextDecoder().decode(bytes.slice(0, n));
|
|
}),
|
|
}));
|
|
jest.mock("@/utils/vaultAdapterUtils", () => ({
|
|
isInVaultCache: jest.fn(() => false),
|
|
listMarkdownFiles: jest.fn().mockResolvedValue([]),
|
|
readFrontmatterViaAdapter: jest.fn().mockResolvedValue(null),
|
|
}));
|
|
|
|
interface FakeFile {
|
|
path: string;
|
|
basename: string;
|
|
contents?: string;
|
|
}
|
|
|
|
/**
|
|
* Build a minimal in-memory `app` mock that records files written via
|
|
* `vault.create` / `vault.adapter.write` so a round-trip save/load test can
|
|
* read what the previous step wrote without wiring real disk I/O.
|
|
*/
|
|
function makeApp() {
|
|
const files = new Map<string, FakeFile>();
|
|
return {
|
|
files,
|
|
vault: {
|
|
getAbstractFileByPath: jest.fn((path: string) => files.get(path) ?? null),
|
|
create: jest.fn(async (path: string, content: string) => {
|
|
const basename = path.split("/").pop()!.replace(/\.md$/, "");
|
|
const file = { path, basename, contents: content };
|
|
files.set(path, file);
|
|
return file;
|
|
}),
|
|
modify: jest.fn(async (file: FakeFile, content: string) => {
|
|
file.contents = content;
|
|
}),
|
|
read: jest.fn(async (file: FakeFile) => file.contents ?? ""),
|
|
delete: jest.fn(async (file: FakeFile) => {
|
|
files.delete(file.path);
|
|
}),
|
|
adapter: {
|
|
exists: jest.fn(async (path: string) => files.has(path)),
|
|
read: jest.fn(async (path: string) => files.get(path)?.contents ?? ""),
|
|
write: jest.fn(async (path: string, content: string) => {
|
|
const existing = files.get(path);
|
|
if (existing) {
|
|
existing.contents = content;
|
|
} else {
|
|
const basename = path.split("/").pop()!.replace(/\.md$/, "");
|
|
files.set(path, { path, basename, contents: content });
|
|
}
|
|
}),
|
|
remove: jest.fn(async (path: string) => {
|
|
files.delete(path);
|
|
}),
|
|
},
|
|
},
|
|
metadataCache: {
|
|
getFileCache: jest.fn(() => undefined),
|
|
},
|
|
fileManager: {
|
|
processFrontMatter: jest.fn(),
|
|
},
|
|
};
|
|
}
|
|
|
|
function makeMessage(sender: string, message: string, epoch = 1735732800000): AgentChatMessage {
|
|
return {
|
|
id: `msg-${epoch}`,
|
|
sender,
|
|
message,
|
|
isVisible: true,
|
|
timestamp: { epoch, display: "2026/01/01 12:00:00", fileName: "20260101_120000" },
|
|
};
|
|
}
|
|
|
|
describe("AgentChatPersistenceManager", () => {
|
|
let app: ReturnType<typeof makeApp>;
|
|
let manager: AgentChatPersistenceManager;
|
|
|
|
beforeEach(() => {
|
|
app = makeApp();
|
|
manager = new AgentChatPersistenceManager(app as unknown as App);
|
|
});
|
|
|
|
it("round-trips messages, backendId, and label", async () => {
|
|
const messages = [makeMessage(USER_SENDER, "hello world"), makeMessage(AI_SENDER, "hi back")];
|
|
const saved = await manager.saveSession(messages, "claude-code", { label: "My chat" });
|
|
expect(saved).not.toBeNull();
|
|
|
|
const file = app.files.get(saved!.path)!;
|
|
const loaded = await manager.loadFile(file as unknown as TFile);
|
|
expect(loaded.backendId).toBe("claude-code");
|
|
expect(loaded.label).toBe("My chat");
|
|
expect(loaded.messages).toHaveLength(2);
|
|
expect(loaded.messages[0].sender).toBe(USER_SENDER);
|
|
expect(loaded.messages[0].message).toBe("hello world");
|
|
expect(loaded.messages[1].sender).toBe(AI_SENDER);
|
|
expect(loaded.messages[1].message).toBe("hi back");
|
|
});
|
|
|
|
it("escapes and round-trips a label containing quotes and backslashes", async () => {
|
|
const tricky = 'has "quotes" and \\backslashes\\';
|
|
const messages = [makeMessage(USER_SENDER, "hi")];
|
|
const saved = await manager.saveSession(messages, "opencode", { label: tricky });
|
|
expect(saved).not.toBeNull();
|
|
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
|
|
expect(loaded.label).toBe(tricky);
|
|
});
|
|
|
|
it("strips control characters from labels so they can't break frontmatter", async () => {
|
|
const messages = [makeMessage(USER_SENDER, "hi")];
|
|
const saved = await manager.saveSession(messages, "opencode", {
|
|
label: "first\nsecond\rthird",
|
|
});
|
|
const raw = app.files.get(saved!.path)!.contents!;
|
|
// The label line must remain a single key:value entry.
|
|
const labelLines = raw.split("\n").filter((l) => l.startsWith("agentLabel:"));
|
|
expect(labelLines).toHaveLength(1);
|
|
const loaded = await manager.loadFile(app.files.get(saved!.path) as unknown as TFile);
|
|
expect(loaded.label).toBe("first second third");
|
|
});
|
|
|
|
it("throws on missing backendId instead of silently defaulting", async () => {
|
|
const path = "test-folder/agent__broken.md";
|
|
await app.vault.adapter.write(
|
|
path,
|
|
["---", "epoch: 1735732800000", "mode: agent", "---", "", "**user**: hi"].join("\n")
|
|
);
|
|
await expect(
|
|
manager.loadFile({ path, basename: "agent__broken" } as unknown as TFile)
|
|
).rejects.toThrow(/Missing backendId/);
|
|
});
|
|
|
|
it("assigns deterministic ids that depend only on message timestamp", async () => {
|
|
const messages = [
|
|
makeMessage(USER_SENDER, "first", 1700000000000),
|
|
makeMessage(AI_SENDER, "second", 1700000000001),
|
|
];
|
|
const saved = await manager.saveSession(messages, "claude-code");
|
|
const file = app.files.get(saved!.path)!;
|
|
|
|
const loadedA = await manager.loadFile(file as unknown as TFile);
|
|
const loadedB = await manager.loadFile(file as unknown as TFile);
|
|
// The key contract: same file + same content → same ids across reloads.
|
|
expect(loadedA.messages.map((m) => m.id)).toEqual(loadedB.messages.map((m) => m.id));
|
|
expect(loadedA.messages[0].id.startsWith("loaded-0-")).toBe(true);
|
|
});
|
|
|
|
it("returns null when given zero messages instead of writing an empty file", async () => {
|
|
const result = await manager.saveSession([], "opencode");
|
|
expect(result).toBeNull();
|
|
expect(app.files.size).toBe(0);
|
|
});
|
|
});
|