Files
paseo/packages/app/src/agent-stream/presentation.ts
T
paseo-bot[bot] f4ba16a0b3 fix(app): keep line breaks in streamed replies when a chunk ends in indentation (#5577)
While a reply streams, presentation reparses only the growing last Markdown
block: the cached block text plus the new chunk. splitMarkdownBlocks drops a
trailing whitespace-only line, and only trailing "\n"s were added back, so a
reply that paused on "]\n   " cached the block as "]". The next chunk " B -->"
then continued the previous line, joining two lines of a code block. Codex
streams a line break and the next line's indent as separate deltas, so its
Mermaid diagrams hit this and failed to render. History reparses the whole
text, which is why a reload showed the reply correctly.

Keep every trailing blank line the split drops on the last block.

Closes #5456

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-09-28 22:09:16 +02:00

185 lines
7.7 KiB
TypeScript

import type { AssistantMessageItem, StreamItem, UserMessageItem } from "@/types/stream";
import type { TimelineItemTransform } from "@/plugins/timeline/model";
import { projectPluginTimelineItems } from "@/plugins/timeline/projection";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import {
prepareToolCallHistory,
projectToolCallDetailLevel,
type PreparedToolCallHistory,
type ToolCallDetailLevel,
} from "@/tool-calls/detail-level/projection";
interface PresentationInput {
tail: StreamItem[];
head: StreamItem[];
transform: TimelineItemTransform | undefined;
level: ToolCallDetailLevel;
isTurnActive: boolean;
}
function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
return previous.length === next.length && previous.every((item, index) => item === next[index])
? previous
: next;
}
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
* addresses a message — find, scroll-to-message, history reveal — asks for this.
*/
export function getStreamItemMessageId(item: StreamItem): string {
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
}
/**
* A block is reusable only when it still stands for the same source state. Text and
* turn are what the reader sees; cursor and timestamp are what the timeline reads back
* through the row — a reused block carrying a pre-canonical cursor reports a stale
* reading position and weakens fork-boundary resolution.
*/
function isReusableBlock(block: AssistantMessageItem, source: AssistantMessageItem, id: string) {
return (
block.id === id &&
block.turnId === source.turnId &&
block.timestamp.getTime() === source.timestamp.getTime() &&
block.timelineCursor?.epoch === source.timelineCursor?.epoch &&
block.timelineCursor?.seq === source.timelineCursor?.seq
);
}
/** Source messages reach plugins before any Markdown splitting or Overview grouping. */
export function createStreamPresentation() {
const userMessageCache = new WeakMap<UserMessageItem, UserMessageItem>();
function presentUserMessage(item: UserMessageItem): UserMessageItem {
const cached = userMessageCache.get(item);
if (cached) return cached;
// Recognize the complete voice envelope, including older messages without the
// instruction suffix. Leave quoted examples and unrelated XML untouched.
const spokenInput =
/^\s*<spoken-input>([\s\S]*?)<\/spoken-input>(?:\s*<instruction>This message was spoken by the user\.[\s\S]*?<\/instruction>)?\s*$/.exec(
item.text,
);
const projected = spokenInput ? { ...item, text: spokenInput[1].trim() } : item;
userMessageCache.set(item, projected);
return projected;
}
const blocksBySource = new WeakMap<AssistantMessageItem, AssistantMessageItem[]>();
let liveSources = new Map<string, AssistantMessageItem>();
let historySource: StreamItem[] | undefined;
let historyTransform: TimelineItemTransform | undefined;
let historyRows: StreamItem[] = [];
let displayTail: StreamItem[] = [];
let displayHistory: StreamItem[] | undefined;
let promotedRows: StreamItem[] = [];
let preparedTail: StreamItem[] | undefined;
let preparedLevel: ToolCallDetailLevel | undefined;
let preparedHistory: PreparedToolCallHistory | null = null;
/**
* One display row per Markdown block. Each block renders on its own, so a construct
* that needs context from another block does not resolve: `splitMarkdownBlocks` keeps
* link reference definitions with the block that uses them, but a reference pointing
* at a definition several blocks away renders as literal text. Streamed messages
* always behaved this way; history now matches them.
*/
function nativeBlocks(item: StreamItem): StreamItem[] {
if (item.kind === "user_message") return [presentUserMessage(item)];
if (item.kind !== "assistant_message") return [item];
const cached = blocksBySource.get(item);
if (cached) return cached;
const previousSource = liveSources.get(item.id);
const previous = previousSource && blocksBySource.get(previousSource);
// Parse only the growing last block on append, as the old live reducer did.
// Canonical text replacements are parsed afresh instead of joining fragments.
const isAppend = previousSource && previous && item.text.startsWith(previousSource.text);
let prefix: AssistantMessageItem[] = [];
let growingText = item.text;
if (isAppend) {
prefix = previous.slice(0, -1);
growingText =
previous[previous.length - 1]!.text + item.text.slice(previousSource.text.length);
}
const parsed = splitMarkdownBlocks(growingText);
// Whitespace-only text has no block, and a message still owns exactly one row.
const textBlocks = parsed.length > 0 || prefix.length > 0 ? parsed : [""];
const blocks = [...prefix];
for (const [offset, text] of textBlocks.entries()) {
const index = prefix.length + offset;
let blockText = text;
if (offset === textBlocks.length - 1) {
// The split drops trailing blank lines, including a line break followed only
// by the next line's indent. Keep them so the next append continues that line.
const trailingBlankLines = /\n\s*$/.exec(item.text)?.[0] ?? "";
blockText += trailingBlankLines;
}
const existing = previous?.[index];
const id = `${item.id}:block:${index}`;
if (existing?.text === blockText && isReusableBlock(existing, item, id)) {
blocks.push(existing);
continue;
}
blocks.push({
...item,
id,
blockGroupId: item.id,
blockIndex: index,
text: blockText,
});
}
blocksBySource.set(item, blocks);
return blocks;
}
return (input: PresentationInput) => {
// Retained history is not reprojected or regrouped on each live text update.
if (historySource !== input.tail || historyTransform !== input.transform) {
// One rendering path: history splits the same way the live head does, and
// `blocksBySource` keeps both the live block identities and the split cost
// from being paid again when only the tail's array identity changed.
historyRows = projectPluginTimelineItems(input.tail, input.transform).flatMap<StreamItem>(
nativeBlocks,
);
historySource = input.tail;
historyTransform = input.transform;
}
const head: StreamItem[] = [];
const promoted: StreamItem[] = [];
const nextLiveSources = new Map<string, AssistantMessageItem>();
for (const item of projectPluginTimelineItems(input.head, input.transform, "streaming")) {
const blocks = nativeBlocks(item);
if (item.kind === "assistant_message") nextLiveSources.set(item.id, item);
if (item.kind === "assistant_message" && blocks.length > 1) {
promoted.push(...blocks.slice(0, -1));
head.push(blocks[blocks.length - 1]!);
} else {
head.push(...blocks);
}
}
liveSources = nextLiveSources;
const nextPromoted = retainItems(promotedRows, promoted);
if (displayHistory !== historyRows || nextPromoted !== promotedRows) {
displayTail = historyRows;
if (nextPromoted.length > 0) displayTail = [...historyRows, ...nextPromoted];
displayHistory = historyRows;
promotedRows = nextPromoted;
}
if (preparedTail !== displayTail || preparedLevel !== input.level) {
preparedHistory = prepareToolCallHistory(input.level, displayTail);
preparedTail = displayTail;
preparedLevel = input.level;
}
return projectToolCallDetailLevel({
level: input.level,
tail: displayTail,
head,
preparedHistory,
isTurnActive: input.isTurnActive,
});
};
}