Skip to content

Commit f9dddea

Browse files
authored
fix(gateway): expose idempotencyKey in chat history metadata (#96273)
* fix(gateway): surface idempotency key in history metadata * test(gateway): avoid unsafe optional chain * fix(gateway): preserve oversized transcript idempotency keys * fix(gateway): preserve oversized idempotency keys * fix(gateway): reuse transcript json helpers --------- Co-authored-by: 吴杨帆 <[email protected]>
1 parent ad8e7dc commit f9dddea

7 files changed

Lines changed: 170 additions & 3 deletions

File tree

src/gateway/server-methods/chat.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1649,13 +1649,16 @@ export function buildOversizedHistoryPlaceholder(message?: unknown): Record<stri
16491649
: {};
16501650
const metadataId = typeof metadata.id === "string" ? metadata.id : undefined;
16511651
const metadataSeq = typeof metadata.seq === "number" ? metadata.seq : undefined;
1652+
const metadataIdempotencyKey =
1653+
typeof metadata.idempotencyKey === "string" ? metadata.idempotencyKey : undefined;
16521654
return {
16531655
role,
16541656
timestamp,
16551657
content: [{ type: "text", text: CHAT_HISTORY_OVERSIZED_PLACEHOLDER }],
16561658
__openclaw: {
16571659
...(metadataId ? { id: metadataId } : {}),
16581660
...(metadataSeq !== undefined ? { seq: metadataSeq } : {}),
1661+
...(metadataIdempotencyKey ? { idempotencyKey: metadataIdempotencyKey } : {}),
16591662
truncated: true,
16601663
reason: "oversized",
16611664
},

src/gateway/server-session-events.test.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,15 @@ function createHandler(projectSessionActive: boolean) {
4343
return { broadcastToConnIds, handler };
4444
}
4545

46-
async function emitAssistantTranscriptUpdate(projectSessionActive: boolean) {
46+
async function emitAssistantTranscriptUpdate(
47+
projectSessionActive: boolean,
48+
message: unknown = { role: "assistant", content: [{ type: "text", text: "Final answer" }] },
49+
) {
4750
const { broadcastToConnIds, handler } = createHandler(projectSessionActive);
4851
handler({
4952
sessionFile: "/tmp/sess-main.jsonl",
5053
sessionKey: "agent:main:main",
51-
message: { role: "assistant", content: [{ type: "text", text: "Final answer" }] },
54+
message,
5255
messageId: "message-1",
5356
messageSeq: 1,
5457
});
@@ -79,4 +82,22 @@ describe("createTranscriptUpdateBroadcastHandler", () => {
7982
session: { hasActiveRun: false },
8083
});
8184
});
85+
86+
it("broadcasts user idempotency keys in session.message metadata", async () => {
87+
await expect(
88+
emitAssistantTranscriptUpdate(false, {
89+
role: "user",
90+
content: [{ type: "text", text: "Optimistic turn" }],
91+
idempotencyKey: "client-turn-3",
92+
}),
93+
).resolves.toMatchObject({
94+
message: {
95+
__openclaw: {
96+
id: "message-1",
97+
idempotencyKey: "client-turn-3",
98+
seq: 1,
99+
},
100+
},
101+
});
102+
});
82103
});

src/gateway/server-session-events.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ import {
3030
type SessionEventSubscribers = Pick<SessionEventSubscriberRegistry, "getAll">;
3131
type SessionMessageSubscribers = Pick<SessionMessageSubscriberRegistry, "get">;
3232

33+
function readMessageIdempotencyKey(message: unknown): string | undefined {
34+
if (!message || typeof message !== "object" || Array.isArray(message)) {
35+
return undefined;
36+
}
37+
const value = (message as Record<string, unknown>).idempotencyKey;
38+
return typeof value === "string" && value.trim() ? value : undefined;
39+
}
40+
3341
function resolveSessionMessageBroadcastKeys(sessionKey: string, agentId?: string): string[] {
3442
// Global sessions can be subscribed through either the raw global key or the
3543
// default-agent scoped key; non-default agent global sessions stay scoped.
@@ -173,8 +181,10 @@ async function handleTranscriptUpdateBroadcast(
173181
includeSession: true,
174182
hasActiveRun,
175183
});
184+
const idempotencyKey = readMessageIdempotencyKey(update.message);
176185
const rawMessage = attachOpenClawTranscriptMeta(update.message, {
177186
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
187+
...(idempotencyKey ? { idempotencyKey } : {}),
178188
...(messageSeq !== undefined ? { seq: messageSeq } : {}),
179189
});
180190
const message = projectChatDisplayMessage(rawMessage);

src/gateway/session-history-state.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,34 @@ describe("SessionHistorySseState", () => {
126126
}
127127
});
128128

129+
test("carries inline user idempotency keys into history metadata", () => {
130+
const state = newState([]);
131+
132+
const appended = state.appendInlineMessage({
133+
message: {
134+
role: "user",
135+
content: [{ type: "text", text: "optimistic turn" }],
136+
idempotencyKey: "client-turn-2",
137+
},
138+
messageId: "message-user-2",
139+
messageSeq: 2,
140+
});
141+
142+
expect(appended).toBeDefined();
143+
expect(appended?.messageSeq).toBe(2);
144+
expect(
145+
(
146+
appended!.message as {
147+
__openclaw?: { id?: string; idempotencyKey?: string; seq?: number };
148+
}
149+
)["__openclaw"],
150+
).toMatchObject({
151+
id: "message-user-2",
152+
idempotencyKey: "client-turn-2",
153+
seq: 2,
154+
});
155+
});
156+
129157
test("reuses one canonical array for items and messages", () => {
130158
const snapshot = buildSessionHistorySnapshot({
131159
rawMessages: [assistantTextMessage("first", 1), assistantTextMessage("second", 2)],

src/gateway/session-history-state.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
// raw messages are projected for display, paginated by transcript seq, then
1717
// incrementally updated until cursor/window semantics require a full refresh.
1818
type SessionHistoryTranscriptMeta = {
19+
idempotencyKey?: string;
1920
seq?: number;
2021
};
2122

@@ -56,6 +57,14 @@ type SessionHistoryRawSnapshot = {
5657
transcriptPath?: string;
5758
};
5859

60+
function readMessageIdempotencyKey(message: unknown): string | undefined {
61+
if (!message || typeof message !== "object" || Array.isArray(message)) {
62+
return undefined;
63+
}
64+
const value = (message as Record<string, unknown>).idempotencyKey;
65+
return typeof value === "string" && value.trim() ? value : undefined;
66+
}
67+
5968
/** Computes an oversized raw transcript tail window for projected chat history. */
6069
export function resolveSessionHistoryTailReadOptions(limit: number): {
6170
maxMessages: number;
@@ -258,8 +267,10 @@ export class SessionHistorySseState {
258267
} else {
259268
this.rawTranscriptSeq += 1;
260269
}
270+
const idempotencyKey = readMessageIdempotencyKey(update.message);
261271
const nextMessage = attachOpenClawTranscriptMeta(update.message, {
262272
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
273+
...(idempotencyKey ? { idempotencyKey } : {}),
263274
seq: this.rawTranscriptSeq,
264275
});
265276
// Projection can split, drop, or rewrite raw transcript messages. When one

src/gateway/session-utils.fs.test.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,6 +691,32 @@ describe("readSessionMessages", () => {
691691
});
692692
});
693693

694+
test("surfaces persisted user idempotency keys in __openclaw metadata (#79844)", async () => {
695+
const sessionId = "test-session-idempotency-key";
696+
writeTranscript(tmpDir, sessionId, [
697+
{ type: "session", version: 1, id: sessionId },
698+
{
699+
id: "entry-user-1",
700+
message: {
701+
role: "user",
702+
content: "pending optimistic turn",
703+
idempotencyKey: "client-turn-1",
704+
},
705+
},
706+
]);
707+
708+
const result = await readRecentSessionMessagesAsync(sessionId, storePath, undefined, {
709+
maxMessages: 5,
710+
maxBytes: 2048,
711+
});
712+
713+
expect(result).toHaveLength(1);
714+
expectMessageFields(result[0], {
715+
content: "pending optimistic turn",
716+
openclaw: { id: "entry-user-1", idempotencyKey: "client-turn-1" },
717+
});
718+
});
719+
694720
test("honors byte caps for async recent-message reads", async () => {
695721
const sessionId = "test-session-recent-async-byte-cap";
696722
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
@@ -2651,7 +2677,11 @@ describe("oversized transcript line guards", () => {
26512677
timestamp,
26522678
id: "oversized-child",
26532679
parentId: "root-msg",
2654-
message: { role: "assistant", content: oversizedContent },
2680+
message: {
2681+
role: "assistant",
2682+
content: oversizedContent,
2683+
idempotencyKey: "oversized-key",
2684+
},
26552685
}),
26562686
];
26572687
fs.writeFileSync(transcriptPath, `${lines.join("\n")}\n`, "utf-8");
@@ -2670,6 +2700,7 @@ describe("oversized transcript line guards", () => {
26702700
// id is preserved in __openclaw transcript metadata
26712701
const meta = (oversized as Record<string, Record<string, unknown>>)["__openclaw"];
26722702
expect(meta?.id).toBe("oversized-child");
2703+
expect(meta?.idempotencyKey).toBe("oversized-key");
26732704
expect(meta?.recordTimestampMs).toBe(Date.parse(timestamp));
26742705
// parentId extraction is proven by the record being included:
26752706
// if parentId was not extracted, the tree would orphan this node.

src/gateway/session-utils.fs.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
extractJsonNullableStringFieldPrefix,
3232
extractJsonNumberFieldPrefix,
3333
extractJsonStringFieldPrefix,
34+
normalizeOptionalString,
3435
} from "./session-transcript-json.js";
3536
import type { SessionPreviewItem } from "./session-utils.types.js";
3637

@@ -158,6 +159,14 @@ export function attachOpenClawTranscriptMeta(
158159
};
159160
}
160161

162+
function readTranscriptMessageIdempotencyKey(message: unknown): string | undefined {
163+
if (!message || typeof message !== "object" || Array.isArray(message)) {
164+
return undefined;
165+
}
166+
const value = (message as Record<string, unknown>).idempotencyKey;
167+
return typeof value === "string" && value.trim() ? value : undefined;
168+
}
169+
161170
/** Read all visible transcript messages for a session from the first existing candidate file. */
162171
export function readSessionMessages(
163172
sessionId: string,
@@ -301,12 +310,60 @@ async function readRecentTranscriptTailLinesAsync(
301310

302311
const MAX_TRANSCRIPT_PARSE_LINE_BYTES = 256 * 1024;
303312
const OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS = 64 * 1024;
313+
const OVERSIZED_TRANSCRIPT_METADATA_SUFFIX_CHARS = 64 * 1024;
304314
const TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER = "[chat.history omitted: message too large]";
305315

306316
function isOversizedTranscriptLine(line: string): boolean {
307317
return Buffer.byteLength(line, "utf8") > MAX_TRANSCRIPT_PARSE_LINE_BYTES;
308318
}
309319

320+
function isJsonObjectFieldToken(source: string, tokenIndex: number): boolean {
321+
for (let index = tokenIndex - 1; index >= 0; index--) {
322+
const char = source[index];
323+
if (/\s/.test(char)) {
324+
continue;
325+
}
326+
return char === "{" || char === ",";
327+
}
328+
return true;
329+
}
330+
331+
function extractJsonStringFieldWindow(
332+
source: string,
333+
field: string,
334+
startIndex = 0,
335+
endIndex = source.length,
336+
): string | undefined {
337+
const fieldToken = JSON.stringify(field);
338+
let searchIndex = startIndex;
339+
while (searchIndex < endIndex) {
340+
const tokenIndex = source.indexOf(fieldToken, searchIndex);
341+
if (tokenIndex < 0 || tokenIndex >= endIndex) {
342+
return undefined;
343+
}
344+
searchIndex = tokenIndex + fieldToken.length;
345+
if (!isJsonObjectFieldToken(source, tokenIndex)) {
346+
continue;
347+
}
348+
const match = /^\s*:\s*"((?:\\.|[^"\\])*)"/.exec(source.slice(searchIndex, endIndex));
349+
if (!match) {
350+
continue;
351+
}
352+
try {
353+
const decoded = JSON.parse(`"${match[1]}"`) as unknown;
354+
return normalizeOptionalString(decoded);
355+
} catch {
356+
return undefined;
357+
}
358+
}
359+
return undefined;
360+
}
361+
362+
function extractJsonStringFieldSuffix(source: string, field: string): string | undefined {
363+
const startIndex = Math.max(0, source.length - OVERSIZED_TRANSCRIPT_METADATA_SUFFIX_CHARS);
364+
return extractJsonStringFieldWindow(source, field, startIndex);
365+
}
366+
310367
function buildOversizedTranscriptRecord(line: string): TailTranscriptRecord {
311368
const prefix = line.slice(0, OVERSIZED_TRANSCRIPT_METADATA_PREFIX_CHARS);
312369
const messageMatch = /"message"\s*:/.exec(prefix);
@@ -318,13 +375,17 @@ function buildOversizedTranscriptRecord(line: string): TailTranscriptRecord {
318375
extractJsonStringFieldPrefix(recordPrefix, "timestamp") ??
319376
extractJsonNumberFieldPrefix(recordPrefix, "timestamp");
320377
const role = extractJsonStringFieldPrefix(prefix, "role") ?? "assistant";
378+
const idempotencyKey =
379+
extractJsonStringFieldPrefix(prefix, "idempotencyKey") ??
380+
extractJsonStringFieldSuffix(line, "idempotencyKey");
321381
const record: Record<string, unknown> = {
322382
...(type ? { type } : {}),
323383
...(id ? { id } : {}),
324384
...(parentId !== undefined ? { parentId } : {}),
325385
...(timestamp !== undefined ? { timestamp } : {}),
326386
message: {
327387
role,
388+
...(idempotencyKey ? { idempotencyKey } : {}),
328389
content: [{ type: "text", text: TRANSCRIPT_OVERSIZED_MESSAGE_PLACEHOLDER }],
329390
__openclaw: { truncated: true, reason: "oversized" },
330391
},
@@ -838,8 +899,10 @@ function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
838899
: typeof entry.timestamp === "number"
839900
? entry.timestamp
840901
: Number.NaN;
902+
const idempotencyKey = readTranscriptMessageIdempotencyKey(entry.message);
841903
return attachOpenClawTranscriptMeta(entry.message, {
842904
...(typeof entry.id === "string" ? { id: entry.id } : {}),
905+
...(idempotencyKey ? { idempotencyKey } : {}),
843906
...(Number.isFinite(recordTimestampMs) ? { recordTimestampMs } : {}),
844907
seq,
845908
});

0 commit comments

Comments
 (0)