Skip to content

Commit 09ec74e

Browse files
test(chat.inject): extend replay and idempotency coverage
1 parent 7612ef3 commit 09ec74e

3 files changed

Lines changed: 279 additions & 13 deletions

File tree

src/gateway/server-methods/chat.inject.parentid.test.ts

Lines changed: 271 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import fs from "node:fs";
22
import { describe, expect, it } from "vitest";
33
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
4-
import { appendInjectedAssistantMessageToTranscript } from "./chat-transcript-inject.js";
4+
import {
5+
appendInjectedAssistantMessageToTranscript,
6+
appendInjectedUserMessageToTranscript,
7+
} from "./chat-transcript-inject.js";
58
import { createTranscriptFixtureSync } from "./chat.test-helpers.js";
69

710
function readTranscriptLines(transcriptPath: string): string[] {
@@ -125,4 +128,271 @@ describe("gateway chat.inject transcript writes", () => {
125128
fs.rmSync(dir, { recursive: true, force: true });
126129
}
127130
});
131+
132+
it("appends an injected user message", async () => {
133+
const { dir, transcriptPath } = createTranscriptFixtureSync({
134+
prefix: "openclaw-chat-inject-user-",
135+
sessionId: "sess-user",
136+
});
137+
138+
try {
139+
const appended = await appendInjectedUserMessageToTranscript({
140+
transcriptPath,
141+
message: "hello from user",
142+
});
143+
144+
expect(appended.ok).toBe(true);
145+
expect(appended.messageId).toBeTypeOf("string");
146+
147+
const lines = readTranscriptLines(transcriptPath);
148+
const last = JSON.parse(lines.at(-1) as string) as {
149+
message?: { role?: string; content?: Array<{ type?: string; text?: string }> };
150+
};
151+
expect(last.message?.role).toBe("user");
152+
expect(last.message?.content?.[0]?.type).toBe("text");
153+
expect(last.message?.content?.[0]?.text).toBe("hello from user");
154+
} finally {
155+
fs.rmSync(dir, { recursive: true, force: true });
156+
}
157+
});
158+
159+
it("dedupes concurrent identical idempotent injects (race-safe)", async () => {
160+
const { dir, transcriptPath } = createTranscriptFixtureSync({
161+
prefix: "openclaw-chat-inject-race-",
162+
sessionId: "sess-race",
163+
});
164+
165+
try {
166+
const idempotencyKey = "race-key-001";
167+
const concurrency = 16;
168+
const results = await Promise.all(
169+
Array.from({ length: concurrency }, () =>
170+
appendInjectedUserMessageToTranscript({
171+
transcriptPath,
172+
message: "concurrent hello",
173+
idempotencyKey,
174+
}),
175+
),
176+
);
177+
178+
// Every call must succeed and return the same canonical messageId.
179+
const messageIds = new Set<string>();
180+
let dedupedCount = 0;
181+
for (const r of results) {
182+
expect(r.ok).toBe(true);
183+
expect(r.messageId).toBeTypeOf("string");
184+
messageIds.add(r.messageId as string);
185+
if (r.deduped) {
186+
dedupedCount += 1;
187+
}
188+
}
189+
expect(messageIds.size).toBe(1);
190+
// Exactly one call writes; the rest must be deduped.
191+
expect(dedupedCount).toBe(concurrency - 1);
192+
193+
// Disk state must reflect a single persisted entry for the key.
194+
const lines = readTranscriptLines(transcriptPath);
195+
const matches = lines.filter((line) => {
196+
try {
197+
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
198+
return parsed?.message?.idempotencyKey === idempotencyKey;
199+
} catch {
200+
return false;
201+
}
202+
});
203+
expect(matches).toHaveLength(1);
204+
} finally {
205+
fs.rmSync(dir, { recursive: true, force: true });
206+
}
207+
});
208+
209+
it("returns the original persisted payload on idempotent replay", async () => {
210+
const { dir, transcriptPath } = createTranscriptFixtureSync({
211+
prefix: "openclaw-chat-inject-replay-",
212+
sessionId: "sess-replay",
213+
});
214+
215+
try {
216+
const idempotencyKey = "replay-key-001";
217+
const first = await appendInjectedUserMessageToTranscript({
218+
transcriptPath,
219+
message: "first payload wins",
220+
idempotencyKey,
221+
});
222+
const replay = await appendInjectedUserMessageToTranscript({
223+
transcriptPath,
224+
message: "second payload should be ignored",
225+
idempotencyKey,
226+
});
227+
228+
expect(first.ok).toBe(true);
229+
expect(replay.ok).toBe(true);
230+
expect(replay.deduped).toBe(true);
231+
expect(replay.messageId).toBe(first.messageId);
232+
expect(
233+
(replay.message as { content?: Array<{ text?: string }> } | undefined)?.content?.[0]?.text,
234+
).toBe("first payload wins");
235+
236+
const lines = readTranscriptLines(transcriptPath);
237+
const messages: Array<{
238+
type?: string;
239+
message?: { content?: Array<{ text?: string }> };
240+
}> = lines
241+
.map(
242+
(line) =>
243+
JSON.parse(line) as { type?: string; message?: { content?: Array<{ text?: string }> } },
244+
)
245+
.filter((entry) => entry.type === "message");
246+
expect(messages).toHaveLength(1);
247+
expect(messages[0]?.message?.content?.[0]?.text).toBe("first payload wins");
248+
} finally {
249+
fs.rmSync(dir, { recursive: true, force: true });
250+
}
251+
});
252+
253+
it("does not deduplicate when no idempotencyKey is provided", async () => {
254+
const { dir, transcriptPath } = createTranscriptFixtureSync({
255+
prefix: "openclaw-chat-inject-nokey-",
256+
sessionId: "sess-nokey",
257+
});
258+
259+
try {
260+
const first = await appendInjectedUserMessageToTranscript({
261+
transcriptPath,
262+
message: "hello no key",
263+
});
264+
const second = await appendInjectedUserMessageToTranscript({
265+
transcriptPath,
266+
message: "hello no key",
267+
});
268+
269+
expect(first.ok).toBe(true);
270+
expect(second.ok).toBe(true);
271+
// Without an idempotency key, both writes must land on disk.
272+
expect(first.deduped).toBeFalsy();
273+
expect(second.deduped).toBeFalsy();
274+
expect(first.messageId).not.toBe(second.messageId);
275+
276+
const lines = readTranscriptLines(transcriptPath);
277+
const messages = lines
278+
.map((line) => JSON.parse(line) as { type?: string })
279+
.filter((e) => e.type === "message");
280+
expect(messages).toHaveLength(2);
281+
} finally {
282+
fs.rmSync(dir, { recursive: true, force: true });
283+
}
284+
});
285+
286+
it("does not emit a transcript update event on idempotent replay", async () => {
287+
const { dir, transcriptPath } = createTranscriptFixtureSync({
288+
prefix: "openclaw-chat-inject-noevent-",
289+
sessionId: "sess-noevent",
290+
});
291+
const updates: unknown[] = [];
292+
const unsubscribe = onSessionTranscriptUpdate((u) => updates.push(u));
293+
294+
try {
295+
const idempotencyKey = "noevent-key-001";
296+
await appendInjectedUserMessageToTranscript({
297+
transcriptPath,
298+
message: "original",
299+
idempotencyKey,
300+
});
301+
const eventCountAfterFirst = updates.length;
302+
303+
await appendInjectedUserMessageToTranscript({
304+
transcriptPath,
305+
message: "replay - should not emit",
306+
idempotencyKey,
307+
});
308+
309+
// First write must have emitted exactly one event.
310+
expect(eventCountAfterFirst).toBe(1);
311+
// Replay must not emit any additional events.
312+
expect(updates).toHaveLength(1);
313+
} finally {
314+
unsubscribe();
315+
fs.rmSync(dir, { recursive: true, force: true });
316+
}
317+
});
318+
319+
it("deduplicates assistant-role injects by idempotency key", async () => {
320+
const { dir, transcriptPath } = createTranscriptFixtureSync({
321+
prefix: "openclaw-chat-inject-asst-dedup-",
322+
sessionId: "sess-asst-dedup",
323+
});
324+
325+
try {
326+
const idempotencyKey = "asst-dedup-key-001";
327+
const first = await appendInjectedAssistantMessageToTranscript({
328+
transcriptPath,
329+
message: "assistant first payload",
330+
idempotencyKey,
331+
});
332+
const replay = await appendInjectedAssistantMessageToTranscript({
333+
transcriptPath,
334+
message: "assistant second payload - must be ignored",
335+
idempotencyKey,
336+
});
337+
338+
expect(first.ok).toBe(true);
339+
expect(first.deduped).toBeFalsy();
340+
expect(replay.ok).toBe(true);
341+
expect(replay.deduped).toBe(true);
342+
expect(replay.messageId).toBe(first.messageId);
343+
344+
// Only one entry on disk.
345+
const lines = readTranscriptLines(transcriptPath);
346+
const messages = lines
347+
.map((line) => JSON.parse(line) as { type?: string })
348+
.filter((e) => e.type === "message");
349+
expect(messages).toHaveLength(1);
350+
} finally {
351+
fs.rmSync(dir, { recursive: true, force: true });
352+
}
353+
});
354+
355+
it("distinct idempotency keys produce distinct entries", async () => {
356+
const { dir, transcriptPath } = createTranscriptFixtureSync({
357+
prefix: "openclaw-chat-inject-multikey-",
358+
sessionId: "sess-multikey",
359+
});
360+
361+
try {
362+
const r1 = await appendInjectedUserMessageToTranscript({
363+
transcriptPath,
364+
message: "message for key A",
365+
idempotencyKey: "key-A",
366+
});
367+
const r2 = await appendInjectedUserMessageToTranscript({
368+
transcriptPath,
369+
message: "message for key B",
370+
idempotencyKey: "key-B",
371+
});
372+
// Replay key A - must not collide with key B.
373+
const r1replay = await appendInjectedUserMessageToTranscript({
374+
transcriptPath,
375+
message: "key A replay",
376+
idempotencyKey: "key-A",
377+
});
378+
379+
expect(r1.ok).toBe(true);
380+
expect(r2.ok).toBe(true);
381+
expect(r1.deduped).toBeFalsy();
382+
expect(r2.deduped).toBeFalsy();
383+
expect(r1.messageId).not.toBe(r2.messageId);
384+
385+
expect(r1replay.deduped).toBe(true);
386+
expect(r1replay.messageId).toBe(r1.messageId);
387+
388+
// Two distinct entries on disk.
389+
const lines = readTranscriptLines(transcriptPath);
390+
const messages = lines
391+
.map((line) => JSON.parse(line) as { type?: string })
392+
.filter((e) => e.type === "message");
393+
expect(messages).toHaveLength(2);
394+
} finally {
395+
fs.rmSync(dir, { recursive: true, force: true });
396+
}
397+
});
128398
});

src/gateway/server-methods/chat.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,9 @@ import {
121121
appendInjectedAssistantMessageToTranscript,
122122
appendInjectedUserMessageToTranscript,
123123
} from "./chat-transcript-inject.js";
124+
import type { GatewayInjectedTranscriptAppendResult } from "./chat-transcript-inject.js";
125+
126+
type TranscriptAppendResult = GatewayInjectedTranscriptAppendResult;
124127
import {
125128
buildWebchatAssistantMessageFromReplyPayloads,
126129
buildWebchatAudioContentBlocksFromReplyPayloads,
@@ -131,15 +134,6 @@ import type {
131134
GatewayRequestHandlers,
132135
} from "./types.js";
133136

134-
type TranscriptAppendResult = {
135-
ok: boolean;
136-
messageId?: string;
137-
message?: Record<string, unknown>;
138-
/** True when an existing entry with matching idempotencyKey was returned. */
139-
deduped?: boolean;
140-
error?: string;
141-
};
142-
143137
type AbortOrigin = "rpc" | "stop-command";
144138

145139
type AbortedPartialSnapshot = {
@@ -1520,6 +1514,8 @@ function createChatAbortOps(context: GatewayRequestContext): ChatAbortOps {
15201514
chatDeltaSentAt: context.chatDeltaSentAt,
15211515
chatDeltaLastBroadcastLen: context.chatDeltaLastBroadcastLen,
15221516
chatDeltaLastBroadcastText: context.chatDeltaLastBroadcastText,
1517+
agentDeltaSentAt: context.agentDeltaSentAt,
1518+
bufferedAgentEvents: context.bufferedAgentEvents,
15231519
chatAbortedRuns: context.chatAbortedRuns,
15241520
removeChatRun: context.removeChatRun,
15251521
agentRunSeq: context.agentRunSeq,

src/plugins/registry.runtime-config.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,9 @@ describe("plugin registry runtime config scope", () => {
5858
loadConfig,
5959
writeConfigFile,
6060
} satisfies PluginRuntime["config"];
61-
const runtime = createPluginRuntime();
62-
runtime.config = configRuntime;
63-
const pluginRegistry = createTestRegistry(runtime);
61+
const pluginRegistry = createTestRegistry({
62+
config: configRuntime,
63+
} as unknown as PluginRuntime);
6464
const record = createPluginRecord({
6565
id: "legacy-plugin",
6666
name: "Legacy Plugin",

0 commit comments

Comments
 (0)