























@@ -227,6 +227,117 @@ describe("chat abort transcript persistence", () => {
227227});
228228});
229229230+it("does not let non-assistant idempotency collisions suppress abort partial persistence", async () => {
231+const { transcriptPath, sessionId } = await createTranscriptFixture(
232+"openclaw-chat-abort-idempotency-collision-",
233+);
234+const runId = "idem-abort-collision";
235+const idempotencyKey = `${runId}:assistant`;
236+await fs.appendFile(
237+transcriptPath,
238+`${JSON.stringify({
239+ type: "message",
240+ id: "user-message-with-colliding-key",
241+ parentId: null,
242+ timestamp: new Date(0).toISOString(),
243+ message: {
244+ role: "user",
245+ content: "colliding user key",
246+ timestamp: 1,
247+ idempotencyKey,
248+ },
249+ })}\n`,
250+"utf-8",
251+);
252+253+const respond = vi.fn();
254+const context = createChatAbortContext({
255+chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]),
256+chatRunBuffers: new Map([[runId, "Partial after collision"]]),
257+chatDeltaSentAt: new Map([[runId, Date.now()]]),
258+logGateway: { warn: vi.fn() },
259+});
260+261+await invokeChatAbortHandler({
262+handler: chatHandlers["chat.abort"],
263+ context,
264+request: { sessionKey: "main", runId },
265+ respond,
266+});
267+268+const lines = await readTranscriptLines(transcriptPath);
269+const assistantMessages = collectMessagesWithIdempotencyKey(lines, idempotencyKey).filter(
270+(message) => message.role === "assistant",
271+);
272+273+expect(assistantMessages).toHaveLength(1);
274+expectPersistedAbortMessage(assistantMessages[0], {
275+ idempotencyKey,
276+origin: "rpc",
277+ runId,
278+stopReason: "stop",
279+});
280+});
281+282+it("dedupes legacy assistant transcript entries without top-level ids", async () => {
283+const { transcriptPath, sessionId } = await createTranscriptFixture(
284+"openclaw-chat-abort-legacy-idempotency-",
285+);
286+const runId = "idem-abort-legacy";
287+const idempotencyKey = `${runId}:assistant`;
288+await fs.appendFile(
289+transcriptPath,
290+`${JSON.stringify({
291+ type: "message",
292+ timestamp: new Date(0).toISOString(),
293+ message: {
294+ role: "assistant",
295+ content: [{ type: "text", text: "legacy partial" }],
296+ timestamp: 1,
297+ stopReason: "stop",
298+ api: "openai-responses",
299+ provider: "openclaw",
300+ model: "gateway-injected",
301+ idempotencyKey,
302+ openclawAbort: {
303+ aborted: true,
304+ origin: "rpc",
305+ runId,
306+ },
307+ },
308+ })}\n`,
309+"utf-8",
310+);
311+312+const respond = vi.fn();
313+const context = createChatAbortContext({
314+chatAbortControllers: new Map([[runId, createActiveRun("main", { sessionId })]]),
315+chatRunBuffers: new Map([[runId, "Duplicate partial"]]),
316+chatDeltaSentAt: new Map([[runId, Date.now()]]),
317+logGateway: { warn: vi.fn() },
318+});
319+320+await invokeChatAbortHandler({
321+handler: chatHandlers["chat.abort"],
322+ context,
323+request: { sessionKey: "main", runId },
324+ respond,
325+});
326+327+const lines = await readTranscriptLines(transcriptPath);
328+const assistantMessages = collectMessagesWithIdempotencyKey(lines, idempotencyKey).filter(
329+(message) => message.role === "assistant",
330+);
331+332+expect(assistantMessages).toHaveLength(1);
333+expectPersistedAbortMessage(assistantMessages[0], {
334+ idempotencyKey,
335+origin: "rpc",
336+ runId,
337+stopReason: "stop",
338+});
339+});
340+230341it("persists session-scoped abort partials with rpc metadata", async () => {
231342const { transcriptPath, sessionId } = await createTranscriptFixture(
232343"openclaw-chat-abort-session-",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。