From fd081d65211605eaf1960c13a03491a0d02df792 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 20 Jul 2026 02:10:56 -0700 Subject: [PATCH] fix(ui): keep Codex commentary visible after runs (#111648) * fix(ui): keep Codex commentary visible * fix(codex): preserve commentary transcript ordering * fix(ui): preserve commentary retention opt-out --- docs/gateway/configuration-reference.md | 6 +- .../event-projector-assistant-message.ts | 26 +++++ .../app-server/event-projector-assistant.ts | 26 +++++ .../app-server/event-projector-snapshot.ts | 53 +++++++++ .../event-projector-tool-transcript.ts | 5 +- .../event-projector.commentary.test.ts | 104 ++++++++++++++++++ .../codex/src/app-server/event-projector.ts | 48 ++++---- src/config/types.openclaw.ts | 2 +- ui/src/app/server-prefs.test.ts | 4 +- ui/src/app/server-prefs.ts | 2 +- ui/src/app/settings.node.test.ts | 2 +- ui/src/app/settings.ts | 4 +- ui/src/pages/chat/chat-controls.test.ts | 8 +- ui/src/pages/chat/chat-gateway.test.ts | 64 +++++------ ui/src/pages/chat/chat-gateway.ts | 2 +- ui/src/pages/chat/chat-history.test.ts | 53 +++++++++ ui/src/pages/chat/chat-history.ts | 9 +- ui/src/pages/chat/chat-pane.ts | 1 + ui/src/pages/chat/chat-thread.test.ts | 65 +++++++++++ ui/src/pages/chat/chat-thread.ts | 21 +++- ui/src/pages/chat/chat-view.ts | 2 + ui/src/pages/chat/components/chat-controls.ts | 2 +- ui/src/pages/chat/components/chat-thread.ts | 2 + 23 files changed, 435 insertions(+), 76 deletions(-) create mode 100644 extensions/codex/src/app-server/event-projector-snapshot.ts diff --git a/docs/gateway/configuration-reference.md b/docs/gateway/configuration-reference.md index 154c7fb7b16..3d0e854b0d3 100644 --- a/docs/gateway/configuration-reference.md +++ b/docs/gateway/configuration-reference.md @@ -518,7 +518,7 @@ See [Inferred commitments](/concepts/commitments). locale: "en", chatShowThinking: true, chatShowToolCalls: true, - chatPersistCommentary: false, + chatPersistCommentary: true, // Keep commentary after runs in Control UI; does not deliver it to channels chatSendShortcut: "enter", // enter | modifier-enter chatFollowUpMode: "steer", // steer | queue; omit to use the server queue mode }, @@ -532,6 +532,10 @@ See [Inferred commitments](/concepts/commitments). change them through the approval gate and every Control UI client stays in sync; browsers mirror the values into local storage for instant boot and keep a device-local copy when they cannot write config (viewer scope, offline). + `chatPersistCommentary` defaults to `true`. Setting it to `false` keeps live + commentary visible during a run but removes it at completion and prevents new + Codex commentary from entering the durable transcript mirror. Messaging-channel + delivery remains separate and unchanged. Connected clients apply server-side changes live: the gateway broadcasts a hash-only `config.changed` event after every persisted config write and clients refresh their snapshot (skipped while a local settings draft has diff --git a/extensions/codex/src/app-server/event-projector-assistant-message.ts b/extensions/codex/src/app-server/event-projector-assistant-message.ts index 860d29917a1..3415eeeeaf0 100644 --- a/extensions/codex/src/app-server/event-projector-assistant-message.ts +++ b/extensions/codex/src/app-server/event-projector-assistant-message.ts @@ -72,6 +72,32 @@ export function createAssistantMessage( }; } +export function createAssistantCommentaryMessage( + params: EmbeddedRunAttemptParams, + text: string, + itemId: string, + timestamp: number, +): AssistantMessage { + const attribution = resolveCodexLocalRuntimeAttribution(params); + return { + role: "assistant", + content: [{ type: "text", text }], + api: attribution.api ?? "openai-chatgpt-responses", + provider: attribution.provider, + model: params.modelId, + usage: ZERO_USAGE, + stopReason: "stop", + timestamp, + // Keep this unphased: gateway history hides commentary-phase assistant rows. + // The keyed fallback persists Control UI narration without channel delivery. + openclawStreamFallback: { + replacementText: text, + source: "segment", + itemId, + }, + } as unknown as AssistantMessage; +} + export function createAssistantMirrorMessage( params: EmbeddedRunAttemptParams, title: string, diff --git a/extensions/codex/src/app-server/event-projector-assistant.ts b/extensions/codex/src/app-server/event-projector-assistant.ts index 420f7aee825..3477fe722df 100644 --- a/extensions/codex/src/app-server/event-projector-assistant.ts +++ b/extensions/codex/src/app-server/event-projector-assistant.ts @@ -1,6 +1,7 @@ import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime"; import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; import { + createAssistantCommentaryMessage as buildAssistantCommentaryMessage, createAssistantMessage as buildAssistantMessage, createAssistantMirrorMessage as buildAssistantMirrorMessage, type AssistantMessageOptions, @@ -15,6 +16,7 @@ type AnswerCandidateStatus = "candidate" | "superseded" | "selected"; export class CodexAssistantProjection { private readonly assistantTextByItem = new Map(); private readonly assistantItemOrder: string[] = []; + private readonly assistantTimestampByItem = new Map(); private readonly assistantPhaseByItem = new Map(); private latestCompletedItemId: string | undefined; private latestCompletedTerminalAssistantItemId: string | undefined; @@ -41,6 +43,7 @@ export class CodexAssistantProjection { private readonly params: EmbeddedRunAttemptParams, private readonly emitAgentEvent: (event: AgentEvent) => void, private readonly matchesToolProgressEcho: (text: string) => boolean, + private readonly nextTranscriptTimestamp: () => number, ) {} hasCompletedTerminalAssistantText(completedItemIds: ReadonlySet): boolean { @@ -152,6 +155,9 @@ export class CodexAssistantProjection { this.pendingRawTerminalAssistantEchoItemId = undefined; } this.rememberAssistantPhase(item); + if (item?.type === "agentMessage" && itemId) { + this.rememberAssistantItem(itemId); + } if (itemId && itemId !== this.latestTerminalAssistantCandidateItemId) { this.markTerminalAssistantCandidateSupersededBy(itemId, { preserveEarlierActiveItem: true, @@ -284,6 +290,25 @@ export class CodexAssistantProjection { return finalText ? [finalText] : []; } + collectCommentaryMessages(): Array<{ itemId: string; message: AssistantMessage }> { + return this.assistantItemOrder.flatMap((itemId) => { + if (!this.isCommentaryAssistantItem(itemId)) { + return []; + } + const text = this.assistantTextByItem.get(itemId)?.trim(); + const timestamp = this.assistantTimestampByItem.get(itemId); + if (!text || timestamp === undefined) { + return []; + } + return [ + { + itemId, + message: buildAssistantCommentaryMessage(this.params, text, itemId, timestamp), + }, + ]; + }); + } + finalizeAnswerCandidate(turn: { status?: string; items?: CodexThreadItem[] }): void { if (turn.status !== "completed") { this.supersedeVisibleAnswerCandidate(); @@ -502,6 +527,7 @@ export class CodexAssistantProjection { return; } this.assistantItemOrder.push(itemId); + this.assistantTimestampByItem.set(itemId, this.nextTranscriptTimestamp()); } private isToolProgressEchoText(itemId: string, text: string): boolean { diff --git a/extensions/codex/src/app-server/event-projector-snapshot.ts b/extensions/codex/src/app-server/event-projector-snapshot.ts new file mode 100644 index 00000000000..4c063240b73 --- /dev/null +++ b/extensions/codex/src/app-server/event-projector-snapshot.ts @@ -0,0 +1,53 @@ +import type { + AgentMessage, + EmbeddedRunAttemptParams, +} from "openclaw/plugin-sdk/agent-harness-runtime"; +import type { AssistantMessage } from "openclaw/plugin-sdk/llm"; +import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime"; +import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js"; +import { promptSnapshot } from "./user-prompt-message.js"; + +export function buildCodexMessagesSnapshot(params: { + runParams: EmbeddedRunAttemptParams; + turnId: string; + upstreamUserText: string | undefined; + reasoningText: string | undefined; + planText: string | undefined; + commentaryMessages: ReadonlyArray<{ itemId: string; message: AssistantMessage }>; + toolMessages: readonly AgentMessage[]; + lastAssistant: AssistantMessage | undefined; + createAssistantMirrorMessage: (title: string, text: string) => AssistantMessage; +}): AgentMessage[] { + const messages = promptSnapshot(params.runParams, params.turnId, params.upstreamUserText); + if (params.reasoningText) { + messages.push( + attachCodexMirrorIdentity( + params.createAssistantMirrorMessage("Codex reasoning", params.reasoningText), + `${params.turnId}:reasoning`, + ), + ); + } + if (params.planText) { + messages.push( + attachCodexMirrorIdentity( + params.createAssistantMirrorMessage("Codex plan", params.planText), + `${params.turnId}:plan`, + ), + ); + } + const commentaryMessages = + params.runParams.config?.ui?.prefs?.chatPersistCommentary === false + ? [] + : params.commentaryMessages.map(({ itemId, message }) => + attachCodexMirrorIdentity(message, `${params.turnId}:commentary:${itemId}`), + ); + const visibleWorkMessages = [...commentaryMessages, ...params.toolMessages].toSorted( + (left, right) => + (asDateTimestampMs(left.timestamp) ?? 0) - (asDateTimestampMs(right.timestamp) ?? 0), + ); + messages.push(...visibleWorkMessages); + if (params.lastAssistant) { + messages.push(attachCodexMirrorIdentity(params.lastAssistant, `${params.turnId}:assistant`)); + } + return messages; +} diff --git a/extensions/codex/src/app-server/event-projector-tool-transcript.ts b/extensions/codex/src/app-server/event-projector-tool-transcript.ts index 06b263d4c1a..1bf98e6cab3 100644 --- a/extensions/codex/src/app-server/event-projector-tool-transcript.ts +++ b/extensions/codex/src/app-server/event-projector-tool-transcript.ts @@ -72,6 +72,7 @@ export class CodexToolTranscriptProjection { private readonly threadId: string, private readonly turnId: string, private readonly progress: CodexToolProgressProjection, + private readonly nextTranscriptTimestamp: () => number, private readonly options: { nativePostToolUseRelayEnabled?: boolean; trajectoryRecorder?: CodexTrajectoryRecorder | null; @@ -343,7 +344,7 @@ export class CodexToolTranscriptProjection { model: this.params.modelId, usage: ZERO_USAGE, stopReason: "toolUse", - timestamp: Date.now(), + timestamp: this.nextTranscriptTimestamp(), } as unknown as AgentMessage; } @@ -367,7 +368,7 @@ export class CodexToolTranscriptProjection { text, }, ], - timestamp: Date.now(), + timestamp: this.nextTranscriptTimestamp(), } as unknown as AgentMessage; } } diff --git a/extensions/codex/src/app-server/event-projector.commentary.test.ts b/extensions/codex/src/app-server/event-projector.commentary.test.ts index 9e098abac41..123b9aa189c 100644 --- a/extensions/codex/src/app-server/event-projector.commentary.test.ts +++ b/extensions/codex/src/app-server/event-projector.commentary.test.ts @@ -164,6 +164,110 @@ describe("CodexAppServerEventProjector commentary projection", () => { const result = projector.buildResult(buildEmptyToolTelemetry()); expect(result.assistantTexts).toEqual(["final answer"]); + const commentary = result.messagesSnapshot.find( + (message) => + (message as { openclawStreamFallback?: { itemId?: unknown } }).openclawStreamFallback + ?.itemId === "msg-commentary", + ); + expect(commentary).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "Checking the app-server stream" }], + openclawStreamFallback: { + replacementText: "Checking the app-server stream", + source: "segment", + itemId: "msg-commentary", + }, + __openclaw: { mirrorIdentity: `${TURN_ID}:commentary:msg-commentary` }, + }); + expect((commentary as { phase?: unknown } | undefined)?.phase).toBeUndefined(); + }); + + it("omits durable commentary when the operator explicitly disables persistence", async () => { + const params = await createParams(); + params.config = { ui: { prefs: { chatPersistCommentary: false } } }; + const projector = await createProjector(params); + + await projector.handleNotification( + turnCompleted([ + { + type: "agentMessage", + id: "msg-commentary", + phase: "commentary", + text: "Checking the workspace", + }, + { type: "agentMessage", id: "msg-final", phase: "final_answer", text: "Done" }, + ]), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + expect(result.assistantTexts).toEqual(["Done"]); + expect( + result.messagesSnapshot.some( + (message) => + (message as { openclawStreamFallback?: { itemId?: unknown } }).openclawStreamFallback + ?.itemId === "msg-commentary", + ), + ).toBe(false); + }); + + it("mirrors commentary and tool activity in event order when timestamps collide", async () => { + const projector = await createProjector(); + vi.spyOn(Date, "now").mockReturnValue(100); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { type: "agentMessage", id: "msg-before-tool", phase: "commentary", text: "" }, + }), + ); + await projector.handleNotification(agentMessageDelta("Before the tool", "msg-before-tool")); + + projector.recordDynamicToolCall({ callId: "call-search", tool: "memory_search" }); + projector.recordDynamicToolResult({ + callId: "call-search", + tool: "memory_search", + success: true, + contentItems: [{ type: "inputText", text: "found it" }], + }); + + await projector.handleNotification( + forCurrentTurn("item/started", { + item: { type: "agentMessage", id: "msg-after-tool", phase: "commentary", text: "" }, + }), + ); + await projector.handleNotification(agentMessageDelta("After the tool", "msg-after-tool")); + await projector.handleNotification( + turnCompleted([ + { + type: "agentMessage", + id: "msg-before-tool", + phase: "commentary", + text: "Before the tool", + }, + { + type: "agentMessage", + id: "msg-after-tool", + phase: "commentary", + text: "After the tool", + }, + { type: "agentMessage", id: "msg-final", phase: "final_answer", text: "Done" }, + ]), + ); + + const result = projector.buildResult(buildEmptyToolTelemetry()); + const identities = result.messagesSnapshot.flatMap((message) => { + const identity = (message as { __openclaw?: { mirrorIdentity?: unknown } })["__openclaw"] + ?.mirrorIdentity; + return typeof identity === "string" && + (identity.includes(":commentary:") || identity.includes(":tool:")) + ? [identity] + : []; + }); + expect(identities).toEqual([ + `${TURN_ID}:commentary:msg-before-tool`, + `${TURN_ID}:tool:call-search:call`, + `${TURN_ID}:tool:call-search:result`, + `${TURN_ID}:commentary:msg-after-tool`, + ]); }); it("does not double-deliver a commentary note echoed on the raw response lane", async () => { diff --git a/extensions/codex/src/app-server/event-projector.ts b/extensions/codex/src/app-server/event-projector.ts index daff5397487..bc1a51e3a74 100644 --- a/extensions/codex/src/app-server/event-projector.ts +++ b/extensions/codex/src/app-server/event-projector.ts @@ -24,6 +24,7 @@ import { import { CodexGeneratedMediaProjection } from "./event-projector-media.js"; import { CodexNativeToolLifecycleProjector } from "./event-projector-native-tool-lifecycle.js"; import { CodexReasoningProjection } from "./event-projector-reasoning.js"; +import { buildCodexMessagesSnapshot } from "./event-projector-snapshot.js"; import { CodexToolProgressProjection } from "./event-projector-tool-progress.js"; import { CodexToolTranscriptProjection } from "./event-projector-tool-transcript.js"; import { @@ -54,9 +55,7 @@ import { } from "./protocol.js"; import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js"; import type { CodexTrajectoryRecorder } from "./trajectory.js"; -import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js"; import { createCodexUsageLimitPromptError } from "./usage-limit-error.js"; -import { promptSnapshot } from "./user-prompt-message.js"; export { CodexNativeToolLifecycleProjector }; export { shouldEmitTranscriptToolProgress } from "./event-projector-tool-progress.js"; @@ -106,6 +105,7 @@ export class CodexAppServerEventProjector { private tokenUsage: ReturnType; private responseUsage: ReturnType; private completedCompactionCount = 0; + private lastTranscriptTimestamp = 0; constructor( private readonly params: EmbeddedRunAttemptParams, @@ -129,6 +129,7 @@ export class CodexAppServerEventProjector { threadId, turnId, this.toolProgressProjection, + () => this.nextTranscriptTimestamp(), { nativePostToolUseRelayEnabled: options.nativePostToolUseRelayEnabled, trajectoryRecorder: options.trajectoryRecorder, @@ -146,12 +147,20 @@ export class CodexAppServerEventProjector { params, (event) => this.emitAgentEvent(event), (text) => this.toolProgressProjection.matchesEcho(text), + () => this.nextTranscriptTimestamp(), ); this.reasoningProjection = new CodexReasoningProjection(params, (event) => this.emitAgentEvent(event), ); } + private nextTranscriptTimestamp(): number { + // Commentary and tool mirrors share this clock so equal wall-clock values + // still preserve the app-server receipt order in the durable transcript. + this.lastTranscriptTimestamp = Math.max(Date.now(), this.lastTranscriptTimestamp + 1); + return this.lastTranscriptTimestamp; + } + getCompletedTurnStatus(): CodexTurn["status"] | undefined { return this.completedTurn?.status; } @@ -313,6 +322,7 @@ export class CodexAppServerEventProjector { // tool lacking a terminal item so audit consumers never retain an open action. this.nativeToolLifecycleProjector.finalizeActive(); const assistantTexts = this.assistantProjection.collectAssistantTexts(); + const commentaryMessages = this.assistantProjection.collectCommentaryMessages(); const reasoningText = this.reasoningProjection.reasoningText(); const planText = this.reasoningProjection.planText(); // A terminal timeout must not publish exact usage, but the timeout watcher @@ -358,30 +368,20 @@ export class CodexAppServerEventProjector { // is preserved → on-disk key still matches → also a no-op. // - Two distinct turns where the user repeats verbatim content → // distinct turnIds → distinct identities → both kept. - const turnId = this.turnId; - const messagesSnapshot = promptSnapshot(this.params, turnId, this.options.upstreamUserText); // Codex owns the canonical thread. These mirror records keep enough local // context for OpenClaw history, search, and future harness switching. - if (reasoningText) { - messagesSnapshot.push( - attachCodexMirrorIdentity( - this.assistantProjection.createAssistantMirrorMessage("Codex reasoning", reasoningText), - `${turnId}:reasoning`, - ), - ); - } - if (planText) { - messagesSnapshot.push( - attachCodexMirrorIdentity( - this.assistantProjection.createAssistantMirrorMessage("Codex plan", planText), - `${turnId}:plan`, - ), - ); - } - messagesSnapshot.push(...this.toolTranscriptProjection.transcriptMessages); - if (lastAssistant) { - messagesSnapshot.push(attachCodexMirrorIdentity(lastAssistant, `${turnId}:assistant`)); - } + const messagesSnapshot = buildCodexMessagesSnapshot({ + runParams: this.params, + turnId: this.turnId, + upstreamUserText: this.options.upstreamUserText, + reasoningText, + planText, + commentaryMessages, + toolMessages: this.toolTranscriptProjection.transcriptMessages, + lastAssistant, + createAssistantMirrorMessage: (title, text) => + this.assistantProjection.createAssistantMirrorMessage(title, text), + }); const turnFailed = this.completedTurn?.status === "failed"; const promptError = this.promptError ?? diff --git a/src/config/types.openclaw.ts b/src/config/types.openclaw.ts index c7059ddfe04..5351d16b2ca 100644 --- a/src/config/types.openclaw.ts +++ b/src/config/types.openclaw.ts @@ -195,7 +195,7 @@ export type OpenClawConfig = { chatShowThinking?: boolean; /** Show tool call cards in chat. */ chatShowToolCalls?: boolean; - /** Keep model commentary visible in the transcript after a run. */ + /** Keep model commentary in Control UI transcripts after a run. */ chatPersistCommentary?: boolean; /** Chat send shortcut: Enter sends, or modifier+Enter sends. */ chatSendShortcut?: "enter" | "modifier-enter"; diff --git a/ui/src/app/server-prefs.test.ts b/ui/src/app/server-prefs.test.ts index 59f9783d1fa..a23aea3a6b7 100644 --- a/ui/src/app/server-prefs.test.ts +++ b/ui/src/app/server-prefs.test.ts @@ -162,11 +162,11 @@ describe("changedServerUiPrefs", () => { const previous = loadSettings(); const withOverrides = { ...previous, - chatPersistCommentary: true, + chatPersistCommentary: false, chatFollowUpMode: "queue" as const, }; expect(changedServerUiPrefs(previous, withOverrides)).toEqual({ - chatPersistCommentary: true, + chatPersistCommentary: false, chatFollowUpMode: "queue", }); diff --git a/ui/src/app/server-prefs.ts b/ui/src/app/server-prefs.ts index 850f1a39a7c..d0da77b0151 100644 --- a/ui/src/app/server-prefs.ts +++ b/ui/src/app/server-prefs.ts @@ -80,7 +80,7 @@ const SYNCED_PREFS = { }), chatPersistCommentary: prefSpec({ extract: (value) => (typeof value === "boolean" ? value : undefined), - local: (settings) => settings.chatPersistCommentary ?? false, + local: (settings) => settings.chatPersistCommentary !== false, }), chatSendShortcut: prefSpec({ extract: (value) => diff --git a/ui/src/app/settings.node.test.ts b/ui/src/app/settings.node.test.ts index 62bb761dbcd..42cb7034dea 100644 --- a/ui/src/app/settings.node.test.ts +++ b/ui/src/app/settings.node.test.ts @@ -349,7 +349,7 @@ describe("loadSettings default gateway URL derivation", () => { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - chatPersistCommentary: false, + chatPersistCommentary: true, splitRatio: 0.6, navCollapsed: false, navWidth: 258, diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index a751a164384..8500650a42f 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -351,7 +351,7 @@ export function loadSettings(): UiSettings { themeMode: "system", chatShowThinking: true, chatShowToolCalls: true, - chatPersistCommentary: false, + chatPersistCommentary: true, chatSendShortcut: "enter", catalogOpenTarget: "viewer", splitRatio: 0.6, @@ -554,7 +554,7 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean } themeMode: next.themeMode, chatShowThinking: next.chatShowThinking, chatShowToolCalls: next.chatShowToolCalls, - chatPersistCommentary: next.chatPersistCommentary ?? false, + chatPersistCommentary: next.chatPersistCommentary ?? true, ...(normalizeChatSendShortcut(next.chatSendShortcut) === "modifier-enter" ? { chatSendShortcut: "modifier-enter" as const } : {}), diff --git a/ui/src/pages/chat/chat-controls.test.ts b/ui/src/pages/chat/chat-controls.test.ts index bc84f7019d5..3aea4812bd5 100644 --- a/ui/src/pages/chat/chat-controls.test.ts +++ b/ui/src/pages/chat/chat-controls.test.ts @@ -18,7 +18,7 @@ function createSettings(): UiSettings { themeMode: "dark", chatShowThinking: true, chatShowToolCalls: true, - chatPersistCommentary: false, + chatPersistCommentary: true, splitRatio: 0.6, navCollapsed: false, navWidth: 280, @@ -69,7 +69,7 @@ describe("chat composer view menu", () => { t("chat.view.toolCalls"), t("chat.view.commentary"), ]); - expect(items.map((item) => item.checked)).toEqual([true, true, false]); + expect(items.map((item) => item.checked)).toEqual([true, true, true]); }); it("toggles settings from the menu rows", () => { @@ -98,7 +98,7 @@ describe("chat composer view menu", () => { ); select(commentary!); expect(onSettingsChange).toHaveBeenLastCalledWith( - expect.objectContaining({ chatPersistCommentary: true }), + expect.objectContaining({ chatPersistCommentary: false }), ); }); @@ -110,7 +110,7 @@ describe("chat composer view menu", () => { const items = menuItems(container); expect(items.every((item) => item.disabled)).toBe(true); // Onboarding forces thinking hidden and tool calls visible. - expect(items.map((item) => item.checked)).toEqual([false, true, false]); + expect(items.map((item) => item.checked)).toEqual([false, true, true]); container.querySelector("wa-dropdown")?.dispatchEvent( new CustomEvent("wa-select", { bubbles: true, diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 76e02f06fbe..c20e0a905f3 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -845,7 +845,7 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStreamStartedAt).toBe(null); }); - it("clears keyed commentary with the final answer by default", () => { + it("persists keyed commentary with the final answer by default", () => { const user = { role: "user", content: [{ type: "text", text: "Ask" }], timestamp: 1 }; const state = createState({ sessionKey: "main", @@ -868,37 +868,6 @@ describe("handleChatGatewayEvent", () => { }, }; - expect(handleChatGatewayEvent(state, payload)).toBe("final"); - expect(state.chatMessages).toHaveLength(2); - expectTextChatMessage(state.chatMessages[0], "user", "Ask"); - expectTextChatMessage(state.chatMessages[1], "assistant", "Final answer."); - expect(state.chatStreamSegments).toEqual([]); - }); - - it("persists keyed commentary alongside the final answer when chatPersistCommentary is true", () => { - const user = { role: "user", content: [{ type: "text", text: "Ask" }], timestamp: 1 }; - const state = createState({ - sessionKey: "main", - chatRunId: "run-1", - chatMessages: [user], - chatStream: null, - chatStreamStartedAt: null, - settings: { chatPersistCommentary: true }, - }) as ChatState & { - chatStreamSegments: Array<{ text: string; ts: number; itemId: string }>; - }; - state.chatStreamSegments = [{ text: "Looking into it.", ts: 2, itemId: "preamble-1" }]; - const payload: ChatEventPayload = { - runId: "run-1", - sessionKey: "main", - state: "final", - message: { - role: "assistant", - content: [{ type: "text", text: "Final answer." }], - timestamp: 5, - }, - }; - expect(handleChatGatewayEvent(state, payload)).toBe("final"); expect(state.chatMessages).toHaveLength(3); expectTextChatMessage(state.chatMessages[0], "user", "Ask"); @@ -907,6 +876,37 @@ describe("handleChatGatewayEvent", () => { expect(state.chatStreamSegments).toEqual([]); }); + it("clears keyed commentary when chatPersistCommentary is false", () => { + const user = { role: "user", content: [{ type: "text", text: "Ask" }], timestamp: 1 }; + const state = createState({ + sessionKey: "main", + chatRunId: "run-1", + chatMessages: [user], + chatStream: null, + chatStreamStartedAt: null, + settings: { chatPersistCommentary: false }, + }) as ChatState & { + chatStreamSegments: Array<{ text: string; ts: number; itemId: string }>; + }; + state.chatStreamSegments = [{ text: "Looking into it.", ts: 2, itemId: "preamble-1" }]; + const payload: ChatEventPayload = { + runId: "run-1", + sessionKey: "main", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "Final answer." }], + timestamp: 5, + }, + }; + + expect(handleChatGatewayEvent(state, payload)).toBe("final"); + expect(state.chatMessages).toHaveLength(2); + expectTextChatMessage(state.chatMessages[0], "user", "Ask"); + expectTextChatMessage(state.chatMessages[1], "assistant", "Final answer."); + expect(state.chatStreamSegments).toEqual([]); + }); + it("reconciles cached run and indicator state on terminal events", () => { vi.useFakeTimers(); try { diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index d03e00c4872..cc5a053e0df 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -316,7 +316,7 @@ function handleChatEvent(state: ChatState, payload?: ChatEventPayload) { state, { isHiddenStreamText: isHiddenAssistantStreamText, - persistCommentary: state.settings?.chatPersistCommentary === true, + persistCommentary: state.settings?.chatPersistCommentary !== false, }, ); if (replacesVisibleStream) { diff --git a/ui/src/pages/chat/chat-history.test.ts b/ui/src/pages/chat/chat-history.test.ts index 81c066744a9..46430762d6a 100644 --- a/ui/src/pages/chat/chat-history.test.ts +++ b/ui/src/pages/chat/chat-history.test.ts @@ -237,6 +237,59 @@ describe("switchChatHistoryBranch", () => { }); }); +describe("active-run commentary reconciliation", () => { + it("keeps keyed commentary live across history reloads when persistence is disabled", async () => { + const state = createState(activeHistory("run-live")); + state.chatRunId = "run-live"; + state.settings = { chatPersistCommentary: false }; + state.chatStreamSegments = [{ text: "Checking the workspace", ts: 2, itemId: "preamble-live" }]; + + await loadChatHistory(state); + + expect(state.chatRunId).toBe("run-live"); + expect(state.chatStreamSegments).toEqual([ + { text: "Checking the workspace", ts: 2, itemId: "preamble-live" }, + ]); + }); + + it("materializes live commentary when history replaces active tool activity", async () => { + const toolResult = { + role: "toolResult", + toolCallId: "call-1", + content: "tool output", + timestamp: 3, + }; + const state = createState({ + ...activeHistory("run-live"), + messages: [{ role: "user", content: "do it", timestamp: 1 }, toolResult], + }); + state.chatRunId = "run-live"; + state.settings = { chatPersistCommentary: false }; + state.chatStreamSegments = [{ text: "Checking the workspace", ts: 2, itemId: "preamble-live" }]; + state.toolStreamOrder = ["call-1"]; + state.toolStreamById.set("call-1", { + toolCallId: "call-1", + runId: "run-live", + name: "read", + startedAt: 2, + receivedAt: 2, + message: toolResult, + }); + state.chatToolMessages = [toolResult]; + + await loadChatHistory(state); + + expect( + state.chatMessages.some( + (message) => + (message as { openclawStreamFallback?: { itemId?: unknown } }).openclawStreamFallback + ?.itemId === "preamble-live", + ), + ).toBe(true); + expect(state.chatStreamSegments).toEqual([]); + }); +}); + describe("chat history plan replay", () => { const retainedPlan = { runId: "run-retained", diff --git a/ui/src/pages/chat/chat-history.ts b/ui/src/pages/chat/chat-history.ts index 0b2301b7185..ab20b96dfd8 100644 --- a/ui/src/pages/chat/chat-history.ts +++ b/ui/src/pages/chat/chat-history.ts @@ -254,18 +254,19 @@ export function materializeVisibleAssistantStreamMessages( includeCurrent?: boolean; requirePersistedTool?: boolean; replacementMessages?: unknown[]; + persistCommentary?: boolean; } = {}, ): unknown[] { return materializeVisibleStreamState(messages, state, { ...opts, - persistCommentary: chatPersistCommentaryEnabled(state), + persistCommentary: opts.persistCommentary ?? chatPersistCommentaryEnabled(state), isHiddenAssistantMessage: shouldHideAssistantChatMessage, isHiddenStreamText: isHiddenAssistantStreamText, }); } function chatPersistCommentaryEnabled(state: ChatState): boolean { - return state.settings?.chatPersistCommentary === true; + return state.settings?.chatPersistCommentary !== false; } function historyHasSameOrNewerDisplayMessage( @@ -1289,7 +1290,7 @@ async function loadChatHistoryUncached( const resetStream = !state.chatRunId || state.chatRunId === previousRunId; if (resetStream) { const streamReconciliation = { - persistCommentary: chatPersistCommentaryEnabled(state), + persistCommentary: state.chatRunId ? true : chatPersistCommentaryEnabled(state), isHiddenAssistantMessage: shouldHideAssistantChatMessage, isHiddenStreamText: isHiddenAssistantStreamText, }; @@ -1331,6 +1332,7 @@ async function loadChatHistoryUncached( } else if (historyReplacedToolStream) { state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { includeCurrent: false, + persistCommentary: true, }); state.chatStream = visibleCurrentAssistantStreamTail( state, @@ -1348,6 +1350,7 @@ async function loadChatHistoryUncached( state.chatMessages = materializeVisibleAssistantStreamMessages(state.chatMessages, state, { includeCurrent: false, requirePersistedTool: true, + persistCommentary: true, }); state.chatStream = visibleCurrentTail; if (state.chatStream === null) { diff --git a/ui/src/pages/chat/chat-pane.ts b/ui/src/pages/chat/chat-pane.ts index 387f17ab5e5..19965b403f3 100644 --- a/ui/src/pages/chat/chat-pane.ts +++ b/ui/src/pages/chat/chat-pane.ts @@ -3059,6 +3059,7 @@ class ChatPane extends OpenClawLightDomElement { autoExpandToolCalls: state.chatVerboseLevel === "full", showThinking: state.settings.chatShowThinking, showToolCalls: state.settings.chatShowToolCalls, + persistCommentary: state.settings.chatPersistCommentary !== false, loading: catalogKey ? this.catalogLoading : state.chatLoading, sending: state.chatSending, canAbort: hasAbortableSessionRun(state), diff --git a/ui/src/pages/chat/chat-thread.test.ts b/ui/src/pages/chat/chat-thread.test.ts index a449913df2a..5399403a349 100644 --- a/ui/src/pages/chat/chat-thread.test.ts +++ b/ui/src/pages/chat/chat-thread.test.ts @@ -85,6 +85,71 @@ function messageRecord(group: MessageGroup, index = 0): Record return requireRecord(group.messages[index]?.message); } +describe("assistant commentary grouping", () => { + it("keeps keyed commentary separate from the terminal assistant reply", () => { + const groups = messageGroups({ + messages: [ + { role: "user", content: "do it", timestamp: 1_000 }, + { + role: "assistant", + content: "Checking the workspace.", + timestamp: 2_000, + openclawStreamFallback: { + replacementText: "Checking the workspace.", + source: "segment", + itemId: "preamble-1", + }, + }, + { + role: "assistant", + content: "Inspecting the result.", + timestamp: 3_000, + openclawStreamFallback: { + replacementText: "Inspecting the result.", + source: "segment", + itemId: "preamble-2", + }, + }, + { role: "assistant", content: "All done.", timestamp: 4_000 }, + ], + }); + + expect(groups.map((group) => group.role)).toEqual(["user", "assistant", "assistant"]); + expect(groupAt(groups, 1).messages).toHaveLength(2); + expect(groupAt(groups, 2).messages).toHaveLength(1); + }); + + it("hides durable commentary when the display preference is disabled", () => { + const paneId = "commentary-visibility"; + const messages = [ + { role: "user", content: "do it", timestamp: 1_000 }, + { + role: "assistant", + content: "Checking the workspace.", + timestamp: 2_000, + openclawStreamFallback: { + replacementText: "Checking the workspace.", + source: "segment", + itemId: "preamble-1", + }, + }, + { role: "assistant", content: "All done.", timestamp: 3_000 }, + ]; + + const visible = buildCachedChatItems(createProps({ paneId, messages })); + const hidden = buildCachedChatItems( + createProps({ paneId, messages, persistCommentary: false }), + ); + const restored = buildCachedChatItems(createProps({ paneId, messages })); + + expect(visible.filter((item) => item.kind === "group")).toHaveLength(3); + expect(hidden.filter((item) => item.kind === "group")).toHaveLength(2); + expect(restored.filter((item) => item.kind === "group")).toHaveLength(3); + expect(messages).toHaveLength(3); + resetChatThreadState(paneId); + }); +}); + describe("collapseCompletedTurnWork", () => { const collapsedItems = (props: Partial, runWorking = false) => collapseCompletedTurnWork(coalesceStreamRuns(buildCachedChatItems(createProps(props))), { diff --git a/ui/src/pages/chat/chat-thread.ts b/ui/src/pages/chat/chat-thread.ts index befacada283..bdfe184eec4 100644 --- a/ui/src/pages/chat/chat-thread.ts +++ b/ui/src/pages/chat/chat-thread.ts @@ -62,6 +62,7 @@ type BuildChatItemsProps = { streamStartedAt: number | null; queue?: ChatQueueItem[]; showToolCalls: boolean; + persistCommentary?: boolean; /** True while the agent is visibly working (isChatRunWorking). */ runWorking?: boolean; /** Keeps the status row visible while a running tool is parked for approval. */ @@ -382,6 +383,15 @@ function findCanvasInsertionIndex(items: ChatItem[], toolTimestamp: number | nul return items.length; } +function isKeyedAssistantStreamFallbackMessage(message: unknown): boolean { + const record = asRecord(message); + if (normalizeLowercaseStringOrEmpty(record?.role) !== "assistant") { + return false; + } + const fallback = asRecord(record?.openclawStreamFallback); + return typeof fallback?.itemId === "string" && fallback.itemId.trim().length > 0; +} + function groupMessages(items: ChatItem[]): Array { const result: Array = []; let currentGroup: MessageGroup | null = null; @@ -407,11 +417,17 @@ function groupMessages(items: ChatItem[]): Array { const shouldSplitBySender = role.toLowerCase() === "user" || role.toLowerCase() === "assistant"; const startsProjectedTurn = asRecord(asRecord(item.message)?.["__openclaw"])?.turnBoundary === true; + const splitsAssistantCommentary = + role.toLowerCase() === "assistant" && + currentGroup?.role.toLowerCase() === "assistant" && + isKeyedAssistantStreamFallbackMessage(currentGroup.messages[0]?.message) !== + isKeyedAssistantStreamFallbackMessage(item.message); if ( !currentGroup || startsProjectedTurn || currentGroup.role !== role || + splitsAssistantCommentary || (shouldSplitBySender && (currentGroup.senderLabel !== senderLabel || senderIdentityKey(currentGroup.sender) !== senderIdentityKey(sender))) @@ -1199,7 +1215,9 @@ function sortChatItemsByVisibleTime( function buildChatItems(props: BuildChatItemsProps): Array { let items: ChatItem[] = []; const history = (Array.isArray(props.messages) ? props.messages : []).filter( - (message) => !isAssistantHeartbeatAckForDisplay(message), + (message) => + !isAssistantHeartbeatAckForDisplay(message) && + (props.persistCommentary !== false || !isKeyedAssistantStreamFallbackMessage(message)), ); const tools = Array.isArray(props.toolMessages) ? props.toolMessages.filter((message) => asRecord(message) !== null) @@ -1708,6 +1726,7 @@ function sameChatItemsStructuralInput( previous.streamStartedAt === next.streamStartedAt && previous.queue === next.queue && previous.showToolCalls === next.showToolCalls && + previous.persistCommentary === next.persistCommentary && previous.runWorking === next.runWorking && previous.waitingApproval === next.waitingApproval && previous.runActive === next.runActive && diff --git a/ui/src/pages/chat/chat-view.ts b/ui/src/pages/chat/chat-view.ts index 69571806692..c58ea86e440 100644 --- a/ui/src/pages/chat/chat-view.ts +++ b/ui/src/pages/chat/chat-view.ts @@ -80,6 +80,7 @@ export type ChatProps = { thinkingLevel: string | null; showThinking: boolean; showToolCalls: boolean; + persistCommentary?: boolean; loading: boolean; sending: boolean; canAbort?: boolean; @@ -335,6 +336,7 @@ export function renderChat(props: ChatProps) { queue: props.queue, showThinking: props.showThinking, showToolCalls: props.showToolCalls, + persistCommentary: props.persistCommentary, runActive: Boolean(props.canAbort), runWorking: isChatRunWorking(props), waitingApproval: props.waitingApproval, diff --git a/ui/src/pages/chat/components/chat-controls.ts b/ui/src/pages/chat/components/chat-controls.ts index b6b2f5df75d..83c00ae4551 100644 --- a/ui/src/pages/chat/components/chat-controls.ts +++ b/ui/src/pages/chat/components/chat-controls.ts @@ -31,7 +31,7 @@ function chatViewMenuRows(props: ChatControlsProps): ChatViewMenuRow[] { // Onboarding pins the display: no thinking noise, tool calls visible. const showThinking = onboarding ? false : settings.chatShowThinking; const showToolCalls = onboarding ? true : settings.chatShowToolCalls; - const persistCommentary = settings.chatPersistCommentary === true; + const persistCommentary = settings.chatPersistCommentary !== false; return [ { label: t("chat.view.reasoning"), diff --git a/ui/src/pages/chat/components/chat-thread.ts b/ui/src/pages/chat/components/chat-thread.ts index 45afbba79cf..2cc2f06a9a2 100644 --- a/ui/src/pages/chat/components/chat-thread.ts +++ b/ui/src/pages/chat/components/chat-thread.ts @@ -112,6 +112,7 @@ type ChatThreadProps = { queue: ChatQueueItem[]; showThinking: boolean; showToolCalls: boolean; + persistCommentary?: boolean; /** True while the session has an abortable live run (marks running tool rows). */ runActive?: boolean; /** True while the agent is visibly working (isChatRunWorking); shows the working spark. */ @@ -1093,6 +1094,7 @@ function renderChatThreadContents( streamStartedAt: props.streamStartedAt, queue: props.queue, showToolCalls: props.showToolCalls, + persistCommentary: props.persistCommentary, runWorking: Boolean(props.runWorking), waitingApproval: Boolean(props.waitingApproval), runActive: Boolean(props.runActive),