Skip to content

Commit 9569e07

Browse files
committed
fix(agents): advance lightweight text deltas
1 parent 4ff4989 commit 9569e07

3 files changed

Lines changed: 77 additions & 19 deletions

File tree

packages/agent-core/src/agent-loop.test.ts

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -76,12 +76,12 @@ describe("agentLoop EventStream failures", () => {
7676
});
7777

7878
describe("agentLoop streaming updates", () => {
79-
it("reuses the current assistant message for text deltas without partial snapshots", async () => {
79+
it("rebuilds assistant message snapshots for text deltas without partial snapshots", async () => {
8080
const streamFn: StreamFn = async () => {
8181
const stream = createAssistantMessageEventStream();
82-
const message: AssistantMessage = {
82+
const startMessage: AssistantMessage = {
8383
role: "assistant",
84-
content: [{ type: "text", text: "" }],
84+
content: [],
8585
api: model.api,
8686
provider: model.provider,
8787
model: model.id,
@@ -96,17 +96,24 @@ describe("agentLoop streaming updates", () => {
9696
stopReason: "stop",
9797
timestamp: 1,
9898
};
99+
const textStartMessage: AssistantMessage = { ...startMessage, content: [] };
100+
const finalMessage: AssistantMessage = {
101+
...startMessage,
102+
content: [{ type: "text", text: "Hello world" }],
103+
};
99104

100105
queueMicrotask(() => {
101-
stream.push({ type: "start", partial: message });
102-
stream.push({ type: "text_start", contentIndex: 0, partial: message });
103-
const textBlock = message.content[0];
104-
if (textBlock?.type === "text") {
105-
textBlock.text = "Hello";
106-
}
106+
stream.push({ type: "start", partial: startMessage });
107+
stream.push({ type: "text_start", contentIndex: 0, partial: textStartMessage });
107108
stream.push({ type: "text_delta", contentIndex: 0, delta: "Hello" });
108-
stream.push({ type: "text_end", contentIndex: 0, content: "Hello", partial: message });
109-
stream.push({ type: "done", reason: "stop", message });
109+
stream.push({ type: "text_delta", contentIndex: 0, delta: " world" });
110+
stream.push({
111+
type: "text_end",
112+
contentIndex: 0,
113+
content: "Hello world",
114+
partial: finalMessage,
115+
});
116+
stream.push({ type: "done", reason: "stop", message: finalMessage });
110117
});
111118

112119
return stream;
@@ -121,15 +128,17 @@ describe("agentLoop streaming updates", () => {
121128
);
122129
const events = await collectEvents(stream);
123130

124-
const deltaUpdate = events.find(
131+
const deltaUpdates = events.filter(
125132
(event): event is Extract<AgentEvent, { type: "message_update" }> =>
126133
event.type === "message_update" && event.assistantMessageEvent.type === "text_delta",
127134
);
128-
expect(deltaUpdate).toMatchObject({
129-
type: "message_update",
130-
message: { role: "assistant", content: [{ type: "text", text: "Hello" }] },
131-
assistantMessageEvent: { type: "text_delta", delta: "Hello" },
132-
});
133-
expect(deltaUpdate?.assistantMessageEvent).not.toHaveProperty("partial");
135+
expect(deltaUpdates).toHaveLength(2);
136+
expect(deltaUpdates.map((event) => event.message)).toMatchObject([
137+
{ role: "assistant", content: [{ type: "text", text: "Hello" }] },
138+
{ role: "assistant", content: [{ type: "text", text: "Hello world" }] },
139+
]);
140+
for (const update of deltaUpdates) {
141+
expect(update.assistantMessageEvent).not.toHaveProperty("partial");
142+
}
134143
});
135144
});

packages/agent-core/src/agent-loop.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { EventStream as LlmEventStream } from "@openclaw/llm-core";
44
import type {
55
AssistantMessage,
6+
AssistantMessageEvent,
67
Context,
78
EventStream,
89
ToolResultMessage,
@@ -35,6 +36,49 @@ const EMPTY_USAGE = {
3536

3637
const EventStreamConstructor: typeof SourceEventStream = LlmEventStream;
3738

39+
type AssistantMessageUpdateEvent = Extract<
40+
AssistantMessageEvent,
41+
{
42+
type:
43+
| "text_start"
44+
| "text_delta"
45+
| "text_end"
46+
| "thinking_start"
47+
| "thinking_delta"
48+
| "thinking_end"
49+
| "toolcall_start"
50+
| "toolcall_delta"
51+
| "toolcall_end";
52+
}
53+
>;
54+
55+
function appendTextDeltaToAssistantMessage(
56+
message: AssistantMessage,
57+
contentIndex: number,
58+
delta: string,
59+
): AssistantMessage {
60+
const content = [...message.content];
61+
const currentContent = content[contentIndex];
62+
content[contentIndex] =
63+
currentContent?.type === "text"
64+
? { ...currentContent, text: currentContent.text + delta }
65+
: { type: "text", text: delta };
66+
return { ...message, content };
67+
}
68+
69+
function resolveAssistantMessageUpdate(
70+
event: AssistantMessageUpdateEvent,
71+
currentMessage: AssistantMessage,
72+
): AssistantMessage {
73+
if ("partial" in event && event.partial) {
74+
return event.partial;
75+
}
76+
if (event.type === "text_delta") {
77+
return appendTextDeltaToAssistantMessage(currentMessage, event.contentIndex, event.delta);
78+
}
79+
return currentMessage;
80+
}
81+
3882
/**
3983
* Start an agent loop with a new prompt message.
4084
* The prompt is added to the context and events are emitted for it.
@@ -402,7 +446,7 @@ async function streamAssistantResponse(
402446
case "toolcall_delta":
403447
case "toolcall_end":
404448
if (partialMessage) {
405-
const message: AssistantMessage = event.partial ?? partialMessage;
449+
const message = resolveAssistantMessageUpdate(event, partialMessage);
406450
partialMessage = message;
407451
context.messages[context.messages.length - 1] = message;
408452
await emit({

packages/llm-core/src/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -366,6 +366,11 @@ export interface Context {
366366
export type AssistantMessageEvent =
367367
| { type: "start"; partial: AssistantMessage }
368368
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
369+
/**
370+
* Plain text deltas may omit `partial` to avoid retaining one full assistant
371+
* snapshot per token. Consumers that need current text should replay `delta`
372+
* from the latest start/end partial checkpoint.
373+
*/
369374
| { type: "text_delta"; contentIndex: number; delta: string; partial?: AssistantMessage }
370375
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
371376
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }

0 commit comments

Comments
 (0)