Skip to content

Commit d0de65b

Browse files
committed
fix(gateway): expose idempotencyKey on chat.history __openclaw envelope (#79844)
When a user message is sent with an idempotencyKey, that key is persisted on the transcript message but was only available as a top-level message field on hydration. Clients consuming chat.history (and the SSE inline message stream) expect identity-style metadata to live inside the __openclaw envelope alongside seq, id, importedFrom, etc. Hoist the persisted idempotencyKey into __openclaw at hydration time: - parsedSessionEntryToMessage (chat.history reads + recent-tail reads) - SessionHistorySseState.appendInlineMessage (live SSE inline append) - broadcastSessionMessage in server-session-events (inline broadcast path) The original top-level field is preserved for back-compat, so no existing reader breaks. Tests: - session-utils.fs.test.ts: idempotencyKey surfaces on full + recent reads - session-history-state.test.ts: idempotencyKey surfaces on inline append
1 parent 48f51c1 commit d0de65b

5 files changed

Lines changed: 84 additions & 0 deletions

File tree

src/gateway/server-session-events.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,9 +126,16 @@ async function handleTranscriptUpdateBroadcast(
126126
sessionRow: loadGatewaySessionRow(sessionKey, { transcriptUsageMaxBytes: 64 * 1024 }),
127127
includeSession: true,
128128
});
129+
const rawIdempotencyKey =
130+
update.message && typeof update.message === "object" && !Array.isArray(update.message)
131+
? (update.message as Record<string, unknown>).idempotencyKey
132+
: undefined;
129133
const rawMessage = attachOpenClawTranscriptMeta(update.message, {
130134
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
131135
...(typeof messageSeq === "number" ? { seq: messageSeq } : {}),
136+
...(typeof rawIdempotencyKey === "string" && rawIdempotencyKey.length > 0
137+
? { idempotencyKey: rawIdempotencyKey }
138+
: {}),
132139
});
133140
const message = projectChatDisplayMessage(rawMessage);
134141
if (message) {

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,4 +329,23 @@ describe("SessionHistorySseState", () => {
329329
).toBeNull();
330330
expect(state.snapshot().messages).toHaveLength(1);
331331
});
332+
333+
test("surfaces idempotencyKey from the inline message in the __openclaw envelope", () => {
334+
const state = SessionHistorySseState.fromRawSnapshot({
335+
target: { sessionId: "sess-idempotency" },
336+
rawMessages: [],
337+
});
338+
const appended = state.appendInlineMessage({
339+
message: {
340+
role: "user",
341+
content: [{ type: "text", text: "hi from client" }],
342+
idempotencyKey: "client-key-42",
343+
},
344+
messageId: "msg-42",
345+
});
346+
expect(appended).not.toBeNull();
347+
const sent = appended?.message as { __openclaw?: { idempotencyKey?: unknown; id?: unknown } };
348+
expect(sent.__openclaw?.idempotencyKey).toBe("client-key-42");
349+
expect(sent.__openclaw?.id).toBe("msg-42");
350+
});
332351
});

src/gateway/session-history-state.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,9 +229,16 @@ export class SessionHistorySseState {
229229
return null;
230230
}
231231
this.rawTranscriptSeq += 1;
232+
const idempotencyKey =
233+
update.message && typeof update.message === "object" && !Array.isArray(update.message)
234+
? (update.message as Record<string, unknown>).idempotencyKey
235+
: undefined;
232236
const nextMessage = attachOpenClawTranscriptMeta(update.message, {
233237
...(typeof update.messageId === "string" ? { id: update.messageId } : {}),
234238
seq: this.rawTranscriptSeq,
239+
...(typeof idempotencyKey === "string" && idempotencyKey.length > 0
240+
? { idempotencyKey }
241+
: {}),
235242
});
236243
const [sanitizedMessage] = toSessionHistoryMessages(
237244
projectChatDisplayMessages([nextMessage], { maxChars: this.maxChars }),

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

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,47 @@ describe("readSessionMessages", () => {
612612
]);
613613
});
614614

615+
test("surfaces idempotencyKey in the __openclaw envelope when persisted on the message", () => {
616+
const sessionId = "test-session-idempotency-key";
617+
writeTranscript(tmpDir, sessionId, [
618+
{ type: "session", version: 1, id: sessionId },
619+
{
620+
message: {
621+
role: "user",
622+
content: "hello",
623+
idempotencyKey: "client-key-abc",
624+
},
625+
},
626+
{
627+
message: {
628+
role: "assistant",
629+
content: "hi",
630+
idempotencyKey: "server-key-xyz",
631+
},
632+
},
633+
{ message: { role: "user", content: "no key here" } },
634+
]);
635+
636+
const out = readSessionMessages(sessionId, storePath) as Array<{
637+
role: string;
638+
__openclaw?: { idempotencyKey?: unknown; seq?: unknown };
639+
}>;
640+
expect(out).toHaveLength(3);
641+
expect(out[0]?.__openclaw?.idempotencyKey).toBe("client-key-abc");
642+
expect(out[1]?.__openclaw?.idempotencyKey).toBe("server-key-xyz");
643+
expect(out[2]?.__openclaw?.idempotencyKey).toBeUndefined();
644+
// The original top-level field is preserved for back-compat readers.
645+
expect((out[0] as { idempotencyKey?: unknown }).idempotencyKey).toBe("client-key-abc");
646+
647+
const recent = readRecentSessionMessages(sessionId, storePath, undefined, {
648+
maxMessages: 2,
649+
maxBytes: 4096,
650+
}) as Array<{ __openclaw?: { idempotencyKey?: unknown } }>;
651+
expect(recent).toHaveLength(2);
652+
expect(recent[0]?.__openclaw?.idempotencyKey).toBe("server-key-xyz");
653+
expect(recent[1]?.__openclaw?.idempotencyKey).toBeUndefined();
654+
});
655+
615656
test("bounds recent-message reads for large append-only transcripts", () => {
616657
const sessionId = "test-session-recent-large";
617658
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);

src/gateway/session-utils.fs.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -711,15 +711,25 @@ export function readRecentSessionTranscriptLines(params: {
711711
return { lines, totalLines };
712712
}
713713

714+
function extractIdempotencyKey(message: unknown): string | undefined {
715+
if (!message || typeof message !== "object" || Array.isArray(message)) {
716+
return undefined;
717+
}
718+
const value = (message as Record<string, unknown>).idempotencyKey;
719+
return typeof value === "string" && value.length > 0 ? value : undefined;
720+
}
721+
714722
function parsedSessionEntryToMessage(parsed: unknown, seq: number): unknown {
715723
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
716724
return null;
717725
}
718726
const entry = parsed as Record<string, unknown>;
719727
if (entry.message) {
728+
const idempotencyKey = extractIdempotencyKey(entry.message);
720729
return attachOpenClawTranscriptMeta(entry.message, {
721730
...(typeof entry.id === "string" ? { id: entry.id } : {}),
722731
seq,
732+
...(idempotencyKey ? { idempotencyKey } : {}),
723733
});
724734
}
725735

0 commit comments

Comments
 (0)