diff --git a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx
index 61894ad7..98b88d27 100644
--- a/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx
+++ b/apps/client/src/features/ai-chat/components/message-item-memo.test.tsx
@@ -27,6 +27,7 @@ vi.mock("@/features/ai-chat/utils/markdown.ts", async () => {
import MessageItem from "./message-item";
import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
+import { splitPlainChunks } from "./streaming-plain-text";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
@@ -114,3 +115,89 @@ describe("MessageItem markdown memoization", () => {
expect(queryByText("streamed answer")).not.toBeNull();
});
});
+
+// PERF SMOKE (#492): the whole point of the incremental streaming render is that
+// the ANSWER path costs O(number of markdown blocks), NOT O(number of throttled
+// ~20Hz ticks). Pre-#492 the finalized MarkdownPart re-parsed the WHOLE growing
+// answer on every delta — a synthetic ~100 KB stream measured 394 renderChatMarkdown
+// calls (one per tick). With the incremental render each STABILIZED block is parsed
+// exactly once (memoized in MarkdownChunk) and the live tail is cheap plain text, so
+// the call count collapses to ~= the block count regardless of tick granularity.
+describe("MessageItem streaming answer render is O(blocks), not O(ticks)", () => {
+ // ~100 KB answer. Each section is a heading + a paragraph — TWO blank-line
+ // delimited markdown blocks — so the safe-cut block count is ~2× the section
+ // count. The perf claim is about the BLOCK count (the memoization granularity),
+ // measured directly with splitPlainChunks below, not the section count.
+ const buildAnswer = () => {
+ const SECTIONS = 100;
+ const paragraphs: string[] = [];
+ for (let i = 0; i < SECTIONS; i++) {
+ paragraphs.push(`## Section ${i}\n\n` + "lorem ipsum dolor ".repeat(55));
+ }
+ const full = paragraphs.join("\n\n");
+ // The number of memoized markdown blocks the incremental render splits into
+ // (all but the live tail are parsed once each).
+ return { full, blocks: splitPlainChunks(full).length };
+ };
+
+ const streamMsg = (text: string, state: "streaming" | "done"): UIMessage =>
+ ({
+ id: "m1",
+ role: "assistant",
+ parts: [{ type: "text", text, state }],
+ }) as UIMessage;
+
+ it("parses each block ~once over a 100KB stream (≈blocks, ≪ ticks)", () => {
+ renderChatMarkdownSpy.mockClear();
+ const { full, blocks } = buildAnswer();
+ const CHUNK = 128; // a realistic ~20Hz throttled delta size
+ const ticks = Math.ceil(full.length / CHUNK);
+
+ let msg = streamMsg(full.slice(0, CHUNK), "streaming");
+ const { rerender } = render(
+
+
+ ,
+ );
+ for (let end = 2 * CHUNK; end < full.length; end += CHUNK) {
+ msg = streamMsg(full.slice(0, end), "streaming");
+ rerender(
+
+
+ ,
+ );
+ }
+ // Finalize: the streaming→done flip renders the whole answer through ONE
+ // canonical pass (visual parity), so the finished DOM matches the pre-#492
+ // output. This is the single extra parse on top of the per-block ones.
+ const done = streamMsg(full, "done");
+ rerender(
+
+
+ ,
+ );
+
+ const calls = renderChatMarkdownSpy.mock.calls.length;
+ // Sanity: the stream really had far more ticks than blocks (else the test is
+ // vacuous — the point is that calls scale with blocks, not ticks).
+ expect(ticks).toBeGreaterThan(blocks * 3);
+ // O(blocks): each stabilized block parsed once + the single final whole-text
+ // parse. A small constant absorbs the finalize render and the live-tail block;
+ // the load-bearing claim is the bound below.
+ expect(calls).toBeLessThanOrEqual(blocks + 2);
+ // ≪ ticks — and, non-vacuously, the blocks WERE parsed (not skipped entirely).
+ expect(calls).toBeLessThan(ticks / 3);
+ expect(calls).toBeGreaterThan(blocks / 2);
+ // MUTATION-VERIFY (documented, not run here): dropping the `memo()` wrapper on
+ // MarkdownChunk (so every stable block re-parses each tick) drives `calls`
+ // toward `ticks` (~394), reddening both upper-bound assertions above.
+ });
+});
diff --git a/apps/client/src/features/ai-chat/components/message-item.render.test.tsx b/apps/client/src/features/ai-chat/components/message-item.render.test.tsx
new file mode 100644
index 00000000..9e4266b8
--- /dev/null
+++ b/apps/client/src/features/ai-chat/components/message-item.render.test.tsx
@@ -0,0 +1,112 @@
+import { describe, expect, it, vi } from "vitest";
+import { render } from "@testing-library/react";
+import { MantineProvider } from "@mantine/core";
+import type { UIMessage } from "@ai-sdk/react";
+
+// Stub react-i18next (the component reads `useTranslation`). Mirrors the other
+// message-item specs.
+vi.mock("react-i18next", () => ({
+ useTranslation: () => ({ t: (key: string) => key }),
+}));
+
+import MessageItem from "./message-item";
+import { messageSignature } from "@/features/ai-chat/utils/message-signature.ts";
+// The REAL canonical renderer (NOT the spy the memo test installs): this file
+// exercises the actual markdown output so the visual-regression assertions below
+// compare against genuine HTML (incl. the schema's `
` wrappers).
+import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
+import classes from "./ai-chat.module.css";
+
+const msg = (
+ parts: UIMessage["parts"],
+ extra?: Partial,
+): UIMessage =>
+ ({ id: "m1", role: "assistant", parts, ...extra }) as UIMessage;
+
+const renderRow = (message: UIMessage, turnStreaming = false) =>
+ render(
+
+
+ ,
+ );
+
+// A rich multi-block answer that exercises headings, a list (the `` case
+// the scoped CSS tightens), inline emphasis, and multiple paragraphs.
+const ANSWER = [
+ "# Заголовок",
+ "",
+ "Первый абзац с **жирным** и `кодом`.",
+ "",
+ "- пункт один",
+ "- пункт два",
+ "",
+ "Второй абзац.",
+].join("\n");
+
+describe("MessageItem final render — visual parity with the canonical pipeline", () => {
+ it("a finalized text part renders exactly renderChatMarkdown(text)", () => {
+ const { container } = renderRow(
+ msg([{ type: "text", text: ANSWER, state: "done" }]),
+ );
+ const block = container.querySelector(`.${classes.markdown}`);
+ expect(block).not.toBeNull();
+ // Byte-for-byte the canonical output (the SAME whole-text pass the pre-#492
+ // MarkdownPart produced), including `
…
` wrappers.
+ expect(block!.innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
+ // The list wrapper is really present (guards against a vacuous empty render).
+ expect(container.querySelectorAll("li p").length).toBe(2);
+ });
+
+ it("the streaming incremental view CONVERGES to the canonical render on finish", () => {
+ // Mount mid-stream (live tail) — the DOM here is the incremental view.
+ const { container, rerender } = render(
+
+
+ ,
+ );
+ // Finish the turn: state flips to done AND the turn is no longer streaming.
+ const done = msg([{ type: "text", text: ANSWER, state: "done" }]);
+ rerender(
+
+
+ ,
+ );
+ // After finish there is exactly ONE canonical markdown container whose HTML is
+ // the whole-text render — identical to the non-streaming path above.
+ const blocks = container.querySelectorAll(`.${classes.markdown}`);
+ expect(blocks.length).toBe(1);
+ expect(blocks[0].innerHTML).toBe(renderChatMarkdown(ANSWER, {}));
+ });
+
+ it("neutralizeInternalLinks is honored on the finalized render", () => {
+ const linkAnswer = "См. [страницу](/p/abc).";
+ const { container } = render(
+
+
+ ,
+ );
+ const block = container.querySelector(`.${classes.markdown}`);
+ expect(block!.innerHTML).toBe(
+ renderChatMarkdown(linkAnswer, { neutralizeInternalLinks: true }),
+ );
+ // The internal link was made inert (no href) by the neutralization flag.
+ const a = container.querySelector("a");
+ expect(a?.hasAttribute("href")).toBe(false);
+ });
+});
diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx
index 878477b4..0cf7b7a4 100644
--- a/apps/client/src/features/ai-chat/components/message-item.tsx
+++ b/apps/client/src/features/ai-chat/components/message-item.tsx
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
import type { UIMessage } from "@ai-sdk/react";
import ToolCallCard from "@/features/ai-chat/components/tool-call-card.tsx";
import ReasoningBlock from "@/features/ai-chat/components/reasoning-block.tsx";
+import { StreamingMarkdownText } from "@/features/ai-chat/components/streaming-markdown-text.tsx";
import ChatErrorAlert from "@/features/ai-chat/components/chat-error-alert.tsx";
import ChatStoppedNotice from "@/features/ai-chat/components/chat-stopped-notice.tsx";
import { ToolUiPart, isToolPart } from "@/features/ai-chat/utils/tool-parts.tsx";
@@ -86,17 +87,39 @@ interface MessageItemProps {
* One assistant text part rendered as sanitized markdown. Memoized on its inputs
* so a finalized text part is NOT re-parsed on every streamed delta: during a
* turn only the actively-growing tail part changes its `text`, so every earlier
- * part hits the memo and skips the expensive marked + DOMPurify pass. Props are
- * primitives, so React.memo's default shallow compare is exactly right (the
- * `text` string is compared by value).
+ * part hits the memo and skips the expensive canonical parse + DOMPurify pass.
+ * Props are primitives, so React.memo's default shallow compare is exactly right
+ * (the `text` string is compared by value).
+ *
+ * Streaming gate (#492) — mirrors ReasoningBlock:
+ * - `streaming` (this is the live, actively-growing tail part of an in-flight
+ * turn): render incrementally via StreamingMarkdownText — the stabilized blocks
+ * go through the canonical pipeline (each parsed ONCE, memoized) and only the
+ * live tail is cheap plain text. This makes the per-tick cost O(new blocks),
+ * not the pre-#492 O(ticks) whole-answer re-parse on every ~20Hz delta.
+ * - finalized (the common case, and the turn-end flip): render the WHOLE text
+ * through ONE canonical pass — byte-identical to the pre-#492 output (visual
+ * parity). The row re-renders on the streaming→done flip because
+ * `messageSignature` tracks each part's `state` (and `turnStreaming` flips at
+ * turn end), so the incremental view always converges to this single render.
*/
const MarkdownPart = memo(function MarkdownPart({
text,
neutralizeInternalLinks,
+ streaming,
}: {
text: string;
neutralizeInternalLinks: boolean;
+ streaming: boolean;
}) {
+ if (streaming) {
+ return (
+
+ );
+ }
const html = renderChatMarkdown(text, { neutralizeInternalLinks });
if (html) {
return (
@@ -179,47 +202,10 @@ function MessageItem({
{resolveAssistantName(assistantName) ?? t("AI agent")}
{message.parts.map((part, index) => {
- if (part.type === "reasoning") {
- // Reasoning ("thinking") -> a collapsible block with its own token
- // count. Empty/whitespace reasoning with no authoritative count carries
- // nothing to show, so skip it (avoids an empty 0-token block).
- const text = (part as { text?: string }).text ?? "";
- if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
- return null;
- // Absent state (persisted rows) and "done" both mean finalized.
- // `messageSignature` already includes each part's `state`, so the
- // streaming→done flip changes the row signature and re-renders this
- // row — which is what lets ReasoningBlock switch from chunked plain
- // text to its one-time markdown parse (see reasoning-block.tsx).
- // ALSO require the turn to be live: a part stranded at
- // `state:"streaming"` after the turn ended (no `reasoning-end` — see
- // the `turnStreaming` prop doc) must still finalize and parse.
- const streaming =
- turnStreaming && (part as { state?: string }).state === "streaming";
- return (
-
- );
- }
-
- if (part.type === "text") {
- // Skip empty/whitespace-only text parts (a streaming message often
- // starts with an empty text part before the first token arrives); the
- // typing indicator covers that gap until real content streams in.
- if (!part.text.trim()) return null;
- return (
-
- );
- }
-
+ // Tool parts (`tool-*` / `dynamic-tool`) are template-literal kinds, so
+ // they cannot be a `switch` case; the runtime guard handles them, and the
+ // switch below covers every CLOSED (literal-typed) part kind with a
+ // compile-time exhaustiveness check in its default.
if (isToolPart(part.type)) {
return (
a collapsible block with its own token
+ // count. Empty/whitespace reasoning with no authoritative count
+ // carries nothing to show, so skip it (avoids an empty 0-token block).
+ const text = part.text ?? "";
+ if (!text.trim() && !(reasoningTokens && reasoningTokens > 0))
+ return null;
+ // Absent state (persisted rows) and "done" both mean finalized.
+ // `messageSignature` already includes each part's `state`, so the
+ // streaming→done flip changes the row signature and re-renders this
+ // row — which is what lets ReasoningBlock switch from chunked plain
+ // text to its one-time markdown parse (see reasoning-block.tsx).
+ // ALSO require the turn to be live: a part stranded at
+ // `state:"streaming"` after the turn ended (no `reasoning-end` — see
+ // the `turnStreaming` prop doc) must still finalize and parse.
+ const streaming = turnStreaming && part.state === "streaming";
+ return (
+
+ );
+ }
+
+ case "text": {
+ // Skip empty/whitespace-only text parts (a streaming message often
+ // starts with an empty text part before the first token arrives); the
+ // typing indicator covers that gap until real content streams in.
+ if (!part.text.trim()) return null;
+ // The live, actively-growing tail part of the in-flight turn renders
+ // incrementally (see MarkdownPart); a finalized part (persisted, or
+ // the turn-end flip) renders the whole text through one canonical
+ // pass. Same liveness rule as the reasoning branch above.
+ const streaming = turnStreaming && part.state === "streaming";
+ return (
+
+ );
+ }
+
+ case "source-url":
+ case "source-document":
+ case "file":
+ case "step-start":
+ // Not surfaced in the chat bubble (v1) — same as the pre-#492 default.
+ return null;
+
+ default: {
+ // Compile-time exhaustiveness over the CLOSED union members: every
+ // literal-typed part kind is handled above, so the only kinds that
+ // can reach here are the OPEN template-literal ones (`tool-*` — caught
+ // by the guard at runtime — and `data-*`) plus `dynamic-tool`. Adding
+ // a NEW closed part kind to UIMessagePart makes this assignment fail
+ // to compile, forcing it to be handled instead of silently ignored
+ // (this replaces the pre-#492 fall-through `return null` + WARNING).
+ const _exhaustive:
+ | `tool-${string}`
+ | "dynamic-tool"
+ | `data-${string}` = part.type;
+ void _exhaustive;
+ return null;
+ }
+ }
})}
{/* A persisted turn error (server stored it in metadata.error). Rendered
here so it survives a thread remount and shows in reopened history. */}
diff --git a/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx b/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx
new file mode 100644
index 00000000..7a6fdca6
--- /dev/null
+++ b/apps/client/src/features/ai-chat/components/streaming-markdown-text.tsx
@@ -0,0 +1,96 @@
+import { memo, useMemo } from "react";
+import { splitPlainChunks } from "@/features/ai-chat/components/streaming-plain-text.tsx";
+import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
+import classes from "@/features/ai-chat/components/ai-chat.module.css";
+
+/**
+ * One STABILIZED markdown block, rendered through the canonical pipeline and
+ * memoized on its string prop. During streaming only the TAIL chunk grows (the
+ * `splitPlainChunks` append-only invariant guarantees every earlier chunk is
+ * byte-identical across deltas), so React skips every stable block and each one
+ * is parsed by `renderChatMarkdown` EXACTLY ONCE — turning the pre-#492
+ * "re-parse the whole accumulated answer on every ~20Hz tick" (O(ticks)) into
+ * O(number of blocks). The markup is DOMPurify-sanitized inside renderChatMarkdown
+ * before it reaches `dangerouslySetInnerHTML`.
+ *
+ * NOTE (transient streaming-only artifact): a safe cut is a blank-line boundary,
+ * so a construct that legitimately contains a blank line (e.g. a fenced code block
+ * with an empty line) can be split across chunks and render oddly WHILE it is still
+ * streaming. This is cosmetic and self-heals: the moment the part finalizes,
+ * MarkdownPart renders the WHOLE text through one canonical pass (visual parity
+ * with the pre-#492 output). The reasoning path makes the same trade (plain text
+ * while streaming, one markdown parse at the end).
+ */
+const MarkdownChunk = memo(function MarkdownChunk({
+ text,
+ neutralizeInternalLinks,
+}: {
+ text: string;
+ neutralizeInternalLinks: boolean;
+}) {
+ const html = renderChatMarkdown(text, { neutralizeInternalLinks });
+ if (html) {
+ return (
+
+ );
+ }
+ // Malformed/unsupported markdown could not render synchronously: raw text.
+ return (
+
+ {text}
+
+ );
+});
+
+/**
+ * The cheap streaming-time stand-in for the finalized answer's one-time markdown
+ * parse (see MarkdownPart in message-item.tsx). Mirrors StreamingPlainText's
+ * chunked-memo pattern but renders the STABILIZED prefix as real markdown (each
+ * block parsed once, memoized) and only the LIVE tail as flat plain text — so the
+ * user sees formatted output for everything up to the last safe cut, and the not-
+ * yet-stable tail (which markdown-parsing every tick would make O(ticks)) stays a
+ * single cheap escaped text node until it stabilizes into a new block.
+ *
+ * `splitPlainChunks` yields chunks where, under append-only growth, every chunk
+ * except the LAST is immutable; the last chunk is the live tail. Index keys are
+ * therefore stable (a given index never changes to a different chunk's content).
+ */
+export function StreamingMarkdownText({
+ text,
+ neutralizeInternalLinks,
+}: {
+ text: string;
+ neutralizeInternalLinks: boolean;
+}) {
+ const chunks = useMemo(() => splitPlainChunks(text), [text]);
+ return (
+ <>
+ {chunks.map((chunk, index) =>
+ index < chunks.length - 1 ? (
+
+ ) : (
+ // The live tail: flat, React-escaped plain text (no markdown parse, no
+ // sanitizer, no innerHTML). `pre-wrap` preserves its newlines; trailing
+ // separator newlines are dropped at display time so the block gap comes
+ // from the markdown margins, not a doubled empty line (mirrors
+ // PlainChunk in streaming-plain-text.tsx).
+
+ {chunk.replace(/\n+$/, "")}
+
+ ),
+ )}
+ >
+ );
+}
diff --git a/apps/server/src/core/ai-chat/ai-chat.controller.ts b/apps/server/src/core/ai-chat/ai-chat.controller.ts
index 2b8aa6ab..66822728 100644
--- a/apps/server/src/core/ai-chat/ai-chat.controller.ts
+++ b/apps/server/src/core/ai-chat/ai-chat.controller.ts
@@ -35,6 +35,7 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
+import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AI_CHAT_THROTTLER } from '../../integrations/throttle/throttler-names';
@@ -43,6 +44,8 @@ import {
AiChatRunHooks,
AiChatService,
AiChatStreamBody,
+ rowHasInlineParts,
+ hydrateAssistantParts,
} from './ai-chat.service';
import { AiChatRunService } from './ai-chat-run.service';
import { AiTranscriptionService } from './ai-transcription.service';
@@ -129,8 +132,39 @@ export class AiChatController {
// production. Only touched on the resumable-stream (flag-on) path.
private readonly streamRegistry?: AiChatStreamRegistryService,
private readonly environment?: EnvironmentService,
+ // #492: reconstruct a #492 mid-run record's parts from the steps table before
+ // returning rows to the client / export. OPTIONAL so positional controller
+ // specs compile unchanged; when absent, hydration is skipped (old-era rows
+ // already carry inline parts, so nothing to reconstruct).
+ private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
) {}
+ /**
+ * Reconstruct parts for any assistant rows that don't carry them INLINE — a
+ * #492 mid-run record whose per-step parts live in `ai_chat_run_steps` (the
+ * append-persist backend). Every FINISHED row (old-era + #492) and every old-era
+ * streaming snapshot already has inline `metadata.parts`, so the common path
+ * fetches NOTHING and returns the rows untouched; only an actively-streaming
+ * new-style row triggers the batch step fetch. Consumers (seed/poll/export) read
+ * `metadata.parts` off the returned rows exactly as before — the era switch is
+ * invisible to them (reconstructRunParts contract).
+ */
+ private async withReconstructedParts(
+ rows: AiChatMessage[],
+ workspaceId: string,
+ ): Promise {
+ if (!this.aiChatRunStepRepo) return rows;
+ const needy = rows.filter(
+ (r) => r.role === 'assistant' && !rowHasInlineParts(r),
+ );
+ if (needy.length === 0) return rows;
+ const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
+ needy.map((r) => r.id),
+ workspaceId,
+ );
+ return hydrateAssistantParts(rows, stepsByMessage);
+ }
+
/** List the requesting user's chats in this workspace (paginated). */
@HttpCode(HttpStatus.OK)
@Post('chats')
@@ -184,11 +218,17 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
) {
await this.assertOwnedChat(dto.chatId, user, workspace);
- return this.aiChatMessageRepo.findByChat(
+ const page = await this.aiChatMessageRepo.findByChat(
dto.chatId,
workspace.id,
pagination,
);
+ // #492: reconstruct parts for any active new-style row so the client seed sees
+ // `metadata.parts` unchanged (a no-op for the finished rows that fill a page).
+ return {
+ ...page,
+ items: await this.withReconstructedParts(page.items, workspace.id),
+ };
}
/**
@@ -225,7 +265,10 @@ export class AiChatController {
workspace.id,
);
return {
- rows,
+ // #492: the delta of an actively-streaming new-style row carries its parts
+ // reconstructed from the steps table, so the degraded poll shows persisted
+ // progress exactly as the pre-#492 full-row snapshot did.
+ rows: await this.withReconstructedParts(rows, workspace.id),
cursor,
run: run ? { id: run.id, status: run.status } : null,
};
@@ -247,8 +290,10 @@ export class AiChatController {
@AuthWorkspace() workspace: Workspace,
): Promise<{ markdown: string }> {
const chat = await this.assertOwnedChat(dto.chatId, user, workspace);
- const rows = await this.aiChatMessageRepo.findAllByChat(
- dto.chatId,
+ const rows = await this.withReconstructedParts(
+ await this.aiChatMessageRepo.findAllByChat(dto.chatId, workspace.id),
+ // #492: an interrupted-but-still-active turn exports its persisted steps
+ // (reconstructed from the steps table) just like the pre-#492 full row did.
workspace.id,
);
const markdown = buildChatMarkdown({
@@ -288,7 +333,13 @@ export class AiChatController {
workspace.id,
)
: undefined;
- return { run, message: message ?? null };
+ // #492: reconnect to an IN-FLIGHT run reconstructs the projection row's parts
+ // from the steps table (the row itself carries only the step marker mid-run);
+ // a finished run's row already has inline parts, so this is a no-op.
+ const [hydrated] = message
+ ? await this.withReconstructedParts([message], workspace.id)
+ : [undefined];
+ return { run, message: hydrated ?? null };
}
/**
diff --git a/apps/server/src/core/ai-chat/ai-chat.service.ts b/apps/server/src/core/ai-chat/ai-chat.service.ts
index 7ec93545..b080804f 100644
--- a/apps/server/src/core/ai-chat/ai-chat.service.ts
+++ b/apps/server/src/core/ai-chat/ai-chat.service.ts
@@ -22,6 +22,7 @@ import { AiSettingsService } from '../../integrations/ai/ai-settings.service';
import { describeProviderError } from '../../integrations/ai/ai-error.util';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
+import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
import { AiAgentRoleRepo } from '@docmost/db/repos/ai-agent-roles/ai-agent-roles.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
@@ -518,6 +519,12 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// constructions compile unchanged; Nest always injects the real singleton, so
// reconcile sees the SAME in-memory active/zombie maps the runner mutates.
private readonly aiChatRunService?: AiChatRunService,
+ // #492 append-persist: per-step INSERT into the lightweight steps table (the
+ // O(Σ steps) replacement for the O(n²) full-row `metadata.parts` rewrite).
+ // OPTIONAL so existing positional constructions (int-specs) compile unchanged;
+ // Nest injects the real singleton. When ABSENT the per-step path falls back to
+ // the pre-#492 full-row flush (no regression, only no WAL win).
+ private readonly aiChatRunStepRepo?: AiChatRunStepRepo,
) {}
// #487: periodic reconcile timer (single-process phase 1). Started in
@@ -1114,8 +1121,34 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
chatId,
workspace.id,
);
+ // #492: HYDRATE needy assistant rows from the steps table BEFORE the replay
+ // map. A #492 mid-run assistant row carries only a step marker
+ // (metadata.parts:[]); its real per-step parts live in `ai_chat_run_steps`.
+ // The graceful terminal callbacks (onFinish/onError/onAbort -> flushAssistant)
+ // assemble the full inline parts, so a normally-ended turn already has them.
+ // But a HARD crash mid-run (SIGKILL/OOM) fires NO terminal callback, so the
+ // row stays parts:[]; without this, rowToUiMessage falls back to an empty
+ // text part and the partial tool-calls/results/text — durable in the steps
+ // table — would DROP OUT of the model's replay context (regressing #183
+ // step-granular durability for the model consumer). Mirrors the controller's
+ // withReconstructedParts EXACTLY (same needy predicate + hydration helper).
+ // Guarded on the optional repo: absent (positional test builds) degrades to
+ // the current behavior rather than crashing.
+ let replayHistory = oldHistory;
+ if (this.aiChatRunStepRepo) {
+ const needy = oldHistory.filter(
+ (r) => r.role === 'assistant' && !rowHasInlineParts(r),
+ );
+ if (needy.length > 0) {
+ const stepsByMessage = await this.aiChatRunStepRepo.findByMessageIds(
+ needy.map((r) => r.id),
+ workspace.id,
+ );
+ replayHistory = hydrateAssistantParts(oldHistory, stepsByMessage);
+ }
+ }
const uiMessages: Array & { id: string }> = [
- ...oldHistory.map(rowToUiMessage),
+ ...replayHistory.map(rowToUiMessage),
{
id: 'pending-user',
role: 'user',
@@ -1154,7 +1187,9 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// hint — confirm it against the persisted history (the preceding assistant
// turn must really be aborted/streaming) so a spoofed flag cannot inject the
// interrupt note onto an ordinary turn. The partial output the model needs is
- // already in `messages` (the aborted assistant row replays via findRecent).
+ // already in `messages`: a #492 mid-run row's per-step parts live only in the
+ // `ai_chat_run_steps` table and were hydrated into the replay history above,
+ // so the aborted assistant turn replays WITH its partial parts intact.
// Append the new user turn (shape-only) so index -2 is the prior assistant.
const interrupted = isInterruptResume(
[...oldHistory, { role: 'user', status: null, metadata: null }],
@@ -1559,17 +1594,57 @@ export class AiChatService implements OnModuleInit, OnModuleDestroy {
// connection when finalize runs, so the SQL `WHERE status='streaming'`
// (not this flag) is what prevents it clobbering the terminal row.
if (finalized) return null;
- // Build the flush ONCE so the returned count is EXACTLY the persisted
- // `stepsPersisted` (both derive from capturedSteps.length at this instant).
- const flushed = flushAssistant(capturedSteps, '', 'streaming', {
- pageChanged,
- partsCache,
- });
- const stepsPersisted = flushed.metadata.stepsPersisted as number;
+ // The count derives from capturedSteps.length at THIS instant, so the
+ // returned value is EXACTLY the persisted `stepsPersisted` the ring rotates
+ // on (whether we take the append-persist path or the legacy fallback).
+ const stepsPersisted = capturedSteps.length;
try {
- await this.aiChatMessageRepo.update(assistantId, workspace.id, flushed, {
- onlyIfStreaming: true,
- });
+ if (this.aiChatRunStepRepo) {
+ // #492 APPEND-PERSIST: write only THIS finished step's parts to the
+ // steps table (O(step) WAL), then bump the row's CHEAP step marker —
+ // NO growing `metadata.parts` blob (that O(n²) full-row rewrite is
+ // exactly what this removes). The full `metadata.parts` is assembled
+ // once at finalize; a mid-run resume seed is reconstructed from the
+ // step rows (reconstructRunParts). The INSERT is idempotent
+ // (ON CONFLICT DO NOTHING), so a re-fired step never doubles the parts.
+ const index = stepsPersisted - 1;
+ if (index >= 0) {
+ const stepParts = assistantParts(
+ [capturedSteps[index]],
+ '',
+ partsCache,
+ );
+ await this.aiChatRunStepRepo.insertStep(
+ assistantId,
+ workspace.id,
+ index,
+ stepParts,
+ );
+ }
+ // Marker UPDATE: advance stepsPersisted + keep the toolTrace era marker
+ // (bumps updatedAt so the delta poll observes the step, and carries the
+ // frontier a resuming client attaches from). Scoped onlyIfStreaming so a
+ // late marker never clobbers the terminal finalize.
+ await this.aiChatMessageRepo.update(
+ assistantId,
+ workspace.id,
+ { metadata: stepMarkerMetadata(stepsPersisted) },
+ { onlyIfStreaming: true },
+ );
+ } else {
+ // Legacy fallback (no steps table wired — positional test builds): the
+ // pre-#492 full-row flush, so parts still land inline on the row.
+ const flushed = flushAssistant(capturedSteps, '', 'streaming', {
+ pageChanged,
+ partsCache,
+ });
+ await this.aiChatMessageRepo.update(
+ assistantId,
+ workspace.id,
+ flushed,
+ { onlyIfStreaming: true },
+ );
+ }
return stepsPersisted;
} catch (err) {
this.logger.warn(
@@ -2749,6 +2824,122 @@ export function rowToUiMessage(row: AiChatMessage): Omit & {
return { id: row.id, role, parts: parts as UIMessage['parts'] };
}
+/**
+ * Cheap step-marker metadata for the #492 per-step UPDATE. Advances
+ * `stepsPersisted` (the resume attach frontier) and keeps the `toolTraceVersion`
+ * era marker, WITHOUT the growing `parts` blob (those live in the steps table
+ * now; the full `metadata.parts` is assembled once at finalize by flushAssistant).
+ * `parts: []` is kept for shape stability — it reads as an empty inline-parts row,
+ * which is exactly the discriminator that routes reconstruction to the steps table.
+ */
+export function stepMarkerMetadata(
+ stepsPersisted: number,
+): Record {
+ return { parts: [], toolTraceVersion: 2, stepsPersisted };
+}
+
+/**
+ * Whether an assistant row already carries its full UI parts INLINE on the row
+ * (`metadata.parts`). TRUE for every FINISHED row — old-era rows AND #492 rows,
+ * whose full parts are assembled once at finalize — and for old-era streaming
+ * snapshots (the pre-#492 per-step full-row flush). FALSE for a #492 MID-RUN
+ * record, whose per-step parts live in the `ai_chat_run_steps` table. This is the
+ * era discriminator the reconstruct seam branches on — no schema flag needed.
+ */
+export function rowHasInlineParts(row: { metadata?: unknown }): boolean {
+ const meta = (row.metadata ?? {}) as { parts?: unknown };
+ return Array.isArray(meta.parts) && meta.parts.length > 0;
+}
+
+/**
+ * Concatenate persisted per-step parts (in `stepIndex` order) into the turn's UI
+ * parts (#492). Reproduces EXACTLY what flushAssistant → assistantParts would have
+ * written to `metadata.parts` for those finished steps, since each step row stored
+ * `assistantParts([step])` at persist time.
+ */
+export function assembleStepParts(
+ stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
+): UIMessage['parts'] {
+ const parts: Array> = [];
+ for (const step of [...stepRows].sort((a, b) => a.stepIndex - b.stepIndex)) {
+ if (Array.isArray(step.parts)) {
+ parts.push(...(step.parts as Array>));
+ }
+ }
+ return parts as UIMessage['parts'];
+}
+
+/**
+ * reconstructRunParts (#492) — the single backend-switch seam. Given an assistant
+ * ROW and its persisted step rows, return the turn's UI `parts` + the persisted
+ * step count, reading from the ROW when it already carries inline parts (old-era
+ * records AND every finished record) and from the STEPS TABLE otherwise (a #492
+ * mid-run record). The higher-level consumers (attach seed, delta poll, export)
+ * route their row→parts through this / {@link hydrateAssistantParts}, so old and
+ * new records reconstruct identically WITHOUT the consumers branching on the era.
+ */
+export function reconstructRunParts(
+ row: { metadata?: unknown; content?: string | null },
+ stepRows: ReadonlyArray<{ stepIndex: number; parts: unknown }>,
+): { parts: UIMessage['parts']; stepsPersisted: number } {
+ if (rowHasInlineParts(row)) {
+ const meta = row.metadata as {
+ parts: UIMessage['parts'];
+ stepsPersisted?: number;
+ };
+ return {
+ parts: meta.parts,
+ stepsPersisted:
+ typeof meta.stepsPersisted === 'number'
+ ? meta.stepsPersisted
+ : stepRows.length,
+ };
+ }
+ if (stepRows.length > 0) {
+ return {
+ parts: assembleStepParts(stepRows),
+ stepsPersisted: stepRows.length,
+ };
+ }
+ // No inline parts and no step rows: an old-era seed / empty streaming row. Fall
+ // back to a single text part from `content` (mirrors rowToUiMessage).
+ return {
+ parts: textPart(row.content ?? '') as UIMessage['parts'],
+ stepsPersisted: 0,
+ };
+}
+
+/**
+ * Fill each assistant row's `metadata.parts` from its step rows when the row does
+ * not already carry them inline (a #492 mid-run record), so a consumer that reads
+ * `metadata.parts` off the RAW row (the client seed/poll, the Markdown export)
+ * sees the reconstructed parts with NO change to itself. Rows that already have
+ * inline parts (old-era + finished) and non-assistant rows pass through untouched.
+ * Pure: returns new row objects, never mutates the inputs.
+ */
+export function hydrateAssistantParts<
+ T extends { id: string; role?: string; metadata?: unknown },
+>(
+ rows: ReadonlyArray,
+ stepsByMessage: Map<
+ string,
+ ReadonlyArray<{ stepIndex: number; parts: unknown }>
+ >,
+): T[] {
+ return rows.map((row) => {
+ if (row.role !== 'assistant' || rowHasInlineParts(row)) return row;
+ const steps = stepsByMessage.get(row.id);
+ if (!steps || steps.length === 0) return row;
+ return {
+ ...row,
+ metadata: {
+ ...((row.metadata ?? {}) as Record),
+ parts: assembleStepParts(steps),
+ },
+ };
+ });
+}
+
/**
* The persisted-row patch shape produced by {@link flushAssistant}. It is the
* SAME shape the assistant repo insert/update consume (content + toolCalls +
diff --git a/apps/server/src/database/database.module.ts b/apps/server/src/database/database.module.ts
index 8d16e5f9..22b4962c 100644
--- a/apps/server/src/database/database.module.ts
+++ b/apps/server/src/database/database.module.ts
@@ -32,6 +32,7 @@ import { TemplateRepo } from '@docmost/db/repos/template/template.repo';
import { AiChatRepo } from '@docmost/db/repos/ai-chat/ai-chat.repo';
import { AiChatMessageRepo } from '@docmost/db/repos/ai-chat/ai-chat-message.repo';
import { AiChatRunRepo } from '@docmost/db/repos/ai-chat/ai-chat-run.repo';
+import { AiChatRunStepRepo } from '@docmost/db/repos/ai-chat/ai-chat-run-step.repo';
import { AiChatPageSnapshotRepo } from '@docmost/db/repos/ai-chat/ai-chat-page-snapshot.repo';
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { AiMcpServerRepo } from '@docmost/db/repos/ai-chat/ai-mcp-server.repo';
@@ -125,6 +126,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
AiChatRepo,
AiChatMessageRepo,
AiChatRunRepo,
+ AiChatRunStepRepo,
AiChatPageSnapshotRepo,
AiProviderCredentialsRepo,
AiMcpServerRepo,
@@ -161,6 +163,7 @@ import { firstSqlToken } from '../integrations/metrics/metrics.constants';
AiChatRepo,
AiChatMessageRepo,
AiChatRunRepo,
+ AiChatRunStepRepo,
AiChatPageSnapshotRepo,
AiProviderCredentialsRepo,
AiMcpServerRepo,
diff --git a/apps/server/src/database/migrations/20260708T120000-ai-chat-run-steps.ts b/apps/server/src/database/migrations/20260708T120000-ai-chat-run-steps.ts
new file mode 100644
index 00000000..e576b09e
--- /dev/null
+++ b/apps/server/src/database/migrations/20260708T120000-ai-chat-run-steps.ts
@@ -0,0 +1,70 @@
+import { type Kysely, sql } from 'kysely';
+
+/**
+ * `ai_chat_run_steps` — append-only per-step persistence for an assistant turn
+ * (#492 wave C). Each finished agent step's UI `parts` (its text part + a part
+ * per tool call, WITH the tool output) is INSERTed as its own lightweight row the
+ * moment the step ends, instead of REWRITING the whole assistant row's growing
+ * `metadata.parts` jsonb on every `onStepFinish`.
+ *
+ * WHY a separate table + INSERT (not a jsonb `||` append on the message row): a
+ * Postgres jsonb UPDATE rewrites the ENTIRE TOASTed row version under MVCC, so
+ * re-persisting a growing `metadata.parts` on every step is O(n²) write volume
+ * (a 50-step run with ~100 KB tool outputs wrote hundreds of MB of WAL / dead
+ * tuples per turn, hammering autovacuum). `||` would only shave the network
+ * payload — the WAL/TOAST rewrite harm remains. An INSERT into a per-step table
+ * writes ONLY that step's bytes, so the per-turn write volume is O(Σ steps).
+ *
+ * The full `metadata.parts` on the message row is assembled ONCE at finalize (the
+ * terminal completed/error/aborted write). Mid-run, a resuming client's seed is
+ * reconstructed by concatenating these step rows in `step_index` order — which
+ * reproduces exactly what the old per-step full-row rewrite persisted. Records
+ * written the OLD way (full `metadata.parts` on the row, no step rows) still
+ * reconstruct from the row unchanged; the two eras are distinguished by whether
+ * the row already carries non-empty `metadata.parts` (see reconstructRunParts /
+ * assembleStepParts in ai-chat.service.ts).
+ *
+ * ON DELETE CASCADE on `message_id`: the step rows are a derived projection of the
+ * assistant message; they must vanish with it (or with its workspace).
+ */
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('ai_chat_run_steps')
+ .ifNotExists()
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ // The assistant message row this step belongs to (the #183 projection). The
+ // step rows are a derived, per-step slice of that message, so they cascade.
+ .addColumn('message_id', 'uuid', (col) =>
+ col.references('ai_chat_messages.id').onDelete('cascade').notNull(),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.references('workspaces.id').onDelete('cascade').notNull(),
+ )
+ // 0-based index of the finished step within the turn. Ordering key for
+ // reconstruction; unique per message (idempotent step re-persist).
+ .addColumn('step_index', 'integer', (col) => col.notNull())
+ // The step's UI parts (text part + a `tool-*` part per call, WITH output).
+ // Concatenated in step order to rebuild the turn's `metadata.parts`.
+ .addColumn('parts', 'jsonb', (col) => col.notNull())
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .execute();
+
+ // Idempotent per-step persist: a retried INSERT of the same (message, step)
+ // is a no-op (the service uses ON CONFLICT DO NOTHING). This also serves the
+ // reconstruction read (WHERE message_id ORDER BY step_index).
+ await db.schema
+ .createIndex('ai_chat_run_steps_message_step_uidx')
+ .ifNotExists()
+ .on('ai_chat_run_steps')
+ .columns(['message_id', 'step_index'])
+ .unique()
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('ai_chat_run_steps').ifExists().execute();
+}
diff --git a/apps/server/src/database/repos/ai-chat/ai-chat-run-step.repo.ts b/apps/server/src/database/repos/ai-chat/ai-chat-run-step.repo.ts
new file mode 100644
index 00000000..8075c7ab
--- /dev/null
+++ b/apps/server/src/database/repos/ai-chat/ai-chat-run-step.repo.ts
@@ -0,0 +1,95 @@
+import { Injectable } from '@nestjs/common';
+import { InjectKysely } from 'nestjs-kysely';
+import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
+import { dbOrTx } from '../../utils';
+import { AiChatRunStep } from '@docmost/db/types/entity.types';
+
+/**
+ * Append-only per-step persistence for an assistant turn (#492). Each finished
+ * agent step's UI `parts` (its text part + a `tool-*` part per call, WITH the
+ * tool output) is INSERTed as its own lightweight row the moment the step ends —
+ * instead of REWRITING the assistant row's growing `metadata.parts` jsonb on every
+ * `onStepFinish` (a Postgres jsonb UPDATE rewrites the whole TOASTed row version
+ * under MVCC, so that was O(n²) WAL/dead-tuple churn per turn).
+ *
+ * The full `metadata.parts` on the message row is assembled ONCE at finalize;
+ * mid-run, a resuming client's seed is rebuilt from these rows in `stepIndex`
+ * order (see `assembleStepParts` / the reconstruct seam in ai-chat.service.ts).
+ * Every method is workspace-scoped as defense-in-depth.
+ */
+@Injectable()
+export class AiChatRunStepRepo {
+ constructor(@InjectKysely() private readonly db: KyselyDB) {}
+
+ /**
+ * Append one finished step's parts. Idempotent: a retried persist of the SAME
+ * (message, stepIndex) is a no-op via ON CONFLICT DO NOTHING — the per-step
+ * writes are fired fire-and-forget + serialized, and a duplicate must never
+ * throw into the stream or double the parts. Returns whether a NEW row landed
+ * (false = the step was already persisted).
+ */
+ async insertStep(
+ messageId: string,
+ workspaceId: string,
+ stepIndex: number,
+ parts: unknown,
+ trx?: KyselyTransaction,
+ ): Promise {
+ const db = dbOrTx(this.db, trx);
+ const inserted = await db
+ .insertInto('aiChatRunSteps')
+ .values({
+ messageId,
+ workspaceId,
+ stepIndex,
+ // jsonb column: cast through never (same pattern as the message repo).
+ parts: parts as never,
+ })
+ .onConflict((oc) => oc.columns(['messageId', 'stepIndex']).doNothing())
+ .returning('id')
+ .executeTakeFirst();
+ return inserted !== undefined;
+ }
+
+ /** All persisted steps for ONE assistant message, in step order. */
+ async findByMessage(
+ messageId: string,
+ workspaceId: string,
+ ): Promise {
+ return this.db
+ .selectFrom('aiChatRunSteps')
+ .selectAll('aiChatRunSteps')
+ .where('messageId', '=', messageId)
+ .where('workspaceId', '=', workspaceId)
+ .orderBy('stepIndex', 'asc')
+ .execute();
+ }
+
+ /**
+ * All persisted steps for a SET of assistant messages, grouped by messageId
+ * (each group in step order). One query for the batch — the hydration seam
+ * (getMessages / delta / export) calls this only for the rows that actually
+ * need reconstruction (an active new-style row whose `metadata.parts` is still
+ * empty), which is usually none, so this is skipped on the common path.
+ */
+ async findByMessageIds(
+ messageIds: string[],
+ workspaceId: string,
+ ): Promise