fix(webui): preserve automation source on streamed replies

This commit is contained in:
chengyongru
2026-07-30 17:57:31 +08:00
committed by chengyongru
parent 606ac56e8f
commit bb2f6cf324
7 changed files with 176 additions and 8 deletions
+28 -4
View File
@@ -37,7 +37,7 @@ interface ActiveAssistantCursor {
}
type PendingStreamEvent =
| { kind: "delta"; text: string; turn: UIMessageTurnFields }
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
@@ -778,7 +778,12 @@ export function useNanobotStream(
}, []);
const appendAnswerChunk = useCallback(
(prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
(
prev: UIMessage[],
chunk: string,
turn: UIMessageTurnFields = {},
source?: UIMessage["source"],
): UIMessage[] => {
let next = prev;
let targetIndex = resolveActiveAssistantIndex(next, turn);
@@ -809,6 +814,7 @@ export function useNanobotStream(
content: target.content + chunk,
isStreaming: true,
...turn,
...(source ? { source } : {}),
};
closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex };
@@ -823,7 +829,7 @@ export function useNanobotStream(
let next = prev;
for (const event of events) {
if (event.kind === "delta") {
next = appendAnswerChunk(next, event.text, event.turn);
next = appendAnswerChunk(next, event.text, event.turn, event.source);
} else {
if (closeActiveAssistantStream()) clearActivitySegment();
next = attachReasoningChunk(
@@ -843,6 +849,7 @@ export function useNanobotStream(
closeAnswerSegment?: boolean;
finalAnswerText?: string;
turn?: UIMessageTurnFields;
source?: UIMessage["source"];
}) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
@@ -855,7 +862,8 @@ export function useNanobotStream(
const events = pendingStreamEventsRef.current;
const finalAnswerText = options?.finalAnswerText;
const turn = options?.turn ?? {};
if (events.length === 0 && finalAnswerText === undefined) {
const source = options?.source;
if (events.length === 0 && finalAnswerText === undefined && source === undefined) {
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return;
}
@@ -873,6 +881,7 @@ export function useNanobotStream(
content: finalAnswerText,
isStreaming: true,
...turn,
...(source ? { source } : {}),
};
next = replaceMessageAt(next, targetIndex, merged);
if (!options?.closeAnswerSegment) {
@@ -890,6 +899,7 @@ export function useNanobotStream(
content: finalAnswerText,
isStreaming: true,
...turn,
...(source ? { source } : {}),
createdAt: Date.now(),
},
];
@@ -900,6 +910,18 @@ export function useNanobotStream(
buffer.current = { messageId: id };
}
}
} else if (source) {
const targetIndex =
resolveActiveAssistantIndex(next, turn)
?? findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current, turn);
if (targetIndex !== null) {
const target = next[targetIndex];
next = replaceMessageAt(next, targetIndex, {
...target,
...turn,
source,
});
}
}
if (options?.closeAnswerSegment) closeActiveAssistantStream();
return next;
@@ -1027,6 +1049,7 @@ export function useNanobotStream(
kind: "delta",
text: chunk,
turn: turnFieldsFromEvent(ev, "answer"),
source: ev.source,
});
schedulePendingStreamFlush();
return;
@@ -1054,6 +1077,7 @@ export function useNanobotStream(
closeAnswerSegment: !mergeNext,
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
turn,
source: ev.source,
});
if (suppressStreamUntilTurnEndRef.current) return;
if (ev.resuming) {
+4
View File
@@ -1185,12 +1185,16 @@ export type InboundEvent =
chat_id: string;
text: string;
stream_id?: string;
/** Lightweight provenance for proactive streamed assistant messages. */
source?: UIMessageSource;
} & InboundTurnMetadata)
| ({
event: "stream_end";
chat_id: string;
stream_id?: string;
text?: string;
/** Lightweight provenance for proactive streamed assistant messages. */
source?: UIMessageSource;
/** This answer segment ended, but the active agent turn will continue. */
resuming?: boolean;
/** The next answer segment continues this same assistant message. */
+57
View File
@@ -314,6 +314,63 @@ describe("useNanobotStream", () => {
});
});
it("preserves proactive automation source metadata on streamed assistant messages", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron-stream", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const source = { kind: "cron", label: "Repo check" };
act(() => {
fake.emit("chat-cron-stream", {
event: "delta",
chat_id: "chat-cron-stream",
text: "Repo ",
source,
});
fake.emit("chat-cron-stream", {
event: "stream_end",
chat_id: "chat-cron-stream",
source,
});
fake.emit("chat-cron-stream", {
event: "turn_end",
chat_id: "chat-cron-stream",
});
});
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Repo ",
isStreaming: false,
source,
});
});
it("preserves proactive automation source metadata on stream_end final text", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-cron-stream-end", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
const source = { kind: "cron", label: "Repo check" };
act(() => {
fake.emit("chat-cron-stream-end", {
event: "stream_end",
chat_id: "chat-cron-stream-end",
text: "Repo clean.",
source,
});
});
expect(result.current.messages[0]).toMatchObject({
role: "assistant",
content: "Repo clean.",
isStreaming: true,
source,
});
});
it("does not start streaming from completed trailing activity after an answer", () => {
const fake = fakeClient();
const initialMessages = [