From 44296fcd2b28be97326b2abd31739b2caad418c3 Mon Sep 17 00:00:00 2001 From: "clawsweeper[bot]" <274271284+clawsweeper[bot]@users.noreply.github.com> Date: Wed, 29 Apr 2026 22:28:05 -0700 Subject: [PATCH] fix(sdk): emit replacement chat projection deltas Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com> --- packages/sdk/src/client.ts | 24 ++++++++++++++++++--- packages/sdk/src/index.test.ts | 38 +++++++++++++++++++++++++++------- 2 files changed, 52 insertions(+), 10 deletions(-) diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 65b13617b37..9b1dc66a2ab 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -219,15 +219,23 @@ function isTerminalRunEvent(event: OpenClawEvent): boolean { function normalizeChatProjectionEvent( event: OpenClawEvent, projection: ChatProjection, + previousText: string | undefined, ): OpenClawEvent { const text = readChatProjectionText(projection.payload); + const isReplacement = Boolean( + previousText && text !== undefined && !text.startsWith(previousText), + ); return { ...event, type: projection.state === "delta" ? "assistant.delta" : "run.completed", data: projection.state === "delta" ? text !== undefined - ? { delta: text } + ? { + text, + delta: isReplacement ? text : text.slice(previousText?.length ?? 0), + ...(isReplacement ? { replace: true } : {}), + } : event.data : { phase: "end", ...(text !== undefined ? { outputText: text } : {}) }, }; @@ -335,20 +343,30 @@ export class OpenClaw { const replayEvents = this.replaySnapshot(runId); let hasCanonicalAssistantRunEvent = replayEvents.some(isAssistantRunEvent); let hasTerminalRunEvent = replayEvents.some(isTerminalRunEvent); + let previousChatProjectionText: string | undefined; const toRunStreamEvent = (event: OpenClawEvent): OpenClawEvent | undefined => { const chatProjection = readChatProjection(event); if (chatProjection?.state === "delta") { if (hasCanonicalAssistantRunEvent) { return undefined; } - return normalizeChatProjectionEvent(event, chatProjection); + const runEvent = normalizeChatProjectionEvent( + event, + chatProjection, + previousChatProjectionText, + ); + const text = readChatProjectionText(chatProjection.payload); + if (text !== undefined) { + previousChatProjectionText = text; + } + return runEvent; } if (chatProjection?.state === "final") { if (hasTerminalRunEvent) { return undefined; } hasTerminalRunEvent = true; - return normalizeChatProjectionEvent(event, chatProjection); + return normalizeChatProjectionEvent(event, chatProjection, previousChatProjectionText); } if (isAssistantRunEvent(event)) { hasCanonicalAssistantRunEvent = true; diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 17509b5ecdc..e27c1175bcb 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -499,20 +499,34 @@ describe("OpenClaw SDK", () => { payload: { runId: "run_chat_only", sessionKey: "chat-only", - state: "final", + state: "delta", message: { role: "assistant", - content: [{ type: "text", text: "hello again" }], + content: [{ type: "text", text: "reset" }], timestamp: ts + 2, }, }, }); fake.emit({ - event: "custom.debug", + event: "chat", seq: 4, payload: { runId: "run_chat_only", - ts: ts + 3, + sessionKey: "chat-only", + state: "final", + message: { + role: "assistant", + content: [{ type: "text", text: "reset" }], + timestamp: ts + 3, + }, + }, + }); + fake.emit({ + event: "custom.debug", + seq: 5, + payload: { + runId: "run_chat_only", + ts: ts + 4, data: { ok: true }, }, }); @@ -534,7 +548,7 @@ describe("OpenClaw SDK", () => { done: false, value: { type: "assistant.delta", - data: { delta: "hello" }, + data: { text: "hello", delta: "hello" }, raw: { event: "chat" }, }, }); @@ -544,17 +558,27 @@ describe("OpenClaw SDK", () => { done: false, value: { type: "assistant.delta", - data: { delta: "hello again" }, + data: { text: "hello again", delta: " again" }, raw: { event: "chat" }, }, }); const third = await iterator.next(); expect(third).toMatchObject({ + done: false, + value: { + type: "assistant.delta", + data: { text: "reset", delta: "reset", replace: true }, + raw: { event: "chat" }, + }, + }); + + const fourth = await iterator.next(); + expect(fourth).toMatchObject({ done: false, value: { type: "run.completed", - data: { phase: "end", outputText: "hello again" }, + data: { phase: "end", outputText: "reset" }, raw: { event: "chat" }, }, });