mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
|
|
import { type ArchiveMarkdownOptions, archivedThreadMarkdown } from "../../../src/domain/threads/archive-markdown";
|
|
import type { Thread } from "../../../src/domain/threads/model";
|
|
import type { ThreadTranscriptEntry } from "../../../src/domain/threads/transcript";
|
|
|
|
describe("thread archive export", () => {
|
|
it("writes frontmatter and readable user/codex turns with turn timestamps", () => {
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
id: "thread-12345678",
|
|
name: "Exported thread",
|
|
archived: false,
|
|
transcriptEntries: [
|
|
transcriptEntry("user", "依頼です", timestamp(2026, 5, 18, 9, 8)),
|
|
transcriptEntry("assistant", "回答です", timestamp(2026, 5, 18, 9, 11)),
|
|
transcriptEntry("plan", "<proposed_plan>\n計画\n</proposed_plan>", timestamp(2026, 5, 18, 9, 11)),
|
|
],
|
|
}),
|
|
new Date(2026, 4, 18, 9, 8, 7),
|
|
);
|
|
|
|
expect(output).toBe(
|
|
[
|
|
"---",
|
|
'title: "Exported thread"',
|
|
'thread_id: "thread-12345678"',
|
|
'created: "2026-05-18"',
|
|
"---",
|
|
"",
|
|
"# Exported thread",
|
|
"",
|
|
"## User - 2026-05-18 09:08",
|
|
"",
|
|
"依頼です",
|
|
"",
|
|
"## Codex - 2026-05-18 09:11",
|
|
"",
|
|
"回答です",
|
|
"",
|
|
"## Proposed plan - 2026-05-18 09:11",
|
|
"",
|
|
"<proposed_plan>",
|
|
"計画",
|
|
"</proposed_plan>",
|
|
"",
|
|
].join("\n"),
|
|
);
|
|
expect(output).not.toContain("npm test");
|
|
});
|
|
|
|
it("uses the shared archive title placeholder instead of leaking thread ids", () => {
|
|
const output = exportedMarkdown(thread({ name: null, preview: "" }), new Date(2026, 4, 18));
|
|
|
|
expect(output).toContain('title: "Untitled thread"');
|
|
expect(output).toContain("# Untitled thread");
|
|
expect(output).not.toContain("# 019e0182-cb70-7a72-ab48-8bc9d0b0d781");
|
|
});
|
|
|
|
it("falls back when turn timestamps are missing and uses start time for incomplete agent output", () => {
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
transcriptEntries: [
|
|
transcriptEntry("user", "古い依頼", null),
|
|
transcriptEntry("assistant", "古い回答", null),
|
|
transcriptEntry("user", "途中の依頼", timestamp(2026, 5, 18, 10, 1)),
|
|
transcriptEntry("assistant", "途中の回答", timestamp(2026, 5, 18, 10, 1)),
|
|
],
|
|
}),
|
|
new Date(2026, 4, 18),
|
|
);
|
|
|
|
expect(output).toContain("## User\n\n古い依頼");
|
|
expect(output).toContain("## Codex\n\n古い回答");
|
|
expect(output).toContain("## User - 2026-05-18 10:01\n\n途中の依頼");
|
|
expect(output).toContain("## Codex - 2026-05-18 10:01\n\n途中の回答");
|
|
});
|
|
|
|
it("orders transcript entries chronologically while preserving source order for equal timestamps", () => {
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
transcriptEntries: [
|
|
transcriptEntry("assistant", "newest", timestamp(2026, 5, 18, 10, 0)),
|
|
transcriptEntry("assistant", "equal-first", timestamp(2026, 5, 18, 9, 0)),
|
|
transcriptEntry("user", "oldest", timestamp(2026, 5, 18, 8, 0)),
|
|
transcriptEntry("plan", "equal-second", timestamp(2026, 5, 18, 9, 0)),
|
|
],
|
|
}),
|
|
new Date(2026, 4, 18),
|
|
);
|
|
|
|
const positions = ["oldest", "equal-first", "equal-second", "newest"].map((text) => output.indexOf(text));
|
|
expect(positions).toEqual([...positions].sort((a, b) => a - b));
|
|
});
|
|
|
|
it("exports only the thread history remaining after rollback", () => {
|
|
const rolledBackUserText = "rollbackされた依頼";
|
|
const rolledBackAssistantText = "rollbackされた回答";
|
|
const remainingEntries = [
|
|
transcriptEntry("user", "残る依頼", timestamp(2026, 5, 18, 9, 0)),
|
|
transcriptEntry("assistant", "残る回答", timestamp(2026, 5, 18, 9, 2)),
|
|
];
|
|
const rolledBackEntries = [
|
|
transcriptEntry("user", rolledBackUserText, timestamp(2026, 5, 18, 10, 0)),
|
|
transcriptEntry("assistant", rolledBackAssistantText, timestamp(2026, 5, 18, 10, 3)),
|
|
];
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
transcriptEntries: remainingEntries,
|
|
}),
|
|
new Date(2026, 4, 18),
|
|
);
|
|
|
|
expect(output).toContain("残る依頼");
|
|
expect(output).toContain("残る回答");
|
|
expect(rolledBackEntries).toHaveLength(2);
|
|
expect(output).not.toContain(rolledBackUserText);
|
|
expect(output).not.toContain(rolledBackAssistantText);
|
|
});
|
|
|
|
it("renders persisted v2 reference metadata without embedding its payload", () => {
|
|
const entry = transcriptEntry("user", "続きです", 1);
|
|
entry.referencedThread = {
|
|
threadId: "thread-ref",
|
|
title: "thread-r",
|
|
includedTurns: 2,
|
|
turnLimit: 20,
|
|
omittedTurns: 3,
|
|
truncated: true,
|
|
};
|
|
const output = exportedMarkdown(thread({ transcriptEntries: [entry] }), new Date(2026, 4, 18));
|
|
|
|
expect(output).toContain("続きです");
|
|
expect(output).toContain("> Referenced: thread-r (2/20 turns, truncated, thread-ref)");
|
|
});
|
|
|
|
it("renders persisted web and Obsidian context metadata", () => {
|
|
const entry = transcriptEntry("user", "https://example.com/article summarize", 1);
|
|
entry.contexts = [
|
|
{ kind: "web", truncated: true },
|
|
{ kind: "obsidian", truncated: false },
|
|
];
|
|
const output = exportedMarkdown(thread({ transcriptEntries: [entry] }), new Date(2026, 4, 18));
|
|
|
|
expect(output).toContain("> Context: Web page (truncated) (https://example.com/article)");
|
|
expect(output).toContain("> Context: Obsidian context");
|
|
});
|
|
|
|
it("keeps ordinary Codex thread links unchanged", () => {
|
|
const link = "[Other thread](codex://threads/019abcde-0000-7000-8000-000000000001)";
|
|
const output = exportedMarkdown(
|
|
thread({ transcriptEntries: [transcriptEntry("user", `${link}\n\n続きです`, 1)] }),
|
|
new Date(2026, 4, 18),
|
|
);
|
|
|
|
expect(output).toContain(link);
|
|
});
|
|
|
|
it("writes optional frontmatter tags from fixed comma-separated settings", () => {
|
|
const output = exportedMarkdown(thread({ name: "Tagged thread" }), new Date(2026, 4, 18), {
|
|
archiveExportTags: '#codex, "archive", codex, {{title}}',
|
|
});
|
|
|
|
expect(output).toContain('tags: ["codex", "archive", "{{title}}"]');
|
|
});
|
|
|
|
it("omits frontmatter tags when archive tags are empty", () => {
|
|
const output = exportedMarkdown(thread(), new Date(2026, 4, 18), { archiveExportTags: " , # , " });
|
|
|
|
expect(output).not.toContain("tags:");
|
|
});
|
|
|
|
it("normalizes archive tags without sorting or changing unmatched quotes", () => {
|
|
const output = exportedMarkdown(thread(), new Date(2026, 4, 18), {
|
|
archiveExportTags: ` "codex" , 'archive', #note/tag, codex, "unfinished `,
|
|
});
|
|
|
|
expect(output).toContain('tags: ["codex", "archive", "note/tag", "\\"unfinished"]');
|
|
});
|
|
|
|
it("normalizes exported markdown links for vault and external absolute paths", () => {
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
transcriptEntries: [
|
|
transcriptEntry(
|
|
"assistant",
|
|
[
|
|
"[Vault note](</Users/showhey/Vault/topics/My Note.md>)",
|
|
"[Vault note with parens](</Users/showhey/Vault/topics/My (Note).md>)",
|
|
"[Vault note with bare parens](/Users/showhey/Vault/topics/My_(Note).md)",
|
|
'[**Titled vault note**](/Users/showhey/Vault/topics/Titled.md "Reference")',
|
|
"[Vault source line](/Users/showhey/Vault/src/main.ts:12:4#L12)",
|
|
"[Vault config](/Users/showhey/Vault/vault-config/plugins/codex-panel/main.js)",
|
|
"[External file](/Users/showhey/Repos/project/README.md)",
|
|
"[External file with backticks](/Users/showhey/Repos/project/a\\`b``c.md)",
|
|
"[Relative](topics/Other.md)",
|
|
"[Website](https://example.com/docs)",
|
|
"",
|
|
"`[Code link](/Users/showhey/Repos/project/README.md)`",
|
|
"``[Double-fenced code link](/Users/showhey/Repos/project/README.md)``",
|
|
"```",
|
|
"[Code block link](/Users/showhey/Repos/project/README.md)",
|
|
"```",
|
|
"~~~",
|
|
"[Tilde code block link](/Users/showhey/Repos/project/README.md)",
|
|
"~~~",
|
|
].join("\n"),
|
|
1,
|
|
),
|
|
],
|
|
}),
|
|
new Date(2026, 4, 18),
|
|
{ vaultPath: "/Users/showhey/Vault", vaultConfigDir: "vault-config" },
|
|
);
|
|
|
|
expect(output).toContain(
|
|
[
|
|
"[Vault note](<topics/My Note.md>)",
|
|
"[Vault note with parens](<topics/My (Note).md>)",
|
|
"[Vault note with bare parens](topics/My_\\(Note\\).md)",
|
|
'[**Titled vault note**](topics/Titled.md "Reference")',
|
|
"[Vault source line](src/main.ts#L12)",
|
|
"Vault config (`/Users/showhey/Vault/vault-config/plugins/codex-panel/main.js`)",
|
|
"External file (`/Users/showhey/Repos/project/README.md`)",
|
|
"External file with backticks (```/Users/showhey/Repos/project/a`b``c.md```)",
|
|
"[Relative](topics/Other.md)",
|
|
"[Website](https://example.com/docs)",
|
|
"",
|
|
"`[Code link](/Users/showhey/Repos/project/README.md)`",
|
|
"``[Double-fenced code link](/Users/showhey/Repos/project/README.md)``",
|
|
"```",
|
|
"[Code block link](/Users/showhey/Repos/project/README.md)",
|
|
"```",
|
|
"~~~",
|
|
"[Tilde code block link](/Users/showhey/Repos/project/README.md)",
|
|
"~~~",
|
|
].join("\n"),
|
|
);
|
|
});
|
|
|
|
it("normalizes exported thread markdown links when vault path is provided", () => {
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
transcriptEntries: [
|
|
transcriptEntry(
|
|
"assistant",
|
|
"[Vault](</Users/showhey/Vault/topics/Alpha.md>)\n[External](/Users/showhey/Repos/project/README.md)",
|
|
1,
|
|
),
|
|
],
|
|
}),
|
|
new Date(2026, 4, 18),
|
|
{ vaultPath: "/Users/showhey/Vault" },
|
|
);
|
|
|
|
expect(output).toContain("[Vault](topics/Alpha.md)");
|
|
expect(output).toContain("External (`/Users/showhey/Repos/project/README.md`)");
|
|
});
|
|
|
|
it("preserves Obsidian syntax outside normalized links", () => {
|
|
const obsidianSyntax = [
|
|
"[[Wiki note]]",
|
|
"![[image.png]]",
|
|
"> [!note] Callout",
|
|
"> body",
|
|
"#tag/path",
|
|
"==highlight==",
|
|
"%% comment %%",
|
|
"- [x] task",
|
|
].join("\n");
|
|
const output = exportedMarkdown(
|
|
thread({ transcriptEntries: [transcriptEntry("assistant", `${obsidianSyntax}\n[Vault](/Users/showhey/Vault/Note.md)`, 1)] }),
|
|
new Date(2026, 4, 18),
|
|
{ vaultPath: "/Users/showhey/Vault" },
|
|
);
|
|
|
|
expect(output).toContain(obsidianSyntax);
|
|
expect(output).toContain("[Vault](Note.md)");
|
|
});
|
|
|
|
it("preserves escaped pipes in table link labels", () => {
|
|
const output = exportedMarkdown(
|
|
thread({
|
|
transcriptEntries: [
|
|
transcriptEntry(
|
|
"assistant",
|
|
"| Vault | External |\n| --- | --- |\n| [A\\|B](/Users/showhey/Vault/Note.md) | [C\\|D](/Outside/Note.md) |",
|
|
1,
|
|
),
|
|
],
|
|
}),
|
|
new Date(2026, 4, 18),
|
|
{ vaultPath: "/Users/showhey/Vault" },
|
|
);
|
|
|
|
expect(output).toContain("| [A\\|B](Note.md) | C\\|D (`/Outside/Note.md`) |");
|
|
});
|
|
});
|
|
|
|
function exportedMarkdown(
|
|
source: Thread & { transcriptEntries: ThreadTranscriptEntry[] },
|
|
now: Date,
|
|
settings: ArchiveMarkdownOptions = {},
|
|
): string {
|
|
return archivedThreadMarkdown(source, now, settings);
|
|
}
|
|
|
|
function thread(
|
|
overrides: Partial<Thread & { transcriptEntries: ThreadTranscriptEntry[] }> = {},
|
|
): Thread & { transcriptEntries: ThreadTranscriptEntry[] } {
|
|
return {
|
|
id: "019e0182-cb70-7a72-ab48-8bc9d0b0d781",
|
|
preview: "Preview",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
name: null,
|
|
archived: false,
|
|
canAcceptDirectInput: null,
|
|
provenance: { kind: "interactive" },
|
|
transcriptEntries: [],
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function timestamp(year: number, month: number, day: number, hour: number, minute: number): number {
|
|
return Math.floor(new Date(year, month - 1, day, hour, minute).getTime() / 1000);
|
|
}
|
|
|
|
function transcriptEntry(kind: ThreadTranscriptEntry["kind"], text: string, timestampValue: number | null): ThreadTranscriptEntry {
|
|
return { kind, text, timestamp: timestampValue };
|
|
}
|