feat(ai-chat): persistent history as source of truth — step durability + server export (#183)
The chat lived in inconsistent paradigms (in-memory stream + client export vs.
DB-as-context), which made export flaky and lost the assistant answer if the
process died mid-turn. Make the DB the single source of truth.
A. STEP-GRANULAR DURABILITY (server)
- ai_chat_messages gains a nullable `status` column (migration; NULL = legacy =
completed). The assistant row is now INSERTED UPFRONT as `status:'streaming'`
and UPDATEd on every onStepFinish with all finished steps (text + tool calls +
tool RESULTS), then finalized once to completed/error/aborted on the terminal
callback. So a process death mid-turn keeps every finished step; a startup
sweep (OnModuleInit → sweepStreaming) flips any dangling 'streaming' row to
'aborted'. The write path no longer depends on a live socket.
- Pure exported `flushAssistant(steps, inProgressText, status, extra?)` builds
the persist payload (metadata.parts byte-identical to the old builder), so a
future background worker can call the same path. AiChatMessageRepo gains
`update`, `sweepStreaming`, and `findAllByChat`.
- consumeStream drain, external-MCP client close-once, SSE heartbeat preserved.
B. SERVER-SIDE EXPORT
- New pure `chat-markdown.util.ts` renders Markdown from DB rows ONLY (server
port of the client builder). Because A persists the in-progress row, the
export now includes an interrupted turn up to its last finished step (flagged
"still generating"). `POST /ai-chat/export` (owner-gated via assertOwnedChat,
workspace-scoped) returns it; `lang` accepts a full client locale tag
('en-US'/'ru-RU') and is normalized server-side (normalizeLang) — a strict
@IsIn(['en','ru']) DTO rejected the real client's i18n.language with a 400,
caught in real-browser testing.
- Client: handleCopy calls the endpoint; `canExport = !!activeChatId`. The whole
liveThreadRef/liveStateRef/onLiveContentChange/hasLiveContent hybrid (and the
client chat-markdown util + test) is removed — the server is now authoritative.
Tests: flushAssistant unit (status shapes + parts parity), chat-markdown.util
unit (incl. legacy NULL-status + interrupted note + ru + normalizeLang locale
tags), controller export wiring + owner-gate, integration update/sweepStreaming.
Verified: server build + 318 ai-chat unit + 3 integration; client tsc + 157
ai-chat unit; and END-TO-END in a real browser — a chat turn persists mid-stream
and the Copy button exports the DB-sourced markdown (showing the in-progress
row), HTTP 200 after the locale fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,747 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { buildChatMarkdown } from "@/features/ai-chat/utils/chat-markdown.ts";
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
|
||||
/**
|
||||
* Tests for the client-only Markdown export builder. The output embeds a live
|
||||
* `new Date().toISOString()` export timestamp; we never assert that value, only
|
||||
* the deterministic structure (headings, numbering, fenced blocks, totals).
|
||||
*
|
||||
* A pass-through translator keeps role/tool labels predictable so the
|
||||
* structural assertions are stable without an i18n runtime.
|
||||
*/
|
||||
const t = (key: string, values?: Record<string, unknown>): string => {
|
||||
if (values && typeof values.name === "string") {
|
||||
return key.replace("{{name}}", values.name);
|
||||
}
|
||||
return key;
|
||||
};
|
||||
|
||||
function row(partial: Partial<IAiChatMessageRow>): IAiChatMessageRow {
|
||||
return {
|
||||
id: partial.id ?? "id",
|
||||
role: partial.role ?? "user",
|
||||
content: partial.content ?? null,
|
||||
metadata: partial.metadata ?? null,
|
||||
createdAt: partial.createdAt ?? "2026-06-21T00:00:00.000Z",
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildChatMarkdown — structure", () => {
|
||||
it("emits the title heading, chat id and message count", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "My chat",
|
||||
chatId: "chat-123",
|
||||
rows: [],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("# My chat");
|
||||
expect(md).toContain("- Chat ID: `chat-123`");
|
||||
expect(md).toContain("- Messages: 0");
|
||||
expect(md).toContain("- Exported:"); // timestamp present, value not asserted
|
||||
});
|
||||
|
||||
it("falls back to the translated 'Untitled chat' for empty/blank titles", () => {
|
||||
expect(
|
||||
buildChatMarkdown({ title: null, chatId: "c", rows: [], t }),
|
||||
).toContain("# Untitled chat");
|
||||
expect(
|
||||
buildChatMarkdown({ title: " ", chatId: "c", rows: [], t }),
|
||||
).toContain("# Untitled chat");
|
||||
});
|
||||
|
||||
it("numbers rows sequentially with role headings", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "hi" }),
|
||||
row({ role: "assistant", content: "hello" }),
|
||||
row({ role: "user", content: "again" }),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("## 3. You");
|
||||
// Heading numbering is strictly index+1, not e.g. role-relative.
|
||||
expect(md).not.toContain("## 0.");
|
||||
});
|
||||
|
||||
it("renders the per-row text content from `content` when no metadata.parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "plain body" })],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("plain body");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — text parts", () => {
|
||||
it("skips empty / whitespace-only text parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "ignored-content",
|
||||
metadata: {
|
||||
parts: [
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "kept line" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
] as any,
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("kept line");
|
||||
// Whitespace-only part contributed no block of its own.
|
||||
expect(md).not.toContain(" \n\n");
|
||||
// When metadata.parts exists, the plain `content` fallback is NOT used.
|
||||
expect(md).not.toContain("ignored-content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — tool parts", () => {
|
||||
it("renders a tool label, name, state and fenced Input/Output blocks", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: { pageId: "p1" },
|
||||
output: { id: "p1", title: "Home" },
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
// Known tool name maps to its label key; raw name in backticks; done state.
|
||||
expect(md).toContain("**Tool: Read page** (`getPage`) — done");
|
||||
expect(md).toContain("Input:");
|
||||
expect(md).toContain("Output:");
|
||||
// Fenced JSON blocks contain the stringified payloads.
|
||||
expect(md).toContain('"pageId": "p1"');
|
||||
expect(md).toContain('"title": "Home"');
|
||||
expect(md).toContain("```json");
|
||||
});
|
||||
|
||||
it("renders the generic label for an unknown tool and surfaces errorText", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-mysteryTool",
|
||||
state: "output-error",
|
||||
input: { a: 1 },
|
||||
errorText: "boom",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain(
|
||||
"**Tool: Ran tool mysteryTool** (`mysteryTool`) — error",
|
||||
);
|
||||
expect(md).toContain("**Error:** boom");
|
||||
});
|
||||
|
||||
it("does not throw on a circular tool input (falls back to String)", () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const circular: any = {};
|
||||
circular.self = circular;
|
||||
expect(() =>
|
||||
buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "input-available",
|
||||
input: circular,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — fence anti-breakout", () => {
|
||||
it("lengthens the delimiter so embedded ``` cannot break out of the block", () => {
|
||||
// Tool input whose stringified string form contains a literal ``` run.
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
// A bare string passes through stringify() verbatim.
|
||||
input: "before ``` after",
|
||||
output: "x",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
// The fence around the 3-backtick content must use at least 4 backticks so
|
||||
// the embedded ``` run cannot terminate the block.
|
||||
expect(md).toContain("````json\nbefore ``` after\n````");
|
||||
// Robust anti-breakout check: the opening fence delimiter is strictly
|
||||
// longer than the longest backtick run inside the wrapped content. (A naive
|
||||
// `not.toContain("```json...")` is a false negative — a 4-backtick fence
|
||||
// textually contains the 3-backtick substring.)
|
||||
const open = md.match(/(`{3,})json\nbefore/);
|
||||
expect(open).not.toBeNull();
|
||||
expect(open![1].length).toBeGreaterThan(3); // > the 3-backtick run in content
|
||||
});
|
||||
|
||||
it("uses a 5-backtick fence when the content has a 4-backtick run", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
metadata: {
|
||||
parts: [
|
||||
{
|
||||
type: "tool-getPage",
|
||||
state: "output-available",
|
||||
input: "a ```` b",
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
} as any,
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("`````json\na ```` b\n`````");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — token totals", () => {
|
||||
it("prints the total-tokens line only when the summed usage is > 0", () => {
|
||||
const withTokens = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(withTokens).toContain("- Total tokens: 15");
|
||||
// Per-row usage footer too.
|
||||
expect(withTokens).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
});
|
||||
|
||||
it("omits the total-tokens line when the sum is 0 / usage absent", () => {
|
||||
const noTokens = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "hi" }),
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 0, outputTokens: 0 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(noTokens).not.toContain("- Total tokens:");
|
||||
});
|
||||
|
||||
it("uses totalTokens when present rather than summing in/out", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 3, outputTokens: 4, totalTokens: 99 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("- Total tokens: 99");
|
||||
});
|
||||
|
||||
it("appends the reasoning figure to the row footer when reasoningTokens > 0", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 8, reasoningTokens: 3 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("_Tokens — in: 10, out: 8, reasoning: 3, total: 18_");
|
||||
});
|
||||
|
||||
it("omits the reasoning figure when reasoningTokens is 0 / absent", () => {
|
||||
const zero = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 5, reasoningTokens: 0 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(zero).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
expect(zero).not.toContain("reasoning:");
|
||||
|
||||
const absent = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: { usage: { inputTokens: 10, outputTokens: 5 } },
|
||||
}),
|
||||
],
|
||||
t,
|
||||
});
|
||||
expect(absent).not.toContain("reasoning:");
|
||||
});
|
||||
});
|
||||
|
||||
// A minimal on-screen (live) message, matching the subset buildChatMarkdown reads.
|
||||
function live(partial: {
|
||||
id?: string;
|
||||
role?: string;
|
||||
parts?: { type: string; text?: string }[];
|
||||
metadata?: { usage?: Record<string, number>; error?: string };
|
||||
}) {
|
||||
return {
|
||||
id: partial.id ?? "live-id",
|
||||
role: partial.role ?? "assistant",
|
||||
parts: partial.parts ?? [],
|
||||
metadata: partial.metadata,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildChatMarkdown — live (WYSIWYG) source", () => {
|
||||
it("uses the live messages as the document (what's on screen), numbered from 1", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
// Persisted rows hold only the user turn; the assistant reply is live-only.
|
||||
rows: [row({ id: "u1", role: "user", content: "persisted user" })],
|
||||
live: [
|
||||
live({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "on-screen user" }],
|
||||
}),
|
||||
live({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "on-screen reply" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: false,
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("on-screen user");
|
||||
expect(md).toContain("on-screen reply");
|
||||
// Message count reflects the LIVE document, not rows + live.
|
||||
expect(md).toContain("- Messages: 2");
|
||||
});
|
||||
|
||||
it("captures a partial reply from an interrupted (non-streaming) turn — no 'generating' note", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ id: "u1", role: "user", content: "q" })],
|
||||
live: [
|
||||
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||
live({
|
||||
id: "a-live",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "partial plan before the drop" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: false, // the stream dropped — not streaming anymore
|
||||
banner: "Connection lost — the answer was interrupted.",
|
||||
t,
|
||||
});
|
||||
// The partial assistant answer that was on screen IS in the export.
|
||||
expect(md).toContain("partial plan before the drop");
|
||||
// It is NOT flagged still-generating (the turn is over, just interrupted).
|
||||
expect(md).not.toContain("still being generated");
|
||||
// The on-screen banner is recorded at the end.
|
||||
expect(md).toContain("Connection lost — the answer was interrupted.");
|
||||
});
|
||||
|
||||
it("flags ONLY the tail assistant as still generating, and only while streaming", () => {
|
||||
const streaming = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [],
|
||||
live: [
|
||||
live({
|
||||
id: "a",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "done earlier" }],
|
||||
}),
|
||||
live({
|
||||
id: "u",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "next q" }],
|
||||
}),
|
||||
live({
|
||||
id: "b",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "streaming now" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: true,
|
||||
t,
|
||||
});
|
||||
// Exactly one "still being generated" note (the tail assistant).
|
||||
expect(streaming.match(/still being generated/g)?.length).toBe(1);
|
||||
|
||||
const idle = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [],
|
||||
live: [
|
||||
live({
|
||||
id: "b",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "final" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: false,
|
||||
t,
|
||||
});
|
||||
expect(idle).not.toContain("still being generated");
|
||||
});
|
||||
|
||||
it("does NOT flag a completed assistant as generating when the streaming tail is a user message", () => {
|
||||
// The `status === "submitted"` window: the user just sent, isStreaming is
|
||||
// already true, but the new assistant turn has no message yet so the tail is
|
||||
// the USER message. The previous assistant answer is complete on screen and
|
||||
// must not be marked still-generating (WYSIWYG; regression for #160 review).
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [],
|
||||
live: [
|
||||
live({
|
||||
id: "a",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "completed answer" }],
|
||||
}),
|
||||
live({
|
||||
id: "u",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "the new question" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: true,
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("completed answer");
|
||||
expect(md).not.toContain("still being generated");
|
||||
});
|
||||
|
||||
it("emits the heading + note for a streaming tail assistant with empty parts", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ id: "u1", role: "user", content: "q" })],
|
||||
live: [
|
||||
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||
live({ id: "a-live", role: "assistant", parts: [] }),
|
||||
],
|
||||
isStreaming: true,
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("still being generated");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — live enrichment from persisted rows", () => {
|
||||
it("pulls usage / error / timestamp from the persisted row matched by id", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
createdAt: "2026-06-22T10:00:00.000Z",
|
||||
metadata: {
|
||||
usage: { inputTokens: 10, outputTokens: 5 },
|
||||
error: "rate limited",
|
||||
},
|
||||
}),
|
||||
],
|
||||
live: [
|
||||
// Same id as the persisted row, but no usage/error/timestamp on the live msg.
|
||||
live({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "reply" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: false,
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("reply");
|
||||
// Token footer + total come from the enriched row.
|
||||
expect(md).toContain("_Tokens — in: 10, out: 5, total: 15_");
|
||||
expect(md).toContain("- Total tokens: 15");
|
||||
expect(md).toContain("**⚠️ Error:** rate limited");
|
||||
// The persisted timestamp is carried into the export.
|
||||
expect(md).toContain("<!-- 2026-06-22T10:00:00.000Z -->");
|
||||
});
|
||||
|
||||
it("prefers authoritative usage already on the live message over the row's", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: "x",
|
||||
metadata: {
|
||||
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
live: [
|
||||
live({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "reply" }],
|
||||
metadata: {
|
||||
usage: { inputTokens: 100, outputTokens: 50, totalTokens: 150 },
|
||||
},
|
||||
}),
|
||||
],
|
||||
isStreaming: false,
|
||||
t,
|
||||
});
|
||||
// The live (authoritative, freshest) usage wins, not the stale row usage.
|
||||
expect(md).toContain("- Total tokens: 150");
|
||||
expect(md).not.toContain("- Total tokens: 2");
|
||||
});
|
||||
|
||||
it("a current-turn live message with no matching row renders without a footer", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ id: "u1", role: "user", content: "q" })],
|
||||
live: [
|
||||
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||
live({
|
||||
id: "a-live",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "fresh reply" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: false,
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("fresh reply");
|
||||
// No persisted row for the live assistant -> no token footer, no timestamp.
|
||||
expect(md).not.toContain("_Tokens —");
|
||||
expect(md).not.toContain("<!-- undefined -->");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChatMarkdown — fallback + banner", () => {
|
||||
it("falls back to the persisted rows when there are no live messages", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [
|
||||
row({ role: "user", content: "from rows" }),
|
||||
row({
|
||||
role: "assistant",
|
||||
content: "answer",
|
||||
metadata: { usage: { inputTokens: 4, outputTokens: 6 } },
|
||||
}),
|
||||
],
|
||||
live: [], // empty live mirror -> fallback path
|
||||
isStreaming: false,
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("from rows");
|
||||
expect(md).toContain("- Messages: 2");
|
||||
expect(md).toContain("- Total tokens: 10");
|
||||
});
|
||||
|
||||
it("appends the on-screen banner once, after the messages", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "q" })],
|
||||
live: [
|
||||
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||
],
|
||||
isStreaming: false,
|
||||
banner: "Rate limit reached — try again shortly.",
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("_⚠️ Rate limit reached — try again shortly._");
|
||||
// Banner comes after the (only) message block.
|
||||
expect(md.indexOf("Rate limit reached")).toBeGreaterThan(
|
||||
md.indexOf("## 1."),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the banner block when there is no banner", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: "t",
|
||||
chatId: "c",
|
||||
rows: [row({ role: "user", content: "q" })],
|
||||
live: [
|
||||
live({ id: "u", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||
],
|
||||
isStreaming: false,
|
||||
banner: null,
|
||||
t,
|
||||
});
|
||||
expect(md).not.toContain("_⚠️");
|
||||
});
|
||||
});
|
||||
|
||||
// #174: a brand-new, not-yet-persisted chat whose first turn is streaming (or was
|
||||
// interrupted) has live messages but NO persisted rows yet, and its chat id is not
|
||||
// known (the caller passes a placeholder). The export must still capture the
|
||||
// on-screen thread WYSIWYG from the live messages alone.
|
||||
describe("buildChatMarkdown — first-turn export with no persisted base (#174)", () => {
|
||||
it("builds the document from live messages alone when rows are empty", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: null,
|
||||
chatId: "unsaved",
|
||||
rows: [],
|
||||
live: [
|
||||
live({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
live({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "partial reply" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: true,
|
||||
t,
|
||||
});
|
||||
// Both on-screen messages are serialized, numbered from 1.
|
||||
expect(md).toContain("## 1. You");
|
||||
expect(md).toContain("hello");
|
||||
expect(md).toContain("## 2. AI agent");
|
||||
expect(md).toContain("partial reply");
|
||||
// The streaming tail assistant is flagged as in-progress.
|
||||
expect(md).toContain("still being generated");
|
||||
// The placeholder chat id and the live message count are recorded.
|
||||
expect(md).toContain("- Chat ID: `unsaved`");
|
||||
expect(md).toContain("- Messages: 2");
|
||||
// No persisted timestamp exists for a current-turn live message.
|
||||
expect(md).not.toContain("<!--");
|
||||
});
|
||||
|
||||
it("captures an interrupted first turn (no rows, not streaming) without a generating note", () => {
|
||||
const md = buildChatMarkdown({
|
||||
title: null,
|
||||
chatId: "unsaved",
|
||||
rows: [],
|
||||
live: [
|
||||
live({ id: "u1", role: "user", parts: [{ type: "text", text: "q" }] }),
|
||||
live({
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "half an answer" }],
|
||||
}),
|
||||
],
|
||||
isStreaming: false,
|
||||
banner: "Connection dropped — the response was cut off.",
|
||||
t,
|
||||
});
|
||||
expect(md).toContain("half an answer");
|
||||
// An interrupted (non-streaming) partial is exported as-is, no generating note.
|
||||
expect(md).not.toContain("still being generated");
|
||||
// The on-screen banner records the interruption.
|
||||
expect(md).toContain("_⚠️ Connection dropped — the response was cut off._");
|
||||
});
|
||||
});
|
||||
@@ -1,308 +0,0 @@
|
||||
/**
|
||||
* Client-only Markdown builder for an AI agent chat. Serializes the already
|
||||
* persisted message rows (loaded via `useAiChatMessagesQuery`) into a single
|
||||
* Markdown string suitable for copying to the clipboard. NO network call is
|
||||
* made and NO server/DB code is touched — this reuses the rich "request
|
||||
* internals" (tool calls with input/output, per-message token usage,
|
||||
* finish/error info) that the chat already holds client-side.
|
||||
*
|
||||
* Only role labels and tool action labels are localized via the passed-in `t`
|
||||
* translator; the structural document words (Input/Output/Error/Tokens/...) are
|
||||
* plain English constants because the output is a technical artifact.
|
||||
*/
|
||||
|
||||
import type { IAiChatMessageRow } from "@/features/ai-chat/types/ai-chat.types.ts";
|
||||
import {
|
||||
ToolUiPart,
|
||||
getToolName,
|
||||
toolRunState,
|
||||
toolLabelKey,
|
||||
} from "@/features/ai-chat/utils/tool-parts.tsx";
|
||||
|
||||
// Minimal translator signature compatible with react-i18next's `t`.
|
||||
type Translate = (key: string, values?: Record<string, unknown>) => string;
|
||||
|
||||
interface BuildChatMarkdownArgs {
|
||||
title: string | null;
|
||||
chatId: string;
|
||||
/** The live, on-screen messages — the WYSIWYG source of the export. When
|
||||
* present and non-empty these DRIVE the document (so it mirrors exactly what
|
||||
* the user sees, including a partial reply from an interrupted turn). Each is
|
||||
* matched to a persisted row by `id` to enrich it with token usage / error /
|
||||
* timestamp. When absent or empty the builder falls back to `rows`. */
|
||||
live?: LiveMessage[];
|
||||
/** Persisted message rows. Enrichment source (matched to `live` by id) AND the
|
||||
* fallback document source when `live` is empty. */
|
||||
rows: IAiChatMessageRow[];
|
||||
/** Whether the live thread is still streaming. Only then is the tail assistant
|
||||
* message flagged "still generating"; an interrupted (non-streaming) partial
|
||||
* reply is exported as-is and the `banner` explains the interruption. */
|
||||
isStreaming?: boolean;
|
||||
/** The on-screen banner text (error / dropped connection / manual stop),
|
||||
* appended at the end of the export so the artifact records the interruption
|
||||
* the user saw. */
|
||||
banner?: string | null;
|
||||
t: Translate;
|
||||
}
|
||||
|
||||
/** A single AI SDK UIMessage part (text part or other). */
|
||||
interface TextLikePart {
|
||||
type: string;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
/** Authoritative per-turn usage the server attaches to a message / row. */
|
||||
interface UsageLike {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}
|
||||
|
||||
/** A live, on-screen message (subset of the AI SDK UIMessage we consume). */
|
||||
interface LiveMessage {
|
||||
id: string;
|
||||
role: "user" | "assistant" | string;
|
||||
parts: TextLikePart[];
|
||||
metadata?: { usage?: UsageLike; error?: string };
|
||||
}
|
||||
|
||||
/** One message normalized for rendering, regardless of live/persisted origin. */
|
||||
interface ExportItem {
|
||||
role: string;
|
||||
parts: TextLikePart[];
|
||||
usage?: UsageLike;
|
||||
error?: string;
|
||||
/** ISO timestamp from the persisted row, when one is known. */
|
||||
createdAt?: string;
|
||||
/** True only for the tail assistant message while the thread is streaming. */
|
||||
generating: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stringify an arbitrary tool input/output value for a fenced block. Strings
|
||||
* pass through as-is; everything else is pretty-printed JSON, falling back to
|
||||
* `String(value)` if serialization throws (e.g. a circular structure).
|
||||
*/
|
||||
function stringify(value: unknown): string {
|
||||
if (typeof value === "string") return value;
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap `code` in a fenced code block whose backtick delimiter is LONGER than
|
||||
* the longest backtick run inside the content, so embedded backticks (or even
|
||||
* a literal ``` fence) never break out of the block. Minimum 3 backticks.
|
||||
*/
|
||||
function fence(code: string, lang = ""): string {
|
||||
const runs: string[] = code.match(/`+/g) ?? [];
|
||||
const longest = runs.reduce((m, s) => Math.max(m, s.length), 0);
|
||||
const delim = "`".repeat(Math.max(3, longest + 1));
|
||||
return `${delim}${lang}\n${code}\n${delim}`;
|
||||
}
|
||||
|
||||
/** Per-row token count, mirroring the header sum in ai-chat-window.tsx. */
|
||||
function rowTokens(usage: {
|
||||
inputTokens?: number;
|
||||
outputTokens?: number;
|
||||
totalTokens?: number;
|
||||
reasoningTokens?: number;
|
||||
}): number {
|
||||
return (
|
||||
usage.totalTokens ?? (usage.inputTokens ?? 0) + (usage.outputTokens ?? 0)
|
||||
);
|
||||
}
|
||||
|
||||
/** Render one message's UIMessage parts into an array of Markdown blocks
|
||||
* (text blocks + tool blocks). Mirrors MessageItem's part handling. */
|
||||
function renderMessageParts(parts: TextLikePart[], t: Translate): string[] {
|
||||
const out: string[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.type === "text") {
|
||||
const text = (part.text ?? "").trim();
|
||||
// Skip empty/whitespace-only text parts (matches MessageItem).
|
||||
if (text.length > 0) out.push(text);
|
||||
continue;
|
||||
}
|
||||
|
||||
const isToolPart =
|
||||
part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
||||
if (!isToolPart) continue;
|
||||
|
||||
const tp = part as unknown as ToolUiPart;
|
||||
const name = getToolName(tp);
|
||||
const { key, values } = toolLabelKey(name);
|
||||
const label = t(key, values);
|
||||
const state = toolRunState(tp.state);
|
||||
|
||||
const toolLines: string[] = [
|
||||
`**Tool: ${label}** (\`${name}\`) — ${state}`,
|
||||
];
|
||||
if (tp.input !== undefined) {
|
||||
toolLines.push("Input:");
|
||||
toolLines.push(fence(stringify(tp.input), "json"));
|
||||
}
|
||||
if (tp.output !== undefined) {
|
||||
toolLines.push("Output:");
|
||||
toolLines.push(fence(stringify(tp.output), "json"));
|
||||
}
|
||||
if (tp.errorText) {
|
||||
toolLines.push(`**Error:** ${tp.errorText}`);
|
||||
}
|
||||
out.push(toolLines.join("\n\n"));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Resolve a persisted row's parts: prefer the rich persisted parts, else a
|
||||
* single text part built from the plain-text content (mirrors `rowToUiMessage`). */
|
||||
function rowParts(row: IAiChatMessageRow): TextLikePart[] {
|
||||
return Array.isArray(row.metadata?.parts) && row.metadata.parts.length > 0
|
||||
? (row.metadata.parts as TextLikePart[])
|
||||
: [{ type: "text", text: row.content ?? "" }];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the export to one ordered list of {@link ExportItem}, WYSIWYG-first:
|
||||
*
|
||||
* - When `live` messages are present, THEY are the document (what the user sees,
|
||||
* incl. an interrupted turn's partial reply). Each is matched to a persisted
|
||||
* row by `id` to pull token usage / error / timestamp — a live message of the
|
||||
* CURRENT turn has no matching row yet, so it simply renders without a footer.
|
||||
* Authoritative `usage`/`error` already on the live message metadata win over
|
||||
* the row (the server attaches usage to the streamed message at a step
|
||||
* boundary before the row is refetched). Only the tail assistant message is
|
||||
* flagged `generating`, and only while `isStreaming`.
|
||||
* - When `live` is empty (e.g. the export runs before the live mirror is
|
||||
* populated), fall back to the persisted `rows` so the format never regresses.
|
||||
*/
|
||||
function resolveItems(
|
||||
live: LiveMessage[] | undefined,
|
||||
rows: IAiChatMessageRow[],
|
||||
isStreaming: boolean,
|
||||
): ExportItem[] {
|
||||
if (live && live.length > 0) {
|
||||
const rowsById = new Map(rows.map((r) => [r.id, r]));
|
||||
// The "still generating" note may apply ONLY to an assistant message that is
|
||||
// the actual TAIL of the list — that is where the on-screen typing indicator
|
||||
// sits. While `status === "submitted"` (isStreaming true) right after the
|
||||
// user hit send, the tail is the USER message and the new assistant turn has
|
||||
// no message yet; the previous assistant answer is shown complete on screen,
|
||||
// so it must NOT be flagged (the indicator renders as a separate bottom
|
||||
// block, not on that answer).
|
||||
const lastIndex = live.length - 1;
|
||||
const tailIsStreamingAssistant =
|
||||
isStreaming && live[lastIndex]?.role === "assistant";
|
||||
return live.map((m, i) => {
|
||||
const row = rowsById.get(m.id);
|
||||
return {
|
||||
role: m.role,
|
||||
parts: m.parts ?? [],
|
||||
// Authoritative usage/error already on the live message (the server
|
||||
// attaches usage to the streamed message at a step boundary) wins over
|
||||
// the persisted row; a current-turn live message has no matching row yet
|
||||
// and simply renders without a token footer (the accepted WYSIWYG
|
||||
// tradeoff — an interrupted turn loses only its token footer, not text).
|
||||
usage: m.metadata?.usage ?? row?.metadata?.usage,
|
||||
error: m.metadata?.error ?? row?.metadata?.error ?? undefined,
|
||||
createdAt: row?.createdAt,
|
||||
generating: tailIsStreamingAssistant && i === lastIndex,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return rows.map((row) => ({
|
||||
role: row.role,
|
||||
parts: rowParts(row),
|
||||
usage: row.metadata?.usage,
|
||||
error: row.metadata?.error ?? undefined,
|
||||
createdAt: row.createdAt,
|
||||
generating: false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a chat to a Markdown string. Pure (apart from `new Date()` for the
|
||||
* export timestamp), so it is straightforward to unit-test.
|
||||
*/
|
||||
export function buildChatMarkdown(args: BuildChatMarkdownArgs): string {
|
||||
const { title, chatId, live, rows, isStreaming, banner, t } = args;
|
||||
const blocks: string[] = [];
|
||||
|
||||
const items = resolveItems(live, rows, isStreaming === true);
|
||||
|
||||
const heading = (title ?? "").trim() || t("Untitled chat");
|
||||
blocks.push(`# ${heading}`);
|
||||
|
||||
// Metadata bullet list. Total tokens is only shown when there is a sum.
|
||||
const totalTokens = items.reduce(
|
||||
(sum, item) => (item.usage ? sum + rowTokens(item.usage) : sum),
|
||||
0,
|
||||
);
|
||||
const meta = [
|
||||
`- Chat ID: \`${chatId}\``,
|
||||
`- Exported: ${new Date().toISOString()}`,
|
||||
`- Messages: ${items.length}`,
|
||||
];
|
||||
if (totalTokens > 0) meta.push(`- Total tokens: ${totalTokens}`);
|
||||
blocks.push(meta.join("\n"));
|
||||
|
||||
items.forEach((item, index) => {
|
||||
blocks.push("---");
|
||||
|
||||
const roleLabel = item.role === "assistant" ? t("AI agent") : t("You");
|
||||
blocks.push(`## ${index + 1}. ${roleLabel}`);
|
||||
|
||||
// Created-at kept in source as an HTML comment (out of the rendered prose).
|
||||
// A live message of the current turn has no persisted row yet — omit it.
|
||||
if (item.createdAt) blocks.push(`<!-- ${item.createdAt} -->`);
|
||||
|
||||
blocks.push(...renderMessageParts(item.parts, t));
|
||||
|
||||
// A generating assistant may have empty/no parts yet — the heading (above)
|
||||
// and this note still record the in-progress turn.
|
||||
if (item.generating) {
|
||||
blocks.push(
|
||||
"_⏳ This message is still being generated — the export captured a partial, in-progress response._",
|
||||
);
|
||||
}
|
||||
|
||||
// A persisted per-message error (the raw provider text) may coexist with the
|
||||
// trailing `banner` (the classified on-screen alert) when the failed turn's
|
||||
// row has already been refetched by export time. They describe the same
|
||||
// failure at different fidelity; showing both is an accepted, minor redundancy.
|
||||
if (item.error) {
|
||||
blocks.push(`**⚠️ Error:** ${item.error}`);
|
||||
}
|
||||
|
||||
const usage = item.usage;
|
||||
if (usage) {
|
||||
const total = usage.totalTokens ?? rowTokens(usage);
|
||||
// Reasoning (thinking) tokens are shown only when the provider reported a
|
||||
// positive count; old rows / non-reasoning providers omit it.
|
||||
const reasoning =
|
||||
usage.reasoningTokens && usage.reasoningTokens > 0
|
||||
? `, reasoning: ${usage.reasoningTokens}`
|
||||
: "";
|
||||
blocks.push(
|
||||
`_Tokens — in: ${usage.inputTokens ?? "?"}, out: ${usage.outputTokens ?? "?"}${reasoning}, total: ${total}_`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Record the on-screen banner (error / dropped connection / manual stop) so
|
||||
// the export reflects exactly what the user saw, including an interruption.
|
||||
if (banner && banner.trim().length > 0) {
|
||||
blocks.push("---");
|
||||
blocks.push(`_⚠️ ${banner.trim()}_`);
|
||||
}
|
||||
|
||||
// Blank line between blocks so the Markdown renders cleanly.
|
||||
return blocks.join("\n\n");
|
||||
}
|
||||
Reference in New Issue
Block a user