Skip to content

Commit 17a285f

Browse files
authored
fix(ui): preserve visible chat stream text
Fix WebChat stream/history reconciliation so visible assistant text survives stale history reloads, tool-history catch-up, and terminal final/error/abort events.\n\nRefactors the UI path into stream reconciliation, stream text, and typed tool-message helpers so persisted history and live stream state use the same matching rules.\n\nCloses #67035.
1 parent c2d7b4a commit 17a285f

13 files changed

Lines changed: 1882 additions & 97 deletions

src/chat/tool-content.ts

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,16 @@
1+
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
2+
3+
const TOOL_USE_ID_FIELDS = [
4+
"id",
5+
"tool_call_id",
6+
"toolCallId",
7+
"tool_use_id",
8+
"toolUseId",
9+
] as const;
10+
type ToolUseIdField = (typeof TOOL_USE_ID_FIELDS)[number];
11+
112
/** Provider-agnostic chat content block shape used before SDK-specific narrowing. */
2-
export type ToolContentBlock = Record<string, unknown>;
13+
export type ToolContentBlock = Record<string, unknown> & Partial<Record<ToolUseIdField, unknown>>;
314

415
function normalizeToolContentType(value: unknown): string {
516
return typeof value === "string" ? value.toLowerCase() : "";
@@ -34,9 +45,11 @@ export function resolveToolBlockArgs(block: ToolContentBlock): unknown {
3445

3546
/** Reads the stable tool-use id across snake_case and camelCase provider field names. */
3647
export function resolveToolUseId(block: ToolContentBlock): string | undefined {
37-
const id =
38-
(typeof block.id === "string" && block.id.trim()) ||
39-
(typeof block.tool_use_id === "string" && block.tool_use_id.trim()) ||
40-
(typeof block.toolUseId === "string" && block.toolUseId.trim());
41-
return id || undefined;
48+
for (const field of TOOL_USE_ID_FIELDS) {
49+
const id = normalizeOptionalString(block[field]);
50+
if (id) {
51+
return id;
52+
}
53+
}
54+
return undefined;
4255
}

ui/src/ui/app-chat.test.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,7 @@ describe("refreshChat", () => {
421421
expect(requestUpdate).toHaveBeenCalled();
422422
});
423423

424-
it("records chat history timing when a reload resets active stream state", async () => {
424+
it("records chat history timing when a reload keeps active stream state visible", async () => {
425425
const request = vi.fn((method: string) => {
426426
if (method === "chat.history") {
427427
return Promise.resolve({
@@ -440,21 +440,14 @@ describe("refreshChat", () => {
440440

441441
await refreshChat(host, { awaitHistory: true, scheduleScroll: false });
442442

443-
expect(host.chatStream).toBeNull();
443+
expect(host.chatStream).toBe("partial");
444444
expect(eventPayloads(host, "control-ui.chat.history")).toEqual(
445445
expect.arrayContaining([
446446
expect.objectContaining({
447447
phase: "start",
448448
sessionKey: "main",
449449
previousRunId: "run-main",
450450
}),
451-
expect.objectContaining({
452-
phase: "stream-reset",
453-
sessionKey: "main",
454-
previousRunId: "run-main",
455-
activeRunId: "run-main",
456-
visibleMessageCount: 1,
457-
}),
458451
expect.objectContaining({
459452
phase: "applied",
460453
sessionKey: "main",

ui/src/ui/app-tool-stream.node.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,34 @@ describe("app-tool-stream fallback lifecycle handling", () => {
286286
expect(host.chatModelOverrides?.main).toBeNull();
287287
});
288288

289+
it("tags stream segments with the tool they precede", () => {
290+
useToolStreamFakeTimers();
291+
const host = createHost({
292+
chatRunId: "run-1",
293+
chatStream: "visible text before tool",
294+
chatStreamStartedAt: TOOL_STREAM_TEST_NOW - 10,
295+
});
296+
297+
handleAgentEvent(host, {
298+
runId: "run-1",
299+
seq: 1,
300+
stream: "tool",
301+
ts: Date.now(),
302+
sessionKey: "main",
303+
data: {
304+
phase: "start",
305+
name: "exec",
306+
toolCallId: "call_1",
307+
},
308+
});
309+
310+
expect(host.chatStreamSegments).toEqual([
311+
{ text: "visible text before tool", ts: TOOL_STREAM_TEST_NOW, toolCallId: "call_1" },
312+
]);
313+
expect(host.chatStream).toBeNull();
314+
vi.useRealTimers();
315+
});
316+
289317
it("records tool activity summaries without storing raw argument values", () => {
290318
useToolStreamFakeTimers();
291319
const host = createHost();

ui/src/ui/app-tool-stream.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ type ToolStreamHost = {
6060
chatRunId: string | null;
6161
chatStream: string | null;
6262
chatStreamStartedAt: number | null;
63-
chatStreamSegments: Array<{ text: string; ts: number }>;
63+
chatStreamSegments: Array<{ text: string; ts: number; toolCallId?: string }>;
6464
toolStreamById: Map<string, ToolStreamEntry>;
6565
toolStreamOrder: string[];
6666
chatToolMessages: Record<string, unknown>[];
@@ -791,7 +791,10 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
791791
host.chatStream &&
792792
host.chatStream.trim().length > 0
793793
) {
794-
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
794+
host.chatStreamSegments = [
795+
...host.chatStreamSegments,
796+
{ text: host.chatStream, ts: now, toolCallId },
797+
];
795798
host.chatStream = null;
796799
host.chatStreamStartedAt = null;
797800
}

ui/src/ui/chat/build-chat-items.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -494,6 +494,31 @@ describe("buildChatItems", () => {
494494
expect(messageRecord(requireGroup(items[1])).content).toBe("Missing timestamp.");
495495
});
496496

497+
it("renders an active stream after the persisted user turn it answers", () => {
498+
const items = buildChatItems(
499+
createProps({
500+
messages: [
501+
{
502+
role: "user",
503+
content: [{ type: "text", text: "Persisted prompt." }],
504+
timestamp: 2_000,
505+
},
506+
],
507+
stream: "Visible partial answer.",
508+
streamStartedAt: 1_000,
509+
}),
510+
);
511+
512+
expect(items).toHaveLength(2);
513+
expect(requireGroup(items[0]).role).toBe("user");
514+
expect(items[1]).toMatchObject({
515+
kind: "stream",
516+
text: "Visible partial answer.",
517+
startedAt: 2_001,
518+
isStreaming: true,
519+
});
520+
});
521+
497522
it("renders submitted queued sends as user turns before chat.send ACK", () => {
498523
const groups = messageGroups({
499524
messages: [{ role: "assistant", content: "Ready.", timestamp: 1 }],

ui/src/ui/chat/build-chat-items.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { extractTextCached } from "./message-extract.ts";
99
import { normalizeMessage, stripMessageDisplayMetadataText } from "./message-normalizer.ts";
1010
import { normalizeRoleForGrouping } from "./role-normalizer.ts";
1111
import { messageMatchesSearchQuery } from "./search-match.ts";
12+
import { trimAccumulatedStreamPrefix } from "./stream-text.ts";
1213
import { extractToolCardsCached, extractToolPreview } from "./tool-cards.ts";
1314
import { buildUserChatMessageContentBlocks } from "./user-message-content.ts";
1415

@@ -300,13 +301,6 @@ function sanitizeStreamText(text: string): string {
300301
return stripped.trim().length > 0 ? stripped : "";
301302
}
302303

303-
function trimAccumulatedStreamPrefix(text: string, previousText: string | null): string {
304-
if (!previousText || !text.startsWith(previousText)) {
305-
return text;
306-
}
307-
return text.slice(previousText.length).trimStart();
308-
}
309-
310304
function shouldRenderQueuedSendInThread(item: ChatQueueItem): boolean {
311305
if (typeof item.sendSubmittedAtMs !== "number" || item.sendState === "failed") {
312306
return false;
@@ -356,6 +350,19 @@ function chatItemTimestamp(item: ChatItem): number | null {
356350
return null;
357351
}
358352

353+
function timestampAfterVisibleItems(items: ChatItem[], desiredTimestamp: number): number {
354+
const latestTimestamp = items.reduce<number | null>((latest, item) => {
355+
const timestamp = chatItemTimestamp(item);
356+
if (timestamp == null) {
357+
return latest;
358+
}
359+
return latest == null || timestamp > latest ? timestamp : latest;
360+
}, null);
361+
return latestTimestamp != null && desiredTimestamp <= latestTimestamp
362+
? latestTimestamp + 1
363+
: desiredTimestamp;
364+
}
365+
359366
function sortChatItemsByVisibleTime(items: ChatItem[]): ChatItem[] {
360367
return items
361368
.map((item, index) => ({ item, index, timestamp: chatItemTimestamp(item) }))
@@ -667,13 +674,14 @@ export function buildChatItems(props: BuildChatItemsProps): Array<ChatItem | Mes
667674
const key = `stream:${props.sessionKey}:${props.streamStartedAt ?? "live"}`;
668675
const text = sanitizeStreamText(props.stream);
669676
const visibleText = trimAccumulatedStreamPrefix(text, previousAccumulatedStreamText);
677+
const startedAt = timestampAfterVisibleItems(items, props.streamStartedAt ?? Date.now());
670678
if (visibleText.length > 0) {
671679
if (!stripHeartbeatTokenForDisplay(visibleText).shouldSkip) {
672680
items.push({
673681
kind: "stream",
674682
key,
675683
text: visibleText,
676-
startedAt: props.streamStartedAt ?? Date.now(),
684+
startedAt,
677685
isStreaming: true,
678686
});
679687
}

0 commit comments

Comments
 (0)