mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 02:06:43 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, string>();
|
||||
private readonly assistantItemOrder: string[] = [];
|
||||
private readonly assistantTimestampByItem = new Map<string, number>();
|
||||
private readonly assistantPhaseByItem = new Map<string, string>();
|
||||
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<string>): 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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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<typeof normalizeCodexThreadTokenUsage>;
|
||||
private responseUsage: ReturnType<typeof normalizeCodexResponseTokenUsage>;
|
||||
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 ??
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ const SYNCED_PREFS = {
|
||||
}),
|
||||
chatPersistCommentary: prefSpec<boolean>({
|
||||
extract: (value) => (typeof value === "boolean" ? value : undefined),
|
||||
local: (settings) => settings.chatPersistCommentary ?? false,
|
||||
local: (settings) => settings.chatPersistCommentary !== false,
|
||||
}),
|
||||
chatSendShortcut: prefSpec<ChatSendShortcut>({
|
||||
extract: (value) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -85,6 +85,71 @@ function messageRecord(group: MessageGroup, index = 0): Record<string, unknown>
|
||||
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<CachedChatItemsProps>, runWorking = false) =>
|
||||
collapseCompletedTurnWork(coalesceStreamRuns(buildCachedChatItems(createProps(props))), {
|
||||
|
||||
@@ -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<ChatItem | MessageGroup> {
|
||||
const result: Array<ChatItem | MessageGroup> = [];
|
||||
let currentGroup: MessageGroup | null = null;
|
||||
@@ -407,11 +417,17 @@ function groupMessages(items: ChatItem[]): Array<ChatItem | MessageGroup> {
|
||||
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<ChatItem | MessageGroup> {
|
||||
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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user