From 7b3014b03c2e8f0adedc2f6e927f1994579f3844 Mon Sep 17 00:00:00 2001 From: mira-2026 Date: Sat, 1 Aug 2026 07:00:04 +0200 Subject: [PATCH 1/4] fix(chat): stabilize replay and control events --- backend/src/chat/openClawChatBridge.ts | 9 +- backend/src/chat/openClawChatRetention.ts | 6 + backend/src/gateway.ts | 35 ++- backend/src/routes/taskRoutes.ts | 2 +- backend/test/gatewayBehavior.test.ts | 31 +++ backend/test/openClawChatBridge.test.ts | 76 ++++++ backend/test/routeAndServiceBehavior.test.ts | 6 +- backend/test/serviceBehavior.test.ts | 3 + contracts/chat/openClawAdapterValues.ts | 16 +- contracts/chat/openClawHistoryNormalizer.ts | 24 +- contracts/chat/openClawHistoryPageAdapter.ts | 88 +++++- contracts/chat/openClawRuntimeAdapter.ts | 72 ++++- contracts/chat/openClawToolAdapter.ts | 7 +- contracts/chatCanonical.ts | 13 + contracts/chatCanonicalHistory.ts | 31 +++ contracts/chatCanonicalTurn.ts | 2 +- .../features/chat/AttachmentPreviewModal.tsx | 46 ++-- .../chat/ChatAttachmentPickerModal.tsx | 9 +- .../features/chat/ChatMessagesList.tsx | 1 + .../src/components/features/chat/chatTypes.ts | 3 + .../chat/domain/chatCanonicalProjection.ts | 6 + .../features/chat/domain/chatPresentation.ts | 6 + .../features/chat/domain/chatProjection.ts | 184 +++++++++++-- .../features/chat/domain/chatState.ts | 232 +++++++++++++++- .../chat/transport/openClawHistoryLoader.ts | 129 ++++++++- .../transport/useOpenClawChatTransport.ts | 6 +- .../features/chat/useChatRuntime.ts | 43 ++- .../features/files/viewers/JsonPreview.tsx | 12 +- .../files/viewers/MarkdownPreview.tsx | 6 +- frontend/src/components/ui/Modal.tsx | 12 +- .../src/test/chatCanonicalHistory.test.ts | 90 ++++++- .../src/test/chatCanonicalProjection.test.ts | 41 ++- frontend/src/test/chatProjection.test.ts | 250 ++++++++++++++++++ .../src/test/chatRuntimeController.test.tsx | 40 +++ frontend/src/test/chatState.test.ts | 96 +++++++ frontend/src/test/componentBehavior.test.tsx | 18 ++ .../src/test/openClawAdapterVariants.test.ts | 86 ++++++ .../src/test/openClawHistoryLoader.test.ts | 77 +++++- .../src/test/support/canonicalChatHistory.ts | 19 +- 39 files changed, 1730 insertions(+), 103 deletions(-) diff --git a/backend/src/chat/openClawChatBridge.ts b/backend/src/chat/openClawChatBridge.ts index f95aeadfe..1344d5bb6 100644 --- a/backend/src/chat/openClawChatBridge.ts +++ b/backend/src/chat/openClawChatBridge.ts @@ -1333,7 +1333,14 @@ export class OpenClawChatBridge { JSON.stringify({ runId: explicitRunId, sessionKey: storageSessionKey }) ) : 0; - if (explicitRunId && associationBytes <= MAX_BYTES_PER_EVENT) { + const isTranscriptBackedControl = envelope.canonicalEvents.some( + (canonicalEvent) => canonicalEvent.kind === "control" + ); + if ( + explicitRunId && + !isTranscriptBackedControl && + associationBytes <= MAX_BYTES_PER_EVENT + ) { this.#identity.rememberRunSession(explicitRunId, storageSessionKey); } if ( diff --git a/backend/src/chat/openClawChatRetention.ts b/backend/src/chat/openClawChatRetention.ts index fafc5dcda..749907475 100644 --- a/backend/src/chat/openClawChatRetention.ts +++ b/backend/src/chat/openClawChatRetention.ts @@ -80,6 +80,12 @@ export function shouldRetainRuntimeEvent( payload: Record, canonicalEvents: OpenClawRuntimeEnvelope["canonicalEvents"] ): boolean { + // Injected control messages already live in chat.history. They must reach live + // clients, but retaining their synthetic run would displace response replay and + // could promote an interrupted response into the inject-* run. + if (canonicalEvents.some((canonicalEvent) => canonicalEvent.kind === "control")) { + return false; + } if (event === "session.started" && !stringField(payload, "runId")) { return false; } diff --git a/backend/src/gateway.ts b/backend/src/gateway.ts index c90d9f663..a3b6b7f7f 100644 --- a/backend/src/gateway.ts +++ b/backend/src/gateway.ts @@ -2,7 +2,10 @@ import fs from "node:fs"; import os from "node:os"; import Path from "node:path"; -import { canonicalizeOpenClawHistoryPage } from "../../contracts/chat/openClawHistoryPageAdapter.ts"; +import { + canonicalizeOpenClawHistoryMessageResult, + canonicalizeOpenClawHistoryPage, +} from "../../contracts/chat/openClawHistoryPageAdapter.ts"; import type { ChatRuntimeMetrics, GatewayMetrics } from "../../contracts/metrics.ts"; import type { Session } from "../../contracts/sessions.ts"; import type { DashboardSettingsResponse } from "../../contracts/settings.ts"; @@ -1335,6 +1338,17 @@ async function forwardRequest( ? parameters.sessionKey : "", }); + } else if (method === "chat.message.get") { + payload = canonicalizeOpenClawHistoryMessageResult(payload, { + messageId: + typeof parameters.messageId === "string" + ? parameters.messageId + : "", + sessionKey: + typeof parameters.sessionKey === "string" + ? parameters.sessionKey + : "", + }); } else if (method === "sessions.list") { normalizedSessions = normalizeGatewaySessionList(payload); payload = { sessions: normalizedSessions }; @@ -1667,6 +1681,24 @@ async function sendSessionMessage(sessionKey: string, message: string): Promise< ); } +/** + * Appends a durable control notice without creating a human chat turn, then wakes the + * owning agent through OpenClaw's system-event lane. + * @param sessionKey Session key value. + * @param message Control notice to display and deliver. + */ +async function sendSessionControlEvent( + sessionKey: string, + message: string +): Promise { + await sendRequestAsync("chat.inject", { message, sessionKey }, { timeoutMs: 10_000 }); + await sendRequestAsync( + "wake", + { mode: "now", sessionKey, text: message }, + { timeoutMs: 10_000 } + ); +} + /** * Performs abort session run. * @param sessionKey Session key value. @@ -1749,6 +1781,7 @@ export default { getMetrics, getChatMetrics, getGatewayWs, + sendSessionControlEvent, sendSessionMessage, abortSessionRun, deleteSession, diff --git a/backend/src/routes/taskRoutes.ts b/backend/src/routes/taskRoutes.ts index a08405a5d..e6de49bf6 100644 --- a/backend/src/routes/taskRoutes.ts +++ b/backend/src/routes/taskRoutes.ts @@ -320,7 +320,7 @@ async function notifyMira( if (isDevelopmentExternalNotificationSuppressed()) return; const isAutomation = currentRequestAuditContext()?.actor.type === "automation"; try { - await gateway.sendSessionMessage( + await gateway.sendSessionControlEvent( "main", isAutomation ? miraAutomationTaskNotificationMessage(eventType, task.id) diff --git a/backend/test/gatewayBehavior.test.ts b/backend/test/gatewayBehavior.test.ts index e28472ed1..5cc6a9483 100644 --- a/backend/test/gatewayBehavior.test.ts +++ b/backend/test/gatewayBehavior.test.ts @@ -620,6 +620,37 @@ describe("gateway behavior", () => { }, }); expect(chatSendRequest?.parameters).not.toHaveProperty("timeoutMs"); + + await gateway.sendSessionControlEvent("main", "Task progress: #388"); + + const controlRequests = client?.requests.filter(({ method }) => + ["chat.inject", "wake"].includes(method) + ); + expect(controlRequests).toEqual([ + { + method: "chat.inject", + options: { timeoutMs: 10_000 }, + parameters: { + message: "Task progress: #388", + sessionKey: "main", + }, + }, + { + method: "wake", + options: { timeoutMs: 10_000 }, + parameters: { + mode: "now", + sessionKey: "main", + text: "Task progress: #388", + }, + }, + ]); + expect( + client?.requests.find( + ({ method, parameters }) => + method === "chat.send" && parameters.message === "Task progress: #388" + ) + ).toBeUndefined(); }); it("rehydrates run associations before reconnect events resume", async () => { diff --git a/backend/test/openClawChatBridge.test.ts b/backend/test/openClawChatBridge.test.ts index fd7af5eb2..4f94e7d63 100644 --- a/backend/test/openClawChatBridge.test.ts +++ b/backend/test/openClawChatBridge.test.ts @@ -3543,6 +3543,82 @@ describe("OpenClaw chat bridge", () => { ]); }); + it("broadcasts injected controls without retaining or promoting synthetic runs", () => { + const store = new MemorySnapshotStore(); + const interruptedRunId = "provider-before-control"; + const resumedRunId = "provider-after-control"; + const bridge = new OpenClawChatBridge(store); + + bridge.recordEvent( + "agent", + { + data: { delta: "before restart" }, + runId: interruptedRunId, + sessionKey: MAIN, + stream: "thinking", + }, + [] + ); + bridge.markGatewayDisconnected(); + expect(bridge.flush()).toBe(true); + + const restarted = new OpenClawChatBridge(store); + const control = restarted.recordEvent( + "chat", + { + message: { + content: "Task progress: #389", + model: "gateway-injected", + provider: "openclaw", + role: "assistant", + stopReason: "stop", + }, + runId: "inject-control-1", + sessionKey: MAIN, + state: "final", + }, + [] + ); + + expect(control.runtimeRunAliases).toBeUndefined(); + expect(control.canonicalEvents).toEqual([ + expect.objectContaining({ + kind: "control", + lifecycle: "completed", + }), + ]); + expect(control.canonicalEvents[0]?.runId).toBeUndefined(); + expect(restarted.snapshot(MAIN)).toMatchObject({ + completed: false, + events: [ + expect.objectContaining({ + payload: expect.objectContaining({ runId: interruptedRunId }), + }), + ], + }); + + const resumed = restarted.recordEvent( + "agent", + { + data: { + item: { kind: "preamble", progressText: "after control" }, + phase: "update", + stream: "item", + }, + runId: resumedRunId, + sessionKey: MAIN, + }, + [] + ); + + expect(resumed.runtimeRunAliases).toEqual([interruptedRunId]); + expect( + restarted + .snapshot(MAIN) + .events.map((event) => (event.payload as { runId?: string }).runId) + ).toEqual([resumedRunId, resumedRunId]); + }); + it("repairs one active provider run after an abrupt Dashboard restart", () => { const store = new MemorySnapshotStore(); const providerRunId = "provider-before-dashboard-crash"; diff --git a/backend/test/routeAndServiceBehavior.test.ts b/backend/test/routeAndServiceBehavior.test.ts index 5a0d4981d..04a09c577 100644 --- a/backend/test/routeAndServiceBehavior.test.ts +++ b/backend/test/routeAndServiceBehavior.test.ts @@ -941,10 +941,10 @@ describe("backend route and service behavior", () => { const gatewayModule = await import("../src/gateway.ts"); const gateway = gatewayModule.default; const originalRequest = gateway.request; - const originalSendSessionMessage = gateway.sendSessionMessage; + const originalSendSessionControlEvent = gateway.sendSessionControlEvent; cleanupCallbacks.push(() => { gateway.request = originalRequest; - gateway.sendSessionMessage = originalSendSessionMessage; + gateway.sendSessionControlEvent = originalSendSessionControlEvent; }); const taskNotifications: string[] = []; gateway.request = () => @@ -961,7 +961,7 @@ describe("backend route and service behavior", () => { }, ], })); - gateway.sendSessionMessage = (_sessionKey, message) => { + gateway.sendSessionControlEvent = (_sessionKey, message) => { return Promise.try(() => { taskNotifications.push(message); }); diff --git a/backend/test/serviceBehavior.test.ts b/backend/test/serviceBehavior.test.ts index 43b78d324..9d7fb4e0b 100644 --- a/backend/test/serviceBehavior.test.ts +++ b/backend/test/serviceBehavior.test.ts @@ -6457,6 +6457,9 @@ fi expect(gateway.sendSessionMessage("agent:main:main", "hello")).rejects.toThrow( "Gateway not connected" ); + expect( + gateway.sendSessionControlEvent("agent:main:main", "hello") + ).rejects.toThrow("Gateway not connected"); expect(gateway.abortSessionRun("agent:main:main")).rejects.toThrow( "Gateway not connected" ); diff --git a/contracts/chat/openClawAdapterValues.ts b/contracts/chat/openClawAdapterValues.ts index 210c5b1ed..7550340c7 100644 --- a/contracts/chat/openClawAdapterValues.ts +++ b/contracts/chat/openClawAdapterValues.ts @@ -184,8 +184,8 @@ export function isToolResultItem(data: Record): boolean { ].includes(itemType(data)); } -export function isThinkingItem(data: Record): boolean { - const markers = itemStrings(data, [ +function itemMarkerText(data: Record): string { + return itemStrings(data, [ "itemId", "itemKind", "kind", @@ -197,10 +197,14 @@ export function isThinkingItem(data: Record): boolean { ]) .join(" ") .toLowerCase(); - return ( - markers.includes("preamble") || - /\b(reasoning|reason|thinking|analysis)\b/u.test(markers) - ); +} + +export function isPreambleItem(data: Record): boolean { + return itemMarkerText(data).includes("preamble"); +} + +export function isThinkingItem(data: Record): boolean { + return /\b(reasoning|reason|thinking|analysis)\b/u.test(itemMarkerText(data)); } export function normalizeAssistant(value: unknown, runId?: string): CanonicalChatMessage { diff --git a/contracts/chat/openClawHistoryNormalizer.ts b/contracts/chat/openClawHistoryNormalizer.ts index 67d1f93b1..8c4e6655b 100644 --- a/contracts/chat/openClawHistoryNormalizer.ts +++ b/contracts/chat/openClawHistoryNormalizer.ts @@ -46,6 +46,21 @@ export interface RawOpenClawHistoryMessage { stopReason?: unknown; } +function historyMetadata(message: RawOpenClawHistoryMessage): Record { + return message.__openclaw && + typeof message.__openclaw === "object" && + !Array.isArray(message.__openclaw) + ? (message.__openclaw as Record) + : {}; +} + +function isInjectedControlMessage(message: RawOpenClawHistoryMessage): boolean { + return ( + stringValue(message.provider)?.toLowerCase() === "openclaw" && + stringValue(message.model)?.toLowerCase() === "gateway-injected" + ); +} + function normalizedIsFinal(message: RawOpenClawHistoryMessage): true | undefined { const role = typeof message.role === "string" ? message.role.toLowerCase() : ""; if ( @@ -410,6 +425,7 @@ function stripGeneratedImagePlaceholder( export function normalizeOpenClawHistoryMessage( message: RawOpenClawHistoryMessage ): CanonicalChatMessage { + const isControl = isInjectedControlMessage(message); const content = message.content ?? message.text ?? ""; const primaryText = normalizeCanonicalChatText(primaryContent(content)); const canonicalMedia = canonicalizeCanonicalChatMedia(content); @@ -427,11 +443,17 @@ export function normalizeOpenClawHistoryMessage( images, attachments ); + let role = typeof message.role === "string" ? message.role : "unknown"; + if (isControl) { + role = "system"; + } return { - role: typeof message.role === "string" ? message.role : "unknown", + role, content: canonicalMedia.content, + controlId: isControl ? stringValue(historyMetadata(message).id) : undefined, text, images, + intent: isControl ? "control" : undefined, attachments, isFinal: normalizedIsFinal(message), isToolUse: normalizedIsToolUse(message), diff --git a/contracts/chat/openClawHistoryPageAdapter.ts b/contracts/chat/openClawHistoryPageAdapter.ts index 1ee27620c..999ca5474 100644 --- a/contracts/chat/openClawHistoryPageAdapter.ts +++ b/contracts/chat/openClawHistoryPageAdapter.ts @@ -4,7 +4,9 @@ import type { } from "../chatCanonical"; import { CANONICAL_CHAT_HISTORY_SCHEMA_VERSION, + parseCanonicalChatHistoryMessageResult, parseCanonicalChatHistoryPage, + type CanonicalChatHistoryMessageResult, type CanonicalChatHistoryPage, type CanonicalChatHistoryRow, } from "../chatCanonicalHistory"; @@ -24,6 +26,14 @@ interface CanonicalizeOpenClawHistoryPageOptions { sessionKey: string; } +interface CanonicalizeOpenClawHistoryMessageOptions { + messageId: string; + sessionKey: string; +} + +const CHAT_HISTORY_TRUNCATION_SUFFIX = "\n...(truncated)..."; +const CHAT_HISTORY_OVERSIZED_PLACEHOLDER = "[chat.history omitted: message too large]"; + function asRecord(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) @@ -41,10 +51,11 @@ function stringValue(value: unknown): string | undefined { } function historyProvider( - message: RawOpenClawHistoryMessage + message: RawOpenClawHistoryMessage, + eventName = "chat.history" ): CanonicalChatProviderMetadata { return { - eventName: "chat.history", + eventName, format: "openclaw-history", model: stringValue(message.model), provider: stringValue(message.provider), @@ -56,9 +67,10 @@ function historyRowId( message: RawOpenClawHistoryMessage, canonicalMessage: CanonicalChatMessage, metadata: Record | undefined, - fallbackPosition: number + fallbackPosition: number, + explicitMessageId?: string ): string { - const providerId = stringValue(metadata?.id); + const providerId = explicitMessageId || stringValue(metadata?.id); const providerSequence = nonNegativeInteger(metadata?.seq); let sourceId = providerId; if (!sourceId) { @@ -93,24 +105,40 @@ function historyRowId( function canonicalHistoryRow( sessionKey: string, message: RawOpenClawHistoryMessage, - fallbackPosition: number + fallbackPosition: number, + options: { + eventName?: string; + messageId?: string; + truncated?: boolean; + } = {} ): CanonicalChatHistoryRow { const metadata = asRecord(message.__openclaw); const canonicalMessage = normalizeOpenClawHistoryMessage(message); + const messageId = options.messageId || stringValue(metadata?.id); + const role = canonicalMessage.role.toLowerCase(); + const isPrimaryTranscriptMessage = ["assistant", "system", "user"].includes(role); + const normalizedText = canonicalMessage.text.trimEnd(); + const isLightweightPreview = + isPrimaryTranscriptMessage && + (normalizedText.endsWith(CHAT_HISTORY_TRUNCATION_SUFFIX.trimStart()) || + normalizedText === CHAT_HISTORY_OVERSIZED_PLACEHOLDER); return { id: historyRowId( sessionKey, message, canonicalMessage, metadata, - fallbackPosition + fallbackPosition, + messageId ), message: canonicalMessage, - provider: historyProvider(message), + messageId, + provider: historyProvider(message, options.eventName), schemaVersion: CANONICAL_CHAT_HISTORY_SCHEMA_VERSION, sequence: nonNegativeInteger(metadata?.seq), sessionKey, source: "openclaw-history", + truncated: options.truncated ?? (isLightweightPreview || undefined), }; } @@ -178,3 +206,49 @@ export function canonicalizeOpenClawHistoryPage( totalMessages: nonNegativeInteger(page?.totalMessages), }); } + +/** + * Converts one raw Gateway chat.message.get response into the Dashboard contract. + * @param raw Raw Gateway response. + * @param options Requested transcript and message identity. + * @returns Versioned provider-independent full-message response. + */ +export function canonicalizeOpenClawHistoryMessageResult( + raw: unknown, + options: CanonicalizeOpenClawHistoryMessageOptions +): CanonicalChatHistoryMessageResult { + const result = asRecord(raw); + const sessionKey = options.sessionKey.trim(); + const messageId = options.messageId.trim(); + if (!sessionKey || !messageId) { + throw new Error("OpenClaw full chat message identity is required"); + } + if (result?.ok !== true) { + const unavailableReason = result?.unavailableReason; + if ( + unavailableReason !== "not_found" && + unavailableReason !== "not_visible" && + unavailableReason !== "oversized" + ) { + throw new Error("OpenClaw full chat message unavailable reason is invalid"); + } + return parseCanonicalChatHistoryMessageResult({ + ok: false, + schemaVersion: CANONICAL_CHAT_HISTORY_SCHEMA_VERSION, + unavailableReason, + }); + } + const message = asRecord(result.message) as RawOpenClawHistoryMessage | undefined; + if (!message) { + throw new Error("OpenClaw full chat message is missing"); + } + return parseCanonicalChatHistoryMessageResult({ + message: canonicalHistoryRow(sessionKey, message, 0, { + eventName: "chat.message.get", + messageId, + truncated: false, + }), + ok: true, + schemaVersion: CANONICAL_CHAT_HISTORY_SCHEMA_VERSION, + }); +} diff --git a/contracts/chat/openClawRuntimeAdapter.ts b/contracts/chat/openClawRuntimeAdapter.ts index 7ee628d2a..11d9d9c1f 100644 --- a/contracts/chat/openClawRuntimeAdapter.ts +++ b/contracts/chat/openClawRuntimeAdapter.ts @@ -12,6 +12,7 @@ import { import { asRecord, isNonWorkTool, + isPreambleItem, isThinkingItem, itemStrings, itemTexts, @@ -114,6 +115,22 @@ function chatEventDrafts( rawMessage === undefined ? undefined : normalizeAssistant(rawMessage, common.runId); + if (state === "final" && message?.intent === "control") { + const controlId = message.controlId || common.runId?.replace(/^inject-/u, ""); + return [ + { + kind: "control", + message: { + ...message, + controlId, + runId: undefined, + timestamp: common.timestamp, + }, + sessionKey: common.sessionKey, + timestamp: common.timestamp, + }, + ]; + } const isCommand = asRecord(payload.message)?.command === true; const explicitError = stringValue(payload.errorMessage) || stringValue(payload.error); const isMessageToolFailure = state === "error" && isToolFailureError(message?.text); @@ -214,6 +231,8 @@ function runtimeStreamDrafts( } } else if (stream === "thinking" || stream === "reasoning") { appendThinkingDraft(drafts, data, common); + } else if (stream === "item" && isPreambleItem(data)) { + appendItemCommentaryDraft(drafts, data, common); } else if (stream === "item" && isThinkingItem(data)) { appendItemThinkingDraft(drafts, data, common); } @@ -340,6 +359,40 @@ function appendItemThinkingDraft( }); } +function appendItemCommentaryDraft( + drafts: CanonicalChatEventDraft[], + data: Record, + common: { runId?: string; sessionKey: string; timestamp: string } +): void { + const snapshotText = itemTexts(data, [ + "progressText", + "summary", + "text", + "meta", + "content", + ])[0]; + const textDelta = itemTexts(data, ["delta"])[0]; + const text = snapshotText || textDelta; + if (!text) { + return; + } + const itemId = itemStrings(data, ["itemId", "id"])[0]; + drafts.push({ + ...common, + kind: "commentary", + message: { + content: "", + intent: "commentary", + role: "assistant", + runId: common.runId, + runtimeKey: itemId ? `commentary:${itemId}` : undefined, + text, + timestamp: common.timestamp, + }, + mode: snapshotText ? "replace" : "append", + }); +} + function canonicalOpenClawOrigin(eventName: string): CanonicalChatEvent["origin"] { if (eventName === "chat") { return "openclaw-chat"; @@ -368,6 +421,9 @@ function canonicalOpenClawFormat( function canonicalOpenClawLifecycle( draft: CanonicalChatEventDraft ): CanonicalChatLifecycle { + if (draft.kind === "control") { + return "completed"; + } if (draft.kind === "finish") { return draft.outcome; } @@ -469,9 +525,10 @@ export function adaptOpenClawRuntimeEvent( lifecycle: canonicalOpenClawLifecycle(draft), origin, provider, - ...(runtimeRunAliases.length > 0 && { - runAliases: runtimeRunAliases, - }), + ...(draft.kind !== "control" && + runtimeRunAliases.length > 0 && { + runAliases: runtimeRunAliases, + }), schemaVersion: CANONICAL_CHAT_EVENT_SCHEMA_VERSION, sequence: canonicalSequence, }; @@ -526,11 +583,14 @@ export function withCurrentCanonicalOpenClawIdentity< event.message && { message: { ...event.message, - runId: context.runId, + runId: event.kind === "control" ? undefined : context.runId, }, }), - runAliases: runAliases.length > 0 ? runAliases : undefined, - runId: context.runId, + runAliases: + event.kind !== "control" && runAliases.length > 0 + ? runAliases + : undefined, + runId: event.kind === "control" ? undefined : context.runId, sessionKey: context.sessionKey, })), }; diff --git a/contracts/chat/openClawToolAdapter.ts b/contracts/chat/openClawToolAdapter.ts index e635d7775..f2ee5bdc3 100644 --- a/contracts/chat/openClawToolAdapter.ts +++ b/contracts/chat/openClawToolAdapter.ts @@ -15,6 +15,7 @@ import { compactStatus, formatToolName, isNonWorkTool, + isPreambleItem, isThinkingItem, isToolCallItem, isToolResultItem, @@ -236,7 +237,11 @@ export function openClawProgress( }; } if (stream === "item") { - if (data.suppressChannelProgress === true || isThinkingItem(data)) { + if ( + data.suppressChannelProgress === true || + isPreambleItem(data) || + isThinkingItem(data) + ) { return {}; } const name = stringValue(data.name) || stringValue(data.itemKind); diff --git a/contracts/chatCanonical.ts b/contracts/chatCanonical.ts index 2e486781c..7eeccc277 100644 --- a/contracts/chatCanonical.ts +++ b/contracts/chatCanonical.ts @@ -74,8 +74,10 @@ export const canonicalChatToolCallSchema = v.strictObject({ export const canonicalChatMessageSchema = v.strictObject({ attachments: v.optional(v.array(canonicalChatAttachmentSchema)), content: v.unknown(), + controlId: v.optional(nonEmptyStringSchema), hasOnlyHiddenToolAttachments: v.optional(v.boolean()), images: v.optional(v.array(canonicalChatImageSchema)), + intent: v.optional(v.picklist(["commentary", "control"])), isFinal: v.optional(v.boolean()), isToolUse: v.optional(v.boolean()), local: v.optional(v.boolean()), @@ -124,6 +126,17 @@ export const canonicalChatEventSchema = v.variant("kind", [ ...canonicalEventBase, kind: v.literal("identity"), }), + v.strictObject({ + ...canonicalEventBase, + kind: v.literal("control"), + message: canonicalChatMessageSchema, + }), + v.strictObject({ + ...canonicalEventBase, + kind: v.literal("commentary"), + message: canonicalChatMessageSchema, + mode: v.picklist(["append", "replace"]), + }), v.strictObject({ ...canonicalEventBase, kind: v.literal("user"), diff --git a/contracts/chatCanonicalHistory.ts b/contracts/chatCanonicalHistory.ts index a4e342a5b..40a56062d 100644 --- a/contracts/chatCanonicalHistory.ts +++ b/contracts/chatCanonicalHistory.ts @@ -13,11 +13,13 @@ const nonEmptyStringSchema = v.pipe(v.string(), v.trim(), v.nonEmpty()); export const canonicalChatHistoryRowSchema = v.strictObject({ id: nonEmptyStringSchema, message: canonicalChatMessageSchema, + messageId: v.optional(nonEmptyStringSchema), provider: canonicalChatProviderMetadataSchema, schemaVersion: v.literal(CANONICAL_CHAT_HISTORY_SCHEMA_VERSION), sequence: v.optional(nonNegativeIntegerSchema), sessionKey: nonEmptyStringSchema, source: v.literal("openclaw-history"), + truncated: v.optional(v.boolean()), }); export const canonicalChatHistoryPageSchema = v.strictObject({ @@ -31,10 +33,26 @@ export const canonicalChatHistoryPageSchema = v.strictObject({ totalMessages: v.optional(nonNegativeIntegerSchema), }); +export const canonicalChatHistoryMessageResultSchema = v.union([ + v.strictObject({ + message: canonicalChatHistoryRowSchema, + ok: v.literal(true), + schemaVersion: v.literal(CANONICAL_CHAT_HISTORY_SCHEMA_VERSION), + }), + v.strictObject({ + ok: v.literal(false), + schemaVersion: v.literal(CANONICAL_CHAT_HISTORY_SCHEMA_VERSION), + unavailableReason: v.picklist(["not_found", "not_visible", "oversized"]), + }), +]); + export type CanonicalChatHistoryRow = v.InferOutput; export type CanonicalChatHistoryPage = v.InferOutput< typeof canonicalChatHistoryPageSchema >; +export type CanonicalChatHistoryMessageResult = v.InferOutput< + typeof canonicalChatHistoryMessageResultSchema +>; /** * Parses one canonical history page at a transport boundary. @@ -48,3 +66,16 @@ export function parseCanonicalChatHistoryPage( ): CanonicalChatHistoryPage { return parseContract(canonicalChatHistoryPageSchema, value, path); } + +/** + * Parses one canonical full-message response at a transport boundary. + * @param value Value to validate. + * @param path Contract path used in validation errors. + * @returns Validated canonical full-message response. + */ +export function parseCanonicalChatHistoryMessageResult( + value: unknown, + path = "chatHistoryMessage" +): CanonicalChatHistoryMessageResult { + return parseContract(canonicalChatHistoryMessageResultSchema, value, path); +} diff --git a/contracts/chatCanonicalTurn.ts b/contracts/chatCanonicalTurn.ts index 1d4eba65c..450867dbe 100644 --- a/contracts/chatCanonicalTurn.ts +++ b/contracts/chatCanonicalTurn.ts @@ -27,7 +27,7 @@ const canonicalChatTurnSourceReferenceSchema = v.strictObject({ export const canonicalChatTurnEntrySchema = v.strictObject({ id: nonEmptyStringSchema, - kind: v.picklist(["assistant", "thinking", "tool", "user"]), + kind: v.picklist(["assistant", "commentary", "control", "thinking", "tool", "user"]), message: canonicalChatMessageSchema, origin: v.optional( v.picklist(["openclaw-chat", "openclaw-runtime", "openclaw-session"]) diff --git a/frontend/src/components/features/chat/AttachmentPreviewModal.tsx b/frontend/src/components/features/chat/AttachmentPreviewModal.tsx index 5d194ab71..463d29340 100644 --- a/frontend/src/components/features/chat/AttachmentPreviewModal.tsx +++ b/frontend/src/components/features/chat/AttachmentPreviewModal.tsx @@ -68,27 +68,31 @@ function AttachmentPreviewBody({ {previewItem.title} ); } if (isTextPreview && textPreview !== undefined) { if (shouldRenderJson) { return ( -
- +
+
); } if (shouldRenderMarkdown) { return ( -
- +
+
); } return ( -
+            
                 {textPreview}
             
); @@ -189,8 +193,8 @@ export function AttachmentPreviewContent({ const isTextPreview = previewItem.kind === "text"; return ( -
-
+
+
@@ -220,16 +224,21 @@ export function AttachmentPreviewContent({ ) : undefined}
- +
+ +
); } @@ -246,6 +255,7 @@ export function AttachmentPreviewModal({ diff --git a/frontend/src/components/features/chat/ChatAttachmentPickerModal.tsx b/frontend/src/components/features/chat/ChatAttachmentPickerModal.tsx index eeacb00ed..bbb4a5258 100644 --- a/frontend/src/components/features/chat/ChatAttachmentPickerModal.tsx +++ b/frontend/src/components/features/chat/ChatAttachmentPickerModal.tsx @@ -172,22 +172,25 @@ export function ChatAttachmentPickerModal({ {previewItem ? ( -
+
- +
+ +
) : (
diff --git a/frontend/src/components/features/chat/ChatMessagesList.tsx b/frontend/src/components/features/chat/ChatMessagesList.tsx index cca5276c6..79737d94b 100644 --- a/frontend/src/components/features/chat/ChatMessagesList.tsx +++ b/frontend/src/components/features/chat/ChatMessagesList.tsx @@ -542,6 +542,7 @@ export function ChatMessagesList({ return (