Skip to content

Commit de2ccff

Browse files
steipetejakepresent
andcommitted
fix(ui): stream tool events live in control chat (#39104)
Land #39104 by @jakepresent. Co-authored-by: Jake Present <jakepresent@microsoft.com>
1 parent 499c1ee commit de2ccff

9 files changed

Lines changed: 94 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,7 @@ Docs: https://docs.openclaw.ai
257257
- Extensions/diffs CI stability: add `headers` to the `localReq` test helper in `extensions/diffs/index.test.ts` so forwarding-hint checks no longer crash with `req.headers` undefined. (supersedes #39063) Thanks @Shennng.
258258
- Agents/compaction thresholding: apply `agents.defaults.contextTokens` cap to the model passed into embedded run and `/compact` session creation so auto-compaction thresholds use the effective context window, not native model max context. (#39099) Thanks @MumuTW.
259259
- Models/merge mode provider precedence: when `models.mode: "merge"` is active and config explicitly sets a provider `baseUrl`, keep config as source of truth instead of preserving stale runtime `models.json` `baseUrl` values; includes normalized provider-key coverage. (#39103) Thanks @BigUncle.
260+
- UI/Control chat tool streaming: render tool events live in webchat without requiring refresh by enabling `tool-events` capability, fixing stream/event correlation, and resetting/reloading stream state around tool results and terminal events. (#39104) Thanks @jakepresent.
260261

261262
## 2026.3.2
262263

ui/src/ui/app-gateway.ts

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -258,22 +258,31 @@ function handleTerminalChatEvent(
258258
host: GatewayHost,
259259
payload: ChatEventPayload | undefined,
260260
state: ReturnType<typeof handleChatEvent>,
261-
) {
261+
): boolean {
262262
if (state !== "final" && state !== "error" && state !== "aborted") {
263-
return;
263+
return false;
264264
}
265-
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
265+
// Check if tool events were seen before resetting (resetToolStream clears toolStreamOrder).
266+
const toolHost = host as unknown as Parameters<typeof resetToolStream>[0];
267+
const hadToolEvents = toolHost.toolStreamOrder.length > 0;
268+
resetToolStream(toolHost);
266269
void flushChatQueueForEvent(host as unknown as Parameters<typeof flushChatQueueForEvent>[0]);
267270
const runId = payload?.runId;
268-
if (!runId || !host.refreshSessionsAfterChat.has(runId)) {
269-
return;
271+
if (runId && host.refreshSessionsAfterChat.has(runId)) {
272+
host.refreshSessionsAfterChat.delete(runId);
273+
if (state === "final") {
274+
void loadSessions(host as unknown as OpenClawApp, {
275+
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
276+
});
277+
}
270278
}
271-
host.refreshSessionsAfterChat.delete(runId);
272-
if (state === "final") {
273-
void loadSessions(host as unknown as OpenClawApp, {
274-
activeMinutes: CHAT_SESSIONS_ACTIVE_MINUTES,
275-
});
279+
// Reload history when tools were used so the persisted tool results
280+
// replace the now-cleared streaming state.
281+
if (hadToolEvents && state === "final") {
282+
void loadChatHistory(host as unknown as OpenClawApp);
283+
return true;
276284
}
285+
return false;
277286
}
278287

279288
function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | undefined) {
@@ -284,8 +293,8 @@ function handleChatGatewayEvent(host: GatewayHost, payload: ChatEventPayload | u
284293
);
285294
}
286295
const state = handleChatEvent(host as unknown as OpenClawApp, payload);
287-
handleTerminalChatEvent(host, payload, state);
288-
if (state === "final" && shouldReloadHistoryForFinalEvent(payload)) {
296+
const historyReloaded = handleTerminalChatEvent(host, payload, state);
297+
if (state === "final" && !historyReloaded && shouldReloadHistoryForFinalEvent(payload)) {
289298
void loadChatHistory(host as unknown as OpenClawApp);
290299
}
291300
}
@@ -307,6 +316,17 @@ function handleGatewayEventUnsafe(host: GatewayHost, evt: GatewayEventFrame) {
307316
host as unknown as Parameters<typeof handleAgentEvent>[0],
308317
evt.payload as AgentEventPayload | undefined,
309318
);
319+
// Reload history after each tool result so the persisted text + tool
320+
// output replaces any truncated streaming fragments.
321+
const agentPayload = evt.payload as AgentEventPayload | undefined;
322+
const toolData = agentPayload?.data;
323+
if (
324+
agentPayload?.stream === "tool" &&
325+
typeof toolData?.phase === "string" &&
326+
toolData.phase === "result"
327+
) {
328+
void loadChatHistory(host as unknown as OpenClawApp);
329+
}
310330
return;
311331
}
312332

ui/src/ui/app-render.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1029,6 +1029,7 @@ export function renderApp(state: AppViewState) {
10291029
assistantAvatarUrl: chatAvatarUrl,
10301030
messages: state.chatMessages,
10311031
toolMessages: state.chatToolMessages,
1032+
streamSegments: state.chatStreamSegments,
10321033
stream: state.chatStream,
10331034
streamStartedAt: state.chatStreamStartedAt,
10341035
draft: state.chatMessage,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ function createHost(overrides?: Partial<MutableHost>): MutableHost {
1313
return {
1414
sessionKey: "main",
1515
chatRunId: null,
16+
chatStream: null,
17+
chatStreamStartedAt: null,
18+
chatStreamSegments: [],
1619
toolStreamById: new Map<string, ToolStreamEntry>(),
1720
toolStreamOrder: [],
1821
chatToolMessages: [],

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ export type ToolStreamEntry = {
2828
type ToolStreamHost = {
2929
sessionKey: string;
3030
chatRunId: string | null;
31+
chatStream: string | null;
32+
chatStreamStartedAt: number | null;
33+
chatStreamSegments: Array<{ text: string; ts: number }>;
3134
toolStreamById: Map<string, ToolStreamEntry>;
3235
toolStreamOrder: string[];
3336
chatToolMessages: Record<string, unknown>[];
@@ -231,10 +234,14 @@ export function scheduleToolStreamSync(host: ToolStreamHost, force = false) {
231234
}
232235

233236
export function resetToolStream(host: ToolStreamHost) {
237+
if (host.toolStreamSyncTimer != null) {
238+
clearTimeout(host.toolStreamSyncTimer);
239+
host.toolStreamSyncTimer = null;
240+
}
234241
host.toolStreamById.clear();
235242
host.toolStreamOrder = [];
236243
host.chatToolMessages = [];
237-
flushToolStreamSync(host);
244+
host.chatStreamSegments = [];
238245
}
239246

240247
export type CompactionStatus = {
@@ -401,11 +408,14 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
401408
if (payload.stream !== "tool") {
402409
return;
403410
}
404-
const accepted = resolveAcceptedSession(host, payload);
405-
if (!accepted.accepted) {
411+
412+
// Filter by session only. Don't check chatRunId because the client sets it
413+
// to a client-generated UUID (via generateUUID in sendChatMessage), while
414+
// tool events arrive with the server's engine runId — they can never match.
415+
const sessionKey = typeof payload.sessionKey === "string" ? payload.sessionKey : undefined;
416+
if (sessionKey && sessionKey !== host.sessionKey) {
406417
return;
407418
}
408-
const sessionKey = accepted.sessionKey;
409419

410420
const data = payload.data ?? {};
411421
const toolCallId = typeof data.toolCallId === "string" ? data.toolCallId : "";
@@ -425,6 +435,13 @@ export function handleAgentEvent(host: ToolStreamHost, payload?: AgentEventPaylo
425435
const now = Date.now();
426436
let entry = host.toolStreamById.get(toolCallId);
427437
if (!entry) {
438+
// Commit any in-progress streaming text as a segment so it renders
439+
// above the tool card instead of below it.
440+
if (host.chatStream && host.chatStream.trim().length > 0) {
441+
host.chatStreamSegments = [...host.chatStreamSegments, { text: host.chatStream, ts: now }];
442+
host.chatStream = null;
443+
host.chatStreamStartedAt = null;
444+
}
428445
entry = {
429446
toolCallId,
430447
runId: payload.runId,

ui/src/ui/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ export class OpenClawApp extends LitElement {
144144
@state() chatMessage = "";
145145
@state() chatMessages: unknown[] = [];
146146
@state() chatToolMessages: unknown[] = [];
147+
@state() chatStreamSegments: Array<{ text: string; ts: number }> = [];
147148
@state() chatStream: string | null = null;
148149
@state() chatStreamStartedAt: number | null = null;
149150
@state() chatRunId: string | null = null;

ui/src/ui/controllers/chat.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { resetToolStream } from "../app-tool-stream.ts";
12
import { extractText } from "../chat/message-extract.ts";
23
import type { GatewayBrowserClient } from "../gateway.ts";
34
import type { ChatAttachment } from "../ui-types.ts";
@@ -50,6 +51,18 @@ export type ChatEventPayload = {
5051
errorMessage?: string;
5152
};
5253

54+
function maybeResetToolStream(state: ChatState) {
55+
const toolHost = state as ChatState & Partial<Parameters<typeof resetToolStream>[0]>;
56+
if (
57+
toolHost.toolStreamById instanceof Map &&
58+
Array.isArray(toolHost.toolStreamOrder) &&
59+
Array.isArray(toolHost.chatToolMessages) &&
60+
Array.isArray(toolHost.chatStreamSegments)
61+
) {
62+
resetToolStream(toolHost as Parameters<typeof resetToolStream>[0]);
63+
}
64+
}
65+
5366
export async function loadChatHistory(state: ChatState) {
5467
if (!state.client || !state.connected) {
5568
return;
@@ -67,6 +80,11 @@ export async function loadChatHistory(state: ChatState) {
6780
const messages = Array.isArray(res.messages) ? res.messages : [];
6881
state.chatMessages = messages.filter((message) => !isAssistantSilentReply(message));
6982
state.chatThinkingLevel = res.thinkingLevel ?? null;
83+
// Clear all streaming state — history includes tool results and text
84+
// inline, so keeping streaming artifacts would cause duplicates.
85+
maybeResetToolStream(state);
86+
state.chatStream = null;
87+
state.chatStreamStartedAt = null;
7088
} catch (err) {
7189
state.lastError = String(err);
7290
} finally {

ui/src/ui/gateway.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export class GatewayBrowserClient {
241241
role,
242242
scopes,
243243
device,
244-
caps: [],
244+
caps: ["tool-events"],
245245
auth,
246246
userAgent: navigator.userAgent,
247247
locale: navigator.language,

ui/src/ui/views/chat.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export type ChatProps = {
4343
fallbackStatus?: FallbackIndicatorStatus | null;
4444
messages: unknown[];
4545
toolMessages: unknown[];
46+
streamSegments: Array<{ text: string; ts: number }>;
4647
stream: string | null;
4748
streamStartedAt: number | null;
4849
assistantAvatarUrl?: string | null;
@@ -566,8 +567,21 @@ function buildChatItems(props: ChatProps): Array<ChatItem | MessageGroup> {
566567
message: msg,
567568
});
568569
}
569-
if (props.showThinking) {
570-
for (let i = 0; i < tools.length; i++) {
570+
// Interleave stream segments and tool cards in order. Each segment
571+
// contains text that was streaming before the corresponding tool started.
572+
// This ensures correct visual ordering: text → tool → text → tool → ...
573+
const segments = props.streamSegments ?? [];
574+
const maxLen = Math.max(segments.length, tools.length);
575+
for (let i = 0; i < maxLen; i++) {
576+
if (i < segments.length && segments[i].text.trim().length > 0) {
577+
items.push({
578+
kind: "stream" as const,
579+
key: `stream-seg:${props.sessionKey}:${i}`,
580+
text: segments[i].text,
581+
startedAt: segments[i].ts,
582+
});
583+
}
584+
if (i < tools.length) {
571585
items.push({
572586
kind: "message",
573587
key: messageKey(tools[i], i + history.length),

0 commit comments

Comments
 (0)