diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index d64015b9a171..095a3f78e9a5 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -18,6 +18,7 @@ import { hasInterSessionUserProvenance, normalizeInputProvenance, } from "../../sessions/input-provenance.js"; +import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js"; import { downgradeOpenAIFunctionCallReasoningPairs, downgradeOpenAIReasoningBlocks, @@ -235,15 +236,6 @@ function stripStaleAssistantUsageBeforeLatestCompaction(messages: AgentMessage[] return touched ? out : messages; } -// `provider:"openclaw"` assistant entries written by the channel-delivery -// transcript mirror (`model:"delivery-mirror"`, see config/sessions/transcript.ts) -// and by the Gateway transcript-inject helper (`model:"gateway-injected"`, see -// gateway/server-methods/chat-transcript-inject.ts) are user-visible transcript -// records, not model output. Replaying them to the actual provider duplicates -// content and, on Bedrock or strict OpenAI-compatible providers, can also -// trigger turn-ordering rejections. -const TRANSCRIPT_ONLY_OPENCLAW_MODELS = new Set(["delivery-mirror", "gateway-injected"]); - function sanitizeUserReplayContent(message: AgentMessage): AgentMessage | null { if (!message || message.role !== "user") { return message; @@ -277,19 +269,6 @@ function sanitizeUserReplayContent(message: AgentMessage): AgentMessage | null { return touched ? ({ ...message, content: sanitizedContent } as AgentMessage) : message; } -function isTranscriptOnlyOpenclawAssistant(message: AgentMessage): boolean { - if (!message || message.role !== "assistant") { - return false; - } - const provider = (message as { provider?: unknown }).provider; - const model = (message as { model?: unknown }).model; - return ( - provider === "openclaw" && - typeof model === "string" && - TRANSCRIPT_ONLY_OPENCLAW_MODELS.has(model) - ); -} - function normalizeAssistantReplayTextContent(message: AgentMessage, replayContent: string) { const strippedText = stripInternalMetadataForDisplay(replayContent); const trimmed = strippedText.trim(); @@ -357,7 +336,7 @@ export function normalizeAssistantReplayContent(messages: AgentMessage[]): Agent out.push(message); continue; } - if (isTranscriptOnlyOpenclawAssistant(message)) { + if (isTranscriptOnlyOpenClawAssistantMessage(message)) { // Drop from the in-memory replay copy; the persisted JSONL keeps the // entry so user-facing transcript surfaces are unchanged. touched = true; diff --git a/src/agents/openclaw-tools.sessions.test.ts b/src/agents/openclaw-tools.sessions.test.ts index ed278bc9623e..306c8f6c89a8 100644 --- a/src/agents/openclaw-tools.sessions.test.ts +++ b/src/agents/openclaw-tools.sessions.test.ts @@ -712,6 +712,18 @@ describe("sessions tools", () => { return { messages: [ { role: "toolResult", content: [] }, + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "mirrored" }], + }, + { + role: "assistant", + provider: "openclaw", + model: "gateway-injected", + content: [{ type: "text", text: "injected" }], + }, { role: "assistant", content: [{ type: "text", text: "ok" }] }, ], }; @@ -725,16 +737,27 @@ describe("sessions tools", () => { } const result = await tool.execute("call3", { sessionKey: "main" }); - const details = result.details as { messages?: Array<{ role?: string }> }; - expect(details.messages).toHaveLength(1); - expect(details.messages?.[0]?.role).toBe("assistant"); + const details = result.details as { messages?: unknown[] }; + expect(details.messages).toHaveLength(3); + expect(details.messages).toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "gateway-injected" }), + ); + expect(details.messages).toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }), + ); const withTools = await tool.execute("call4", { sessionKey: "main", includeTools: true, }); const withToolsDetails = withTools.details as { messages?: unknown[] }; - expect(withToolsDetails.messages).toHaveLength(2); + expect(withToolsDetails.messages).toHaveLength(4); + expect(withToolsDetails.messages).toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }), + ); + expect(withToolsDetails.messages).toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "gateway-injected" }), + ); }); it("sessions_history caps oversized payloads and strips heavy fields", async () => { diff --git a/src/agents/session-tool-result-guard.ts b/src/agents/session-tool-result-guard.ts index 5b2996232df7..b10fed4f2888 100644 --- a/src/agents/session-tool-result-guard.ts +++ b/src/agents/session-tool-result-guard.ts @@ -21,6 +21,7 @@ import type { PluginHookBeforeMessageWriteResult, } from "../plugins/types.js"; import { emitSessionTranscriptUpdate } from "../sessions/transcript-events.js"; +import { isTranscriptOnlyOpenClawAssistantModel } from "../shared/transcript-only-openclaw-assistant.js"; import { formatContextLimitTruncationNotice } from "./embedded-agent-runner/context-truncation-notice.js"; import { DEFAULT_MAX_LIVE_TOOL_RESULT_CHARS, @@ -533,7 +534,7 @@ function isTranscriptOnlyOpenClawAssistantMessage(message: AgentMessage): boolea } const provider = normalizeOptionalString((message as { provider?: unknown }).provider) ?? ""; const model = normalizeOptionalString((message as { model?: unknown }).model) ?? ""; - return provider === "openclaw" && (model === "delivery-mirror" || model === "gateway-injected"); + return isTranscriptOnlyOpenClawAssistantModel(provider, model); } export { getRawSessionAppendMessage }; diff --git a/src/config/sessions/transcript.ts b/src/config/sessions/transcript.ts index a3d4a0037590..15d856c6489b 100644 --- a/src/config/sessions/transcript.ts +++ b/src/config/sessions/transcript.ts @@ -7,6 +7,7 @@ import { redactTranscriptMessage } from "../../agents/transcript-redact.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { emitSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { extractAssistantVisibleText } from "../../shared/chat-message-content.js"; +import { isTranscriptOnlyOpenClawAssistantModel } from "../../shared/transcript-only-openclaw-assistant.js"; import type { OpenClawConfig } from "../types.openclaw.js"; import { resolveDefaultSessionStorePath, @@ -97,10 +98,7 @@ function isTranscriptOnlyOpenClawAssistantMessage(message: { provider?: unknown; model?: unknown; }): boolean { - return ( - message.provider === "openclaw" && - (message.model === "delivery-mirror" || message.model === "gateway-injected") - ); + return isTranscriptOnlyOpenClawAssistantModel(message.provider, message.model); } export async function resolveSessionTranscriptFile(params: { diff --git a/src/gateway/chat-display-projection.ts b/src/gateway/chat-display-projection.ts index ff5c6cbfa031..cfaf841900e8 100644 --- a/src/gateway/chat-display-projection.ts +++ b/src/gateway/chat-display-projection.ts @@ -18,6 +18,7 @@ import { parseAssistantTextSignature, resolveAssistantMessagePhase, } from "../shared/chat-message-content.js"; +import { isOpenClawDeliveryMirrorAssistantMessage } from "../shared/transcript-only-openclaw-assistant.js"; import { stripInlineDirectiveTagsForDisplay } from "../utils/directive-tags.js"; import { stripEnvelopeFromMessages } from "./chat-sanitize.js"; import { isSuppressedControlReplyText } from "./control-reply-text.js"; @@ -34,6 +35,8 @@ type PendingMessageToolVisibleReply = { text: string; anchor: Record; completionAnchor?: Record; + deliveryMirrorAnchor?: Record; + deliveryMirrorIndex?: number; succeeded: boolean; }; @@ -929,6 +932,16 @@ function buildMessageToolVisibleReplyMirror( return mirror; } +function readMessageToolDeliveryMirrorText(message: Record): string | undefined { + // Delivery mirrors can arrive between a successful message-tool result and + // the final NO_REPLY. The pending mirror is the display row; the raw mirror + // would duplicate that same send. + if (!isOpenClawDeliveryMirrorAssistantMessage(message)) { + return undefined; + } + return displayTextForDuplicateCheck(message); +} + function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] { if (messages.length === 0) { return messages; @@ -957,6 +970,24 @@ function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] { clearPending(); }; + const flushSelectedMirrors = (items: PendingMessageToolVisibleReply[]) => { + if (items.length === 0) { + return; + } + const selected = new Set(items); + const remaining: PendingMessageToolVisibleReply[] = []; + for (const item of pending) { + if (selected.has(item) && item.succeeded) { + next.push(buildMessageToolVisibleReplyMirror(item)); + changed = true; + continue; + } + remaining.push(item); + } + pending.length = 0; + pending.push(...remaining); + }; + for (const message of messages) { const record = readRecord(message); if (!record) { @@ -978,6 +1009,12 @@ function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] { continue; } + const flushAfterCurrentMessage: PendingMessageToolVisibleReply[] = []; + const deliveryMirrorText = readMessageToolDeliveryMirrorText(record); + const matchingDeliveryMirrorPending = deliveryMirrorText + ? pending.filter((item) => item.text.trim() === deliveryMirrorText) + : []; + const duplicateDeliveryMirror = matchingDeliveryMirrorPending.some((item) => item.succeeded); const visibleReplies = extractMessageToolVisibleReplies(record); if (visibleReplies.length > 0) { for (const reply of visibleReplies) { @@ -987,7 +1024,10 @@ function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] { succeeded: false, }); } - } else if (isRenderableAssistantDisplayMessage(record)) { + } else if ( + matchingDeliveryMirrorPending.length === 0 && + isRenderableAssistantDisplayMessage(record) + ) { clearPending(); } @@ -995,7 +1035,13 @@ function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] { for (const item of pending) { if (!item.succeeded && isSuccessfulMessageToolResult(record, item)) { item.succeeded = true; - item.completionAnchor = record; + item.completionAnchor = item.deliveryMirrorAnchor ?? record; + if (item.deliveryMirrorAnchor) { + if (typeof item.deliveryMirrorIndex === "number") { + next[item.deliveryMirrorIndex] = { ...item.deliveryMirrorAnchor, display: false }; + } + flushAfterCurrentMessage.push(item); + } } } if (isAssistantSilentControlReplyOnly(record)) { @@ -1003,7 +1049,21 @@ function mirrorMessageToolVisibleReplies(messages: unknown[]): unknown[] { } } + if (duplicateDeliveryMirror) { + for (const item of matchingDeliveryMirrorPending) { + item.completionAnchor = record; + } + flushSelectedMirrors(matchingDeliveryMirrorPending); + changed = true; + continue; + } + + for (const item of matchingDeliveryMirrorPending) { + item.deliveryMirrorAnchor = record; + item.deliveryMirrorIndex = next.length; + } next.push(message); + flushSelectedMirrors(flushAfterCurrentMessage); } return changed ? next : messages; diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.test.ts index 5383e518d4f6..50ab504bea30 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.test.ts @@ -746,6 +746,246 @@ describe("gateway server chat", () => { ).toBe(true); }); + test("chat.history hides raw delivery-mirror rows but keeps message-tool mirrors", async () => { + const replyText = "One visible send."; + const historyMessages = await loadChatHistoryWithMessages([ + { + role: "user", + content: [{ type: "text", text: "send once" }], + timestamp: 1, + }, + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-message-transcript-only", + name: "message", + arguments: { + action: "send", + message: replyText, + }, + }, + ], + timestamp: 2, + }, + { + role: "toolResult", + toolName: "message", + toolCallId: "call-message-transcript-only", + content: { ok: true, messageId: "24271", chatId: "current-run" }, + timestamp: 3, + }, + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: replyText }], + timestamp: 4, + }, + { + role: "assistant", + content: [{ type: "text", text: "NO_REPLY" }], + timestamp: 5, + }, + ]); + + expect(collectHistoryTextValues(historyMessages)).toEqual(["send once", replyText]); + expect( + historyMessages.some( + (message) => + Boolean(message) && + typeof message === "object" && + Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror), + ), + ).toBe(true); + expect(historyMessages).not.toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }), + ); + }); + + test("chat.history keeps message-tool mirrors before silent completion rows", async () => { + const replyText = "Visible before completion."; + const historyMessages = await loadChatHistoryWithMessages([ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-message-before-completion", + name: "message", + arguments: { + action: "send", + message: replyText, + }, + }, + ], + timestamp: 1, + }, + { + role: "toolResult", + toolName: "message", + toolCallId: "call-message-before-completion", + content: { ok: true, messageId: "24272", chatId: "current-run" }, + timestamp: 2, + }, + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: replyText }], + timestamp: 3, + }, + ]); + + expect(collectHistoryTextValues(historyMessages)).toEqual([replyText]); + expect( + historyMessages.some( + (message) => + Boolean(message) && + typeof message === "object" && + Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror), + ), + ).toBe(true); + expect(historyMessages).not.toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }), + ); + }); + + test("chat.history hides delivery mirrors that precede successful tool results", async () => { + const replyText = "Visible after result."; + const historyMessages = await loadChatHistoryWithMessages([ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-message-before-result", + name: "message", + arguments: { + action: "send", + message: replyText, + }, + }, + ], + timestamp: 1, + }, + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: replyText }], + timestamp: 2, + }, + { + role: "toolResult", + toolName: "message", + toolCallId: "call-message-before-result", + content: { ok: true, messageId: "24273", chatId: "current-run" }, + timestamp: 3, + }, + ]); + + expect(collectHistoryTextValues(historyMessages)).toEqual([replyText]); + expect( + historyMessages.some( + (message) => + Boolean(message) && + typeof message === "object" && + Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror), + ), + ).toBe(true); + expect(historyMessages).not.toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }), + ); + }); + + test("chat.history preserves other pending message-tool mirrors while deduping one send", async () => { + const firstText = "First visible send."; + const secondText = "Second visible send."; + const historyMessages = await loadChatHistoryWithMessages([ + { + role: "assistant", + content: [ + { + type: "toolCall", + id: "call-message-first", + name: "message", + arguments: { + action: "send", + message: firstText, + }, + }, + { + type: "toolCall", + id: "call-message-second", + name: "message", + arguments: { + action: "send", + message: secondText, + }, + }, + ], + timestamp: 1, + }, + { + role: "toolResult", + toolName: "message", + toolCallId: "call-message-first", + content: { ok: true, messageId: "24274", chatId: "current-run" }, + timestamp: 2, + }, + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: firstText }], + timestamp: 3, + }, + { + role: "toolResult", + toolName: "message", + toolCallId: "call-message-second", + content: { ok: true, messageId: "24275", chatId: "current-run" }, + timestamp: 4, + }, + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: secondText }], + timestamp: 5, + }, + ]); + + expect(collectHistoryTextValues(historyMessages)).toEqual([firstText, secondText]); + expect( + historyMessages.filter( + (message) => + Boolean(message) && + typeof message === "object" && + Boolean((message as { openclawMessageToolMirror?: unknown }).openclawMessageToolMirror), + ), + ).toHaveLength(2); + expect(historyMessages).not.toContainEqual( + expect.objectContaining({ provider: "openclaw", model: "delivery-mirror" }), + ); + }); + + test("chat.history keeps standalone delivery-mirror rows", async () => { + const historyMessages = await loadChatHistoryWithMessages([ + { + role: "assistant", + provider: "openclaw", + model: "delivery-mirror", + content: [{ type: "text", text: "standalone delivered reply" }], + timestamp: 1, + }, + ]); + + expect(collectHistoryTextValues(historyMessages)).toEqual(["standalone delivered reply"]); + }); + test("chat.history mirrors current-session message tool sends with channel hints", async () => { const replyText = "Still the current chat."; const historyMessages = await loadChatHistoryWithMessages([ diff --git a/src/gateway/sessions-history-http.test.ts b/src/gateway/sessions-history-http.test.ts index 044cc20d8a0c..c18b6cbe5654 100644 --- a/src/gateway/sessions-history-http.test.ts +++ b/src/gateway/sessions-history-http.test.ts @@ -56,10 +56,10 @@ async function seedSession(params?: { text?: string }) { storePath, }); if (params?.text) { - const appended = await appendAssistantMessageToSessionTranscript({ + const appended = await appendExactAssistantMessageToSessionTranscript({ sessionKey: "agent:main:main", - text: params.text, storePath, + message: makeTranscriptAssistantMessage({ text: params.text }), }); expect(appended.ok).toBe(true); } @@ -69,13 +69,15 @@ async function seedSession(params?: { text?: string }) { function makeTranscriptAssistantMessage(params: { text: string; content?: AssistantMessage["content"]; + provider?: string; + model?: string; }): AssistantMessage { return { role: "assistant" as const, content: params.content ?? [{ type: "text", text: params.text }], api: "openai-responses", - provider: "openclaw", - model: "delivery-mirror", + provider: params.provider ?? "openai", + model: params.model ?? "gpt-5.5", usage: { input: 0, output: 0, @@ -95,6 +97,16 @@ function makeTranscriptAssistantMessage(params: { }; } +function makeDeliveryMirrorAssistantMessage( + params: Parameters[0], +): AssistantMessage { + return makeTranscriptAssistantMessage({ + ...params, + provider: "openclaw", + model: "delivery-mirror", + }); +} + async function appendTranscriptMessage(params: { sessionKey: string; message: AssistantMessage; @@ -119,7 +131,11 @@ async function appendVisibleAssistantMessage(params: { text: string; storePath: string; }) { - const appended = await appendAssistantMessageToSessionTranscript(params); + const appended = await appendExactAssistantMessageToSessionTranscript({ + sessionKey: params.sessionKey, + storePath: params.storePath, + message: makeTranscriptAssistantMessage({ text: params.text }), + }); expect(appended.ok).toBe(true); if (!appended.ok) { throw new Error(`append failed: ${appended.reason}`); @@ -363,6 +379,24 @@ describe("session history HTTP endpoints", () => { }); }); + test("keeps standalone delivery-mirror rows in direct REST history", async () => { + const { storePath } = await seedSession({ text: "visible history" }); + await appendTranscriptMessage({ + sessionKey: "agent:main:main", + storePath, + message: makeDeliveryMirrorAssistantMessage({ text: "raw delivery mirror" }), + emitInlineMessage: false, + }); + + await withGatewayHarness(async (harness) => { + const body = await readSessionHistoryBody(harness.port, "agent:main:main"); + expect(body.messages?.map((message) => message.content?.[0]?.text)).toEqual([ + "visible history", + "raw delivery mirror", + ]); + }); + }); + test("returns 404 for unknown sessions", async () => { await createSessionStoreFile(); await withGatewayHarness(async (harness) => { diff --git a/src/shared/transcript-only-openclaw-assistant.ts b/src/shared/transcript-only-openclaw-assistant.ts new file mode 100644 index 000000000000..334a1b0ea05c --- /dev/null +++ b/src/shared/transcript-only-openclaw-assistant.ts @@ -0,0 +1,36 @@ +// Identifies OpenClaw-authored assistant rows that are transcript bookkeeping, +// not provider model output. Some history surfaces keep gateway-injected rows +// visible, so use the narrower delivery-mirror predicate when visibility matters. +export const TRANSCRIPT_ONLY_OPENCLAW_ASSISTANT_MODELS = new Set([ + "delivery-mirror", + "gateway-injected", +]); + +export function isTranscriptOnlyOpenClawAssistantModel(provider: unknown, model: unknown): boolean { + return ( + provider === "openclaw" && + typeof model === "string" && + TRANSCRIPT_ONLY_OPENCLAW_ASSISTANT_MODELS.has(model) + ); +} + +export function isTranscriptOnlyOpenClawAssistantMessage(message: unknown): boolean { + if (!message || typeof message !== "object" || Array.isArray(message)) { + return false; + } + const entry = message as { role?: unknown; provider?: unknown; model?: unknown }; + return ( + entry.role === "assistant" && + isTranscriptOnlyOpenClawAssistantModel(entry.provider, entry.model) + ); +} + +export function isOpenClawDeliveryMirrorAssistantMessage(message: unknown): boolean { + if (!message || typeof message !== "object" || Array.isArray(message)) { + return false; + } + const entry = message as { role?: unknown; provider?: unknown; model?: unknown }; + return ( + entry.role === "assistant" && entry.provider === "openclaw" && entry.model === "delivery-mirror" + ); +}