fix(gateway): dedupe delivery mirror history rows

This commit is contained in:
Vincent Koc
2026-06-06 06:19:31 -07:00
parent a547010a95
commit f5eddc2b6d
8 changed files with 410 additions and 39 deletions
@@ -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<string>(["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;
+27 -4
View File
@@ -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 () => {
+2 -1
View File
@@ -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 };
+2 -4
View File
@@ -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: {
+62 -2
View File
@@ -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<string, unknown>;
completionAnchor?: Record<string, unknown>;
deliveryMirrorAnchor?: Record<string, unknown>;
deliveryMirrorIndex?: number;
succeeded: boolean;
};
@@ -929,6 +932,16 @@ function buildMessageToolVisibleReplyMirror(
return mirror;
}
function readMessageToolDeliveryMirrorText(message: Record<string, unknown>): 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;
@@ -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([
+39 -5
View File
@@ -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<typeof makeTranscriptAssistantMessage>[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) => {
@@ -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<string>([
"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"
);
}