fix(webui): merge length recovery stream segments
This commit is contained in:
@@ -26,7 +26,7 @@ import type {
|
||||
} from "@/lib/types";
|
||||
|
||||
interface StreamBuffer {
|
||||
/** ID of the assistant message currently receiving deltas (cleared on ``stream_end``). */
|
||||
/** ID of the assistant message currently receiving deltas (cleared when its segment closes). */
|
||||
messageId: string;
|
||||
}
|
||||
|
||||
@@ -780,15 +780,20 @@ export function useNanobotStream(
|
||||
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
|
||||
if (targetIndex !== null) {
|
||||
const target = next[targetIndex];
|
||||
next = replaceMessageAt(next, targetIndex, {
|
||||
const merged = {
|
||||
...target,
|
||||
content: finalAnswerText,
|
||||
isStreaming: true,
|
||||
...turn,
|
||||
});
|
||||
};
|
||||
next = replaceMessageAt(next, targetIndex, merged);
|
||||
if (!options?.closeAnswerSegment) {
|
||||
closedAssistantStreamIdsRef.current.delete(merged.id);
|
||||
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
||||
buffer.current = { messageId: merged.id };
|
||||
}
|
||||
} else {
|
||||
const id = crypto.randomUUID();
|
||||
closedAssistantStreamIdsRef.current.add(id);
|
||||
next = [
|
||||
...next,
|
||||
{
|
||||
@@ -800,6 +805,12 @@ export function useNanobotStream(
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
if (options?.closeAnswerSegment) {
|
||||
closedAssistantStreamIdsRef.current.add(id);
|
||||
} else {
|
||||
activeAssistantRef.current = { id, index: next.length - 1 };
|
||||
buffer.current = { messageId: id };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||
@@ -911,8 +922,9 @@ export function useNanobotStream(
|
||||
|
||||
if (ev.event === "stream_end") {
|
||||
const turn = turnFieldsFromEvent(ev, "answer");
|
||||
const mergeNext = ev.resuming === true && ev.merge_next === true;
|
||||
flushPendingStreamEvents({
|
||||
closeAnswerSegment: true,
|
||||
closeAnswerSegment: !mergeNext,
|
||||
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
||||
turn,
|
||||
});
|
||||
@@ -920,7 +932,9 @@ export function useNanobotStream(
|
||||
if (ev.resuming) {
|
||||
cancelStreamEndTimer();
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
if (!mergeNext) {
|
||||
setMessages((prev) => finalizeStreamedTurn(prev, turn));
|
||||
}
|
||||
return;
|
||||
}
|
||||
scheduleStreamEndTimer(turn);
|
||||
|
||||
@@ -1118,6 +1118,8 @@ export type InboundEvent =
|
||||
text?: string;
|
||||
/** This answer segment ended, but the active agent turn will continue. */
|
||||
resuming?: boolean;
|
||||
/** The next answer segment continues this same assistant message. */
|
||||
merge_next?: boolean;
|
||||
} & InboundTurnMetadata)
|
||||
| ({
|
||||
event: "reasoning_delta";
|
||||
|
||||
@@ -2009,6 +2009,79 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps length-recovery segments in one assistant message", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-length", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.send("give a long answer");
|
||||
});
|
||||
const activeTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId;
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-length", {
|
||||
event: "delta",
|
||||
chat_id: "chat-length",
|
||||
text: "first ",
|
||||
turn_id: activeTurnId,
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
const assistantId = result.current.messages[1].id;
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-length", {
|
||||
event: "stream_end",
|
||||
chat_id: "chat-length",
|
||||
text: "first ",
|
||||
resuming: true,
|
||||
merge_next: true,
|
||||
turn_id: activeTurnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "first ",
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-length", {
|
||||
event: "delta",
|
||||
chat_id: "chat-length",
|
||||
text: "second",
|
||||
turn_id: activeTurnId,
|
||||
});
|
||||
});
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "first second",
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-length", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-length",
|
||||
turn_id: activeTurnId,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
id: assistantId,
|
||||
content: "first second",
|
||||
isStreaming: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps streaming alive across stream_end when tool activity follows", async () => {
|
||||
const fake = fakeClient();
|
||||
const onTurnEnd = vi.fn();
|
||||
|
||||
Reference in New Issue
Block a user