Align duplicated markdown and app-server helpers

This commit is contained in:
murashit 2026-07-07 21:27:46 +09:00
parent 1946ecffe0
commit a9e68e159d
16 changed files with 206 additions and 120 deletions

View file

@ -1,5 +1,10 @@
import type { SkillMetadata } from "../../domain/catalog/metadata";
import { type DiagnosticProbeResult, diagnosticProbeError, diagnosticProbeOk } from "../../domain/server/diagnostics";
import {
type DiagnosticProbeResult,
diagnosticProbeError,
diagnosticProbeOk,
shortDiagnosticErrorMessage,
} from "../../domain/server/diagnostics";
import {
type McpServerDiagnostic,
type McpServerStatusSummary,
@ -92,7 +97,7 @@ async function readPlugins(
return {
items: null,
marketplaceErrors: [],
error: shortErrorMessage(error),
error: shortDiagnosticErrorMessage(error),
probe: diagnosticProbeError("plugins", error, checkedAt),
};
}
@ -118,7 +123,7 @@ async function readMcpServers(
} catch (error) {
return {
items: null,
error: shortErrorMessage(error),
error: shortDiagnosticErrorMessage(error),
probe: diagnosticProbeError("mcpServers", error, checkedAt),
};
}
@ -142,12 +147,6 @@ async function readSkills(
probe: diagnosticProbeOk("skills", `${String(catalog.totalCount)} skills`, checkedAt),
};
} catch (error) {
return { items: null, error: shortErrorMessage(error), probe: diagnosticProbeError("skills", error, checkedAt) };
return { items: null, error: shortDiagnosticErrorMessage(error), probe: diagnosticProbeError("skills", error, checkedAt) };
}
}
function shortErrorMessage(error: unknown, maxLength = 160): string {
const message = error instanceof Error ? error.message : String(error);
const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed.";
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;
}

View file

@ -0,0 +1,7 @@
export function yamlFrontmatterString(value: string): string {
return JSON.stringify(value);
}
export function yamlFrontmatterInlineList(values: readonly string[]): string {
return `[${values.map(yamlFrontmatterString).join(", ")}]`;
}

View file

@ -88,7 +88,7 @@ export function diagnosticProbeError(id: DiagnosticProbeId, error: unknown, chec
return {
id,
status: "failed",
message: shortErrorMessage(error),
message: shortDiagnosticErrorMessage(error),
summary: null,
checkedAt,
};
@ -125,7 +125,7 @@ export function upsertMcpServerStatusDiagnostics(diagnostics: Diagnostics, serve
return next;
}
function shortErrorMessage(error: unknown, maxLength = 160): string {
export function shortDiagnosticErrorMessage(error: unknown, maxLength = 160): string {
const message = error instanceof Error ? error.message : String(error);
const compact = message.replace(/\s+/g, " ").trim() || "Codex app-server request failed.";
return compact.length > maxLength ? `${compact.slice(0, maxLength - 3)}...` : compact;

View file

@ -1,3 +1,4 @@
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter";
import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs";
import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths";
import type { Thread } from "./model";
@ -33,9 +34,9 @@ export function archivedThreadMarkdown(
const tags = normalizedArchiveTags(settings?.archiveExportTags ?? "");
const lines = [
"---",
`title: ${yamlString(title)}`,
`thread_id: ${yamlString(thread.id)}`,
`created: ${yamlString(formatDate(exportedAt))}`,
`title: ${yamlFrontmatterString(title)}`,
`thread_id: ${yamlFrontmatterString(thread.id)}`,
`created: ${yamlFrontmatterString(formatDate(exportedAt))}`,
...frontmatterTagsLines(tags),
"---",
"",
@ -131,7 +132,7 @@ function formatUnixTimestamp(unixSeconds: number | null): string | null {
}
function frontmatterTagsLines(tags: string[]): string[] {
return tags.length > 0 ? [`tags: [${tags.map(yamlString).join(", ")}]`] : [];
return tags.length > 0 ? [`tags: ${yamlFrontmatterInlineList(tags)}`] : [];
}
function stripLeadingHashes(value: string): string {
@ -199,10 +200,6 @@ function isInsideInlineCode(line: string, offset: number): boolean {
return inCode;
}
function yamlString(value: string): string {
return JSON.stringify(value);
}
function formatDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}

View file

@ -0,0 +1,49 @@
import { sanitizeVaultPathSegment, vaultRelativeFolderPath } from "./write-paths";
interface VaultFolderMessages {
emptyPathMessage: string;
absolutePathMessage: string;
relativeSegmentMessage: string;
}
const TEMPLATE_TOKEN_PATTERN = /{{\s*([A-Za-z][A-Za-z0-9]*)\s*}}/g;
export function vaultMarkdownTemplateDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
export function vaultMarkdownTemplateTime(date: Date): string {
return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
}
function expandVaultMarkdownTemplate(template: string, context: object): string {
const values = context as Record<string, string | undefined>;
return template.replace(TEMPLATE_TOKEN_PATTERN, (match, key: string) => values[key] ?? match);
}
export function vaultMarkdownFolderPath(value: string, normalizePath: (path: string) => string, messages: VaultFolderMessages): string {
return vaultRelativeFolderPath(value.trim().replaceAll("\\", "/"), {
normalizePath,
emptyPathMessage: messages.emptyPathMessage,
absolutePathMessage: messages.absolutePathMessage,
relativeSegmentMessage: messages.relativeSegmentMessage,
});
}
export function vaultMarkdownFilenameFromTemplate(
template: string,
context: object,
normalizePath: (path: string) => string,
emptyFilenameMessage: string,
): string {
const expanded = expandVaultMarkdownTemplate(template, context)
.trim()
.replace(/[\\/]+/g, "-");
const filename = normalizePath(sanitizeVaultPathSegment(expanded));
if (!filename || filename === "." || filename === "..") throw new Error(emptyFilenameMessage);
return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`;
}
function pad2(value: number): string {
return value.toString().padStart(2, "0");
}

View file

@ -1,11 +1,13 @@
import type { AppServerClient } from "../../../app-server/connection/client";
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
import type { AppServerClientAccess, AppServerClientAccessOptions } from "../../../app-server/connection/client-access";
import { withShortLivedAppServerClient } from "../../../app-server/connection/short-lived-client";
import type { CodexInput } from "../../../domain/chat/input";
import type { ComposerInputSnapshot } from "../application/composer/input-snapshot";
import { createThreadReferenceResolver, type ThreadReferenceResolver } from "./thread-reference-resolver";
import { type ChatSessionTransports, createChatSessionTransports } from "./transports/session-transports";
export interface ChatAppServerGatewayHost {
codexPath(): string;
vaultPath: string;
currentClient(): AppServerClient | null;
connectedClient(): Promise<AppServerClient | null>;
@ -26,7 +28,7 @@ export interface ChatAppServerGateway extends ChatSessionTransports {
export function createChatAppServerGateway(host: ChatAppServerGatewayHost): ChatAppServerGateway {
return {
clientAccess: createCurrentClientAccess(() => host.currentClient()),
clientAccess: createCurrentClientAccess(host),
...createChatSessionTransports(host),
connectionAvailable: () => host.currentClient() !== null,
readFileBase64: (path, options) => readCurrentClientFileBase64(host, path, options),
@ -44,13 +46,17 @@ export function createChatAppServerGateway(host: ChatAppServerGatewayHost): Chat
};
}
function createCurrentClientAccess(currentClient: () => AppServerClient | null): AppServerClientAccess {
function createCurrentClientAccess(host: ChatAppServerGatewayHost): AppServerClientAccess {
return {
withClient: async (operation) => {
const client = currentClient();
withClient: async (operation, options: AppServerClientAccessOptions = {}) => {
if (options.serverRequests?.kind === "reject") {
return withShortLivedAppServerClient(host.codexPath(), host.vaultPath, operation, options);
}
const client = host.currentClient();
if (!client) throw new Error("Codex app-server is not connected.");
const result = await operation(client);
if (currentClient() !== client) {
if (host.currentClient() !== client) {
throw new Error("Codex app-server connection changed while running the operation.");
}
return result;

View file

@ -1,9 +1,15 @@
import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../../../../domain/markdown/frontmatter";
import {
vaultMarkdownFilenameFromTemplate,
vaultMarkdownFolderPath,
vaultMarkdownTemplateDate,
vaultMarkdownTemplateTime,
} from "../../../../domain/vault/markdown-write-templates";
import {
ensureVaultFolder,
sanitizeVaultPathSegment,
uniqueVaultPath,
type VaultMarkdownDestination,
vaultRelativeFolderPath,
} from "../../../../domain/vault/write-paths";
export interface WebClipSettings {
@ -59,9 +65,9 @@ export function webClipMarkdown(page: WebClipPage, settings: Pick<WebClipSetting
const tags = normalizedClipTags(settings.clipTags ?? "");
const frontmatter = [
"---",
`title: ${yamlString(normalizedTitle)}`,
`url: ${yamlString(page.url)}`,
`created: ${yamlString(now.toISOString())}`,
`title: ${yamlFrontmatterString(normalizedTitle)}`,
`url: ${yamlFrontmatterString(page.url)}`,
`created: ${yamlFrontmatterString(now.toISOString())}`,
...frontmatterTagsLines(tags),
"---",
"",
@ -72,8 +78,8 @@ export function webClipMarkdown(page: WebClipPage, settings: Pick<WebClipSetting
function templateContext(page: WebClipPage, now: Date): TemplateContext {
const title = sanitizeVaultPathSegment(normalizedDisplayTitle(page.title));
return {
date: formatDate(now),
time: formatTime(now),
date: vaultMarkdownTemplateDate(now),
time: vaultMarkdownTemplateTime(now),
title,
site: sanitizeVaultPathSegment(page.site?.trim() || ""),
domain: sanitizeVaultPathSegment(page.domain?.trim() || hostnameFromUrl(page.url) || ""),
@ -84,13 +90,8 @@ function normalizedDisplayTitle(title: string): string {
return title.replace(/\s+/g, " ").trim() || DEFAULT_CLIP_TITLE;
}
function expandTemplate(template: string, context: TemplateContext): string {
return template.replace(/{{\s*(date|time|title|site|domain)\s*}}/g, (_match, key: keyof TemplateContext) => context[key]);
}
function folderPath(value: string, normalizePath: (path: string) => string): string {
return vaultRelativeFolderPath(value, {
normalizePath,
return vaultMarkdownFolderPath(value, normalizePath, {
emptyPathMessage: "Clip folder produced an empty path.",
absolutePathMessage: "Clip folder must be relative to the vault.",
relativeSegmentMessage: "Clip folder cannot contain relative path segments.",
@ -98,12 +99,7 @@ function folderPath(value: string, normalizePath: (path: string) => string): str
}
function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
const expanded = expandTemplate(template, context)
.trim()
.replace(/[\\/]+/g, "-");
const filename = normalizePath(sanitizeVaultPathSegment(expanded));
if (!filename || filename === "." || filename === "..") throw new Error("Clip filename template produced an empty filename.");
return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`;
return vaultMarkdownFilenameFromTemplate(template, context, normalizePath, "Clip filename template produced an empty filename.");
}
function normalizedClipTags(input: string): string[] {
@ -123,11 +119,7 @@ function normalizedClipTags(input: string): string[] {
}
function frontmatterTagsLines(tags: string[]): string[] {
return tags.length > 0 ? [`tags: [${tags.map(yamlString).join(", ")}]`] : [];
}
function yamlString(value: string): string {
return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
return tags.length > 0 ? [`tags: ${yamlFrontmatterInlineList(tags)}`] : [];
}
function hostnameFromUrl(url: string): string {
@ -137,15 +129,3 @@ function hostnameFromUrl(url: string): string {
return "";
}
}
function formatDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function formatTime(date: Date): string {
return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
}
function pad2(value: number): string {
return value.toString().padStart(2, "0");
}

View file

@ -0,0 +1,11 @@
import { truncate } from "../../../../../domain/display/text-preview";
export function agentMessagePreview(message: string | null, maxLength: number): string | null {
if (!message) return null;
const firstLine = message
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) return null;
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
}

View file

@ -1,4 +1,4 @@
import { truncate } from "../../../../../domain/display/text-preview";
import { agentMessagePreview } from "../format/agent-message-preview";
import type {
AgentRunSummary,
AgentRunSummaryAgent,
@ -118,16 +118,6 @@ function activeAgentRunSummaryAnchorId(items: readonly MessageStreamSemanticClas
return firstActiveAgent?.item.id ?? null;
}
function agentMessagePreview(message: string | null, maxLength: number): string | null {
if (!message) return null;
const firstLine = message
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) return null;
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
}
function agentRunState(agent: AgentStateSummary): AgentRunState {
return agent.executionState ?? "running";
}

View file

@ -81,6 +81,7 @@ export function createChatPanelSessionGraph(host: ChatPanelSessionGraphHost): Ch
return currentClient();
};
const appServer = createChatAppServerGateway({
codexPath: () => environment.plugin.settingsRef.settings.codexPath(),
vaultPath: environment.plugin.settingsRef.vaultPath,
currentClient,
connectedClient,

View file

@ -1,6 +1,7 @@
import { truncate } from "../../../../domain/display/text-preview";
import { shortThreadId } from "../../../../domain/threads/id";
import { pathRelativeToRoot } from "../../../../domain/vault/paths";
import { agentMessagePreview } from "../../domain/message-stream/format/agent-message-preview";
import type {
AgentMessageStreamItem,
ApprovalResultMessageStreamItem,
@ -449,16 +450,6 @@ function agentStatusLabel(status: string, message: string | null): string {
return preview ? `${status}: ${preview}` : status;
}
function agentMessagePreview(message: string | null, maxLength: number): string | null {
if (!message) return null;
const firstLine = message
.split(/\r?\n/)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) return null;
return truncate(firstLine.replace(/\s+/g, " "), maxLength);
}
function isLongAgentMessage(message: string): boolean {
return message.length > AGENT_ROW_MESSAGE_PREVIEW_LIMIT || message.includes("\n");
}

View file

@ -1,12 +1,17 @@
import { type ArchiveExportSettings, type ArchiveThreadInput, archivedThreadMarkdown } from "../../../domain/threads/archive-markdown";
import { shortThreadId } from "../../../domain/threads/id";
import { threadArchiveTitle } from "../../../domain/threads/title";
import {
vaultMarkdownFilenameFromTemplate,
vaultMarkdownFolderPath,
vaultMarkdownTemplateDate,
vaultMarkdownTemplateTime,
} from "../../../domain/vault/markdown-write-templates";
import {
ensureVaultFolder,
sanitizeVaultPathSegment,
uniqueVaultPath,
type VaultMarkdownDestination,
vaultRelativeFolderPath,
} from "../../../domain/vault/write-paths";
export interface ArchiveExportResult {
@ -31,7 +36,7 @@ export async function exportArchivedThreadMarkdown(
): Promise<ArchiveExportResult> {
const context = templateContext(thread, now);
const normalizePath = (path: string): string => destination.normalizePath(path);
const folder = folderPathFromTemplate(settings.archiveExportFolderTemplate, context, normalizePath);
const folder = folderPath(settings.archiveExportFolderTemplate, normalizePath);
const filename = filenameFromTemplate(settings.archiveExportFilenameTemplate, context, normalizePath);
await ensureVaultFolder(destination, folder);
@ -43,47 +48,27 @@ export async function exportArchivedThreadMarkdown(
function templateContext(thread: ArchiveThreadInput, now: Date): TemplateContext {
const title = sanitizeVaultPathSegment(threadArchiveTitle(thread));
return {
date: formatDate(now),
time: formatTime(now),
date: vaultMarkdownTemplateDate(now),
time: vaultMarkdownTemplateTime(now),
title,
id: sanitizeVaultPathSegment(thread.id),
shortId: sanitizeVaultPathSegment(shortThreadId(thread.id)),
};
}
function expandTemplate(template: string, context: TemplateContext): string {
return template.replace(/{{\s*(date|time|title|id|shortId)\s*}}/g, (_match, key: keyof TemplateContext) => context[key]);
}
function folderPathFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
const expanded = expandTemplate(template, context).trim().replaceAll("\\", "/");
return vaultRelativeFolderPath(expanded, {
normalizePath,
emptyPathMessage: "Archive export folder template produced an empty path.",
function folderPath(value: string, normalizePath: (path: string) => string): string {
return vaultMarkdownFolderPath(value, normalizePath, {
emptyPathMessage: "Archive export folder produced an empty path.",
absolutePathMessage: "Archive export folder must be relative to the vault.",
relativeSegmentMessage: "Archive export folder cannot contain relative path segments.",
});
}
function filenameFromTemplate(template: string, context: TemplateContext, normalizePath: (path: string) => string): string {
const expanded = expandTemplate(template, context)
.trim()
.replace(/[\\/]+/g, "-");
const filename = normalizePath(sanitizeVaultPathSegment(expanded));
if (!filename || filename === "." || filename === "..") {
throw new Error("Archive export filename template produced an empty filename.");
}
return filename.toLowerCase().endsWith(".md") ? filename : `${filename}.md`;
}
function formatDate(date: Date): string {
return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function formatTime(date: Date): string {
return `${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`;
}
function pad2(value: number): string {
return value.toString().padStart(2, "0");
return vaultMarkdownFilenameFromTemplate(
template,
context,
normalizePath,
"Archive export filename template produced an empty filename.",
);
}

View file

@ -39,7 +39,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState })
}}
/>
</SettingRow>
<SettingRow name="Saved note folder" desc="Vault-relative folder template for archived thread notes.">
<SettingRow name="Saved note folder" desc="Vault-relative folder for archived thread notes.">
<ObsidianCommitTextInput
placeholder={DEFAULT_ARCHIVE_EXPORT_FOLDER_TEMPLATE}
value={state.exportFolderTemplate}

View file

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import type { AppServerClient, ClientResponseByMethod } from "../../../../src/app-server/connection/client";
import * as shortLivedClient from "../../../../src/app-server/connection/short-lived-client";
import type { ThreadRecord } from "../../../../src/app-server/protocol/thread";
import type { TurnItem, TurnRecord } from "../../../../src/app-server/protocol/turn";
import type { CodexInput } from "../../../../src/domain/chat/input";
@ -342,16 +343,68 @@ describe("chat app-server transports", () => {
expect(result?.referencedThread).toMatchObject({ title: "Other", includedTurns: 1, turnLimit: 20 });
expect(setStatus).toHaveBeenCalledWith("Referencing 019abcde (1/20 turns).");
});
it("uses a short-lived client for clientAccess operations that reject server requests", async () => {
const currentRequest = vi.fn();
const currentClient = { request: currentRequest } as unknown as AppServerClient;
const shortClient = { request: vi.fn().mockResolvedValue("short-lived") } as unknown as AppServerClient;
const withShortLived = vi
.spyOn(shortLivedClient, "withShortLivedAppServerClient")
.mockImplementation(async (_codexPath, _cwd, operation) => {
return operation(shortClient);
});
const gateway = createTestGateway({
codexPath: "/usr/local/bin/codex",
currentClient: () => currentClient,
});
await expect(
gateway.clientAccess.withClient((client) => client.request("thread/list", {}), {
serverRequests: { kind: "reject", message: "Background operation cannot answer server requests." },
}),
).resolves.toBe("short-lived");
expect(withShortLived).toHaveBeenCalledWith("/usr/local/bin/codex", "/vault", expect.any(Function), {
serverRequests: { kind: "reject", message: "Background operation cannot answer server requests." },
});
expect(currentRequest).not.toHaveBeenCalled();
withShortLived.mockRestore();
});
it("reads the current Codex command when creating short-lived clientAccess clients", async () => {
let codexPath = "/first/codex";
const shortClient = { request: vi.fn().mockResolvedValue("short-lived") } as unknown as AppServerClient;
const withShortLived = vi
.spyOn(shortLivedClient, "withShortLivedAppServerClient")
.mockImplementation(async (_codexPath, _cwd, operation) => {
return operation(shortClient);
});
const gateway = createTestGateway({
codexPath: () => codexPath,
currentClient: () => ({ request: vi.fn() }) as unknown as AppServerClient,
});
codexPath = "/second/codex";
await gateway.clientAccess.withClient((client) => client.request("thread/list", {}), {
serverRequests: { kind: "reject", message: "Background operation cannot answer server requests." },
});
expect(withShortLived).toHaveBeenCalledWith("/second/codex", "/vault", expect.any(Function), expect.any(Object));
withShortLived.mockRestore();
});
});
type AppServerThreadResumeResponse = ClientResponseByMethod["thread/resume"];
function createTestGateway(options: {
codexPath?: string | (() => string);
vaultPath?: string;
currentClient: () => AppServerClient | null;
connectedClient?: () => Promise<AppServerClient | null>;
}) {
const codexPath = options.codexPath;
return createChatAppServerGateway({
codexPath: typeof codexPath === "function" ? codexPath : () => codexPath ?? "codex",
vaultPath: options.vaultPath ?? "/vault",
currentClient: options.currentClient,
connectedClient: options.connectedClient ?? (async () => options.currentClient()),

View file

@ -36,6 +36,23 @@ describe("web clipping", () => {
);
});
it("escapes frontmatter strings with YAML-safe JSON string literals", () => {
const output = webClipMarkdown(
{
url: "https://example.com/post?title=line\nbreak",
title: "Example Post",
content: "Body",
},
{ clipTags: "web\nclip, tab\tvalue" },
"Example\nPost",
new Date("2026-07-05T03:04:05.000Z"),
);
expect(output).toContain('title: "Example Post"');
expect(output).toContain('url: "https://example.com/post?title=line\\nbreak"');
expect(output).toContain('tags: ["web\\nclip", "tab\\tvalue"]');
});
it("saves clips under a unique path from the filename template", async () => {
const destination = memoryDestination(["Codex Clippings/Example Site - Example-Post.md"]);

View file

@ -5,8 +5,8 @@ import type { ThreadTranscriptEntry } from "../../../../src/domain/threads/trans
import { type ArchiveExportDestination, exportArchivedThreadMarkdown } from "../../../../src/features/threads/workflows/archive-export";
describe("archive export workflow", () => {
it("expands templates, sanitizes paths, creates folders, and preserves existing files", async () => {
const destination = new MemoryDestination(["Codex Archives/2026-05-18/My-Thread- abcdef12.md"]);
it("uses a fixed folder path and expands the filename template", async () => {
const destination = new MemoryDestination(["Codex Archives/{{date}}/My-Thread- abcdef12.md"]);
const result = await exportArchivedThreadMarkdown(
thread({ id: "abcdef12-9999", name: "My/Thread?" }),
@ -19,9 +19,9 @@ describe("archive export workflow", () => {
new Date(2026, 4, 18, 9, 8, 7),
);
expect(result.path).toBe("Codex Archives/2026-05-18/My-Thread- abcdef12 2.md");
expect(result.path).toBe("Codex Archives/{{date}}/My-Thread- abcdef12 2.md");
expect(destination.folders).toContain("Codex Archives");
expect(destination.folders).toContain("Codex Archives/2026-05-18");
expect(destination.folders).toContain("Codex Archives/{{date}}");
expect(destination.files.get(result.path)).toContain('thread_id: "abcdef12-9999"');
expect(destination.files.get(result.path)).toContain('tags: ["codex", "archive"]');
});