diff --git a/src/app-server/services/tool-inventory.ts b/src/app-server/services/tool-inventory.ts index 5c514d0b..b84f9106 100644 --- a/src/app-server/services/tool-inventory.ts +++ b/src/app-server/services/tool-inventory.ts @@ -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; -} diff --git a/src/domain/markdown/frontmatter.ts b/src/domain/markdown/frontmatter.ts new file mode 100644 index 00000000..03e8b194 --- /dev/null +++ b/src/domain/markdown/frontmatter.ts @@ -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(", ")}]`; +} diff --git a/src/domain/server/diagnostics.ts b/src/domain/server/diagnostics.ts index d00a5a70..67849ab6 100644 --- a/src/domain/server/diagnostics.ts +++ b/src/domain/server/diagnostics.ts @@ -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; diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 473ddf1d..16bdfd7f 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -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())}`; } diff --git a/src/domain/vault/markdown-write-templates.ts b/src/domain/vault/markdown-write-templates.ts new file mode 100644 index 00000000..b896a628 --- /dev/null +++ b/src/domain/vault/markdown-write-templates.ts @@ -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; + 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"); +} diff --git a/src/features/chat/app-server/session-gateway.ts b/src/features/chat/app-server/session-gateway.ts index 83adbcf7..d9925d78 100644 --- a/src/features/chat/app-server/session-gateway.ts +++ b/src/features/chat/app-server/session-gateway.ts @@ -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; @@ -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; diff --git a/src/features/chat/application/web-clipping/web-clip.ts b/src/features/chat/application/web-clipping/web-clip.ts index 428a5115..0a650608 100644 --- a/src/features/chat/application/web-clipping/web-clip.ts +++ b/src/features/chat/application/web-clipping/web-clip.ts @@ -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 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"); -} diff --git a/src/features/chat/domain/message-stream/format/agent-message-preview.ts b/src/features/chat/domain/message-stream/format/agent-message-preview.ts new file mode 100644 index 00000000..2db265d2 --- /dev/null +++ b/src/features/chat/domain/message-stream/format/agent-message-preview.ts @@ -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); +} diff --git a/src/features/chat/domain/message-stream/semantics/active-turn.ts b/src/features/chat/domain/message-stream/semantics/active-turn.ts index 81f9c1e1..f5430b88 100644 --- a/src/features/chat/domain/message-stream/semantics/active-turn.ts +++ b/src/features/chat/domain/message-stream/semantics/active-turn.ts @@ -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"; } diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 16de53c0..c1d2548e 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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, diff --git a/src/features/chat/presentation/message-stream/detail-view.ts b/src/features/chat/presentation/message-stream/detail-view.ts index c62bb409..9ac0575c 100644 --- a/src/features/chat/presentation/message-stream/detail-view.ts +++ b/src/features/chat/presentation/message-stream/detail-view.ts @@ -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"); } diff --git a/src/features/threads/workflows/archive-export.ts b/src/features/threads/workflows/archive-export.ts index 3b1f1867..e2317623 100644 --- a/src/features/threads/workflows/archive-export.ts +++ b/src/features/threads/workflows/archive-export.ts @@ -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 { 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.", + ); } diff --git a/src/settings/archived-section.tsx b/src/settings/archived-section.tsx index a383eef7..70db498b 100644 --- a/src/settings/archived-section.tsx +++ b/src/settings/archived-section.tsx @@ -39,7 +39,7 @@ function ArchiveExportSettings({ state }: { state: ArchivedThreadSectionState }) }} /> - + { 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; }) { + 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()), diff --git a/tests/features/chat/application/web-clipping/web-clip.test.ts b/tests/features/chat/application/web-clipping/web-clip.test.ts index 4e8484b6..f8d61886 100644 --- a/tests/features/chat/application/web-clipping/web-clip.test.ts +++ b/tests/features/chat/application/web-clipping/web-clip.test.ts @@ -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"]); diff --git a/tests/features/threads/workflows/archive-export.test.ts b/tests/features/threads/workflows/archive-export.test.ts index 2718db54..ea96973e 100644 --- a/tests/features/threads/workflows/archive-export.test.ts +++ b/tests/features/threads/workflows/archive-export.test.ts @@ -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"]'); });