fix(sdk): emit replacement chat projection deltas

Co-authored-by: openclaw-clawsweeper[bot] <280122609+openclaw-clawsweeper[bot]@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-04-29 22:28:05 -07:00
committed by GitHub
parent b876ecdb84
commit 44296fcd2b
2 changed files with 52 additions and 10 deletions

View File

@@ -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;

View File

@@ -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" },
},
});