mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(webui): preserve automation source on streamed replies
This commit is contained in:
parent
606ac56e8f
commit
bb2f6cf324
@ -1216,6 +1216,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
body,
|
body,
|
||||||
metadata=meta,
|
metadata=meta,
|
||||||
phase="answer",
|
phase="answer",
|
||||||
|
include_source=True,
|
||||||
)
|
)
|
||||||
raw = json.dumps(body, ensure_ascii=False)
|
raw = json.dumps(body, ensure_ascii=False)
|
||||||
if not conns:
|
if not conns:
|
||||||
|
|||||||
@ -51,6 +51,7 @@ from nanobot.webui.http_utils import (
|
|||||||
)
|
)
|
||||||
from nanobot.webui.metadata import (
|
from nanobot.webui.metadata import (
|
||||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
|
WEBUI_MESSAGE_SOURCE_METADATA_KEY,
|
||||||
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
)
|
)
|
||||||
@ -1350,6 +1351,35 @@ async def test_send_delta_emits_delta_and_stream_end() -> None:
|
|||||||
assert "text" not in second
|
assert "text" not in second
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_delta_preserves_webui_source_metadata() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"], "streaming": True}, bus, gateway=_basic_handler(bus))
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-source-stream")
|
||||||
|
source = {"kind": "cron", "label": "Repo check"}
|
||||||
|
metadata = {WEBUI_MESSAGE_SOURCE_METADATA_KEY: source}
|
||||||
|
|
||||||
|
await channel.send_delta("chat-source-stream", "done", metadata=metadata, stream_id="sid")
|
||||||
|
await channel.send_delta(
|
||||||
|
"chat-source-stream",
|
||||||
|
"",
|
||||||
|
metadata=metadata,
|
||||||
|
stream_id="sid",
|
||||||
|
stream_end=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = json.loads(mock_ws.send.call_args_list[0][0][0])
|
||||||
|
second = json.loads(mock_ws.send.call_args_list[1][0][0])
|
||||||
|
assert first["event"] == "delta"
|
||||||
|
assert first["source"] == source
|
||||||
|
assert second["event"] == "stream_end"
|
||||||
|
assert second["source"] == source
|
||||||
|
lines = read_transcript_lines("websocket:chat-source-stream")
|
||||||
|
assert lines[-2]["source"] == source
|
||||||
|
assert lines[-1]["source"] == source
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_marks_resuming_stream_end() -> None:
|
async def test_send_delta_marks_resuming_stream_end() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@ -2026,6 +2026,7 @@ def replay_transcript_to_ui_messages(
|
|||||||
continue
|
continue
|
||||||
close_activity_for_answer()
|
close_activity_for_answer()
|
||||||
turn_fields = _turn_fields(rec, "answer")
|
turn_fields = _turn_fields(rec, "answer")
|
||||||
|
source_fields = _source_fields(rec)
|
||||||
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
|
adopted = find_active_placeholder(messages, turn_fields) if buffer_message_id is None else None
|
||||||
if buffer_message_id is None:
|
if buffer_message_id is None:
|
||||||
if adopted:
|
if adopted:
|
||||||
@ -2038,7 +2039,8 @@ def replay_transcript_to_ui_messages(
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": "",
|
"content": "",
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**_turn_fields(rec, "answer"),
|
**turn_fields,
|
||||||
|
**source_fields,
|
||||||
"createdAt": _created_at_ms(rec, idx),
|
"createdAt": _created_at_ms(rec, idx),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -2050,7 +2052,8 @@ def replay_transcript_to_ui_messages(
|
|||||||
**m,
|
**m,
|
||||||
"content": combined,
|
"content": combined,
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**_turn_fields(rec, "answer"),
|
**turn_fields,
|
||||||
|
**source_fields,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
@ -2062,6 +2065,8 @@ def replay_transcript_to_ui_messages(
|
|||||||
continue
|
continue
|
||||||
merge_next = rec.get("resuming") is True and rec.get("merge_next") is True
|
merge_next = rec.get("resuming") is True and rec.get("merge_next") is True
|
||||||
final_text = rec.get("text")
|
final_text = rec.get("text")
|
||||||
|
turn_fields = _turn_fields(rec, "answer")
|
||||||
|
source_fields = _source_fields(rec)
|
||||||
if isinstance(final_text, str):
|
if isinstance(final_text, str):
|
||||||
if buffer_message_id is None:
|
if buffer_message_id is None:
|
||||||
buffer_message_id = _new_id("buf", idx)
|
buffer_message_id = _new_id("buf", idx)
|
||||||
@ -2071,7 +2076,8 @@ def replay_transcript_to_ui_messages(
|
|||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"content": final_text,
|
"content": final_text,
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**_turn_fields(rec, "answer"),
|
**turn_fields,
|
||||||
|
**source_fields,
|
||||||
"createdAt": _created_at_ms(rec, idx),
|
"createdAt": _created_at_ms(rec, idx),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -2082,11 +2088,21 @@ def replay_transcript_to_ui_messages(
|
|||||||
**m,
|
**m,
|
||||||
"content": final_text,
|
"content": final_text,
|
||||||
"isStreaming": True,
|
"isStreaming": True,
|
||||||
**_turn_fields(rec, "answer"),
|
**turn_fields,
|
||||||
|
**source_fields,
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
if merge_next:
|
if merge_next:
|
||||||
buffer_parts = [final_text]
|
buffer_parts = [final_text]
|
||||||
|
elif source_fields and buffer_message_id is not None:
|
||||||
|
for i, m in enumerate(messages):
|
||||||
|
if m.get("id") == buffer_message_id:
|
||||||
|
messages[i] = {
|
||||||
|
**m,
|
||||||
|
**turn_fields,
|
||||||
|
**source_fields,
|
||||||
|
}
|
||||||
|
break
|
||||||
if not merge_next:
|
if not merge_next:
|
||||||
buffer_message_id = None
|
buffer_message_id = None
|
||||||
buffer_parts = []
|
buffer_parts = []
|
||||||
|
|||||||
@ -696,6 +696,42 @@ def test_replay_preserves_local_trigger_source_metadata(tmp_path, monkeypatch) -
|
|||||||
assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"}
|
assert msgs[0]["source"] == {"kind": "local_trigger", "label": "PR review"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_replay_preserves_automation_source_metadata_on_streamed_reply(
|
||||||
|
tmp_path,
|
||||||
|
monkeypatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
|
key = "websocket:t-streamed-cron-source"
|
||||||
|
source = {"kind": "cron", "label": "Repo check"}
|
||||||
|
|
||||||
|
for record in (
|
||||||
|
{
|
||||||
|
"event": "delta",
|
||||||
|
"chat_id": "t-streamed-cron-source",
|
||||||
|
"text": "Repo ",
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "delta",
|
||||||
|
"chat_id": "t-streamed-cron-source",
|
||||||
|
"text": "clean.",
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "stream_end",
|
||||||
|
"chat_id": "t-streamed-cron-source",
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
{"event": "turn_end", "chat_id": "t-streamed-cron-source"},
|
||||||
|
):
|
||||||
|
append_transcript_object(key, record)
|
||||||
|
|
||||||
|
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
|
||||||
|
|
||||||
|
assert msgs[0]["content"] == "Repo clean."
|
||||||
|
assert msgs[0]["source"] == source
|
||||||
|
|
||||||
|
|
||||||
def test_replay_preserves_legacy_trigger_source_metadata(tmp_path, monkeypatch) -> None:
|
def test_replay_preserves_legacy_trigger_source_metadata(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t-trigger-source"
|
key = "websocket:t-trigger-source"
|
||||||
|
|||||||
@ -37,7 +37,7 @@ interface ActiveAssistantCursor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type PendingStreamEvent =
|
type PendingStreamEvent =
|
||||||
| { kind: "delta"; text: string; turn: UIMessageTurnFields }
|
| { kind: "delta"; text: string; turn: UIMessageTurnFields; source?: UIMessage["source"] }
|
||||||
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
| { kind: "reasoning"; text: string; turn: UIMessageTurnFields };
|
||||||
|
|
||||||
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
type UIMessageTurnFields = Pick<UIMessage, "turnId" | "turnPhase" | "turnSeq">;
|
||||||
@ -778,7 +778,12 @@ export function useNanobotStream(
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const appendAnswerChunk = useCallback(
|
const appendAnswerChunk = useCallback(
|
||||||
(prev: UIMessage[], chunk: string, turn: UIMessageTurnFields = {}): UIMessage[] => {
|
(
|
||||||
|
prev: UIMessage[],
|
||||||
|
chunk: string,
|
||||||
|
turn: UIMessageTurnFields = {},
|
||||||
|
source?: UIMessage["source"],
|
||||||
|
): UIMessage[] => {
|
||||||
let next = prev;
|
let next = prev;
|
||||||
let targetIndex = resolveActiveAssistantIndex(next, turn);
|
let targetIndex = resolveActiveAssistantIndex(next, turn);
|
||||||
|
|
||||||
@ -809,6 +814,7 @@ export function useNanobotStream(
|
|||||||
content: target.content + chunk,
|
content: target.content + chunk,
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
...turn,
|
...turn,
|
||||||
|
...(source ? { source } : {}),
|
||||||
};
|
};
|
||||||
closedAssistantStreamIdsRef.current.delete(merged.id);
|
closedAssistantStreamIdsRef.current.delete(merged.id);
|
||||||
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
activeAssistantRef.current = { id: merged.id, index: targetIndex };
|
||||||
@ -823,7 +829,7 @@ export function useNanobotStream(
|
|||||||
let next = prev;
|
let next = prev;
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
if (event.kind === "delta") {
|
if (event.kind === "delta") {
|
||||||
next = appendAnswerChunk(next, event.text, event.turn);
|
next = appendAnswerChunk(next, event.text, event.turn, event.source);
|
||||||
} else {
|
} else {
|
||||||
if (closeActiveAssistantStream()) clearActivitySegment();
|
if (closeActiveAssistantStream()) clearActivitySegment();
|
||||||
next = attachReasoningChunk(
|
next = attachReasoningChunk(
|
||||||
@ -843,6 +849,7 @@ export function useNanobotStream(
|
|||||||
closeAnswerSegment?: boolean;
|
closeAnswerSegment?: boolean;
|
||||||
finalAnswerText?: string;
|
finalAnswerText?: string;
|
||||||
turn?: UIMessageTurnFields;
|
turn?: UIMessageTurnFields;
|
||||||
|
source?: UIMessage["source"];
|
||||||
}) => {
|
}) => {
|
||||||
if (streamFrameRef.current !== null) {
|
if (streamFrameRef.current !== null) {
|
||||||
window.cancelAnimationFrame(streamFrameRef.current);
|
window.cancelAnimationFrame(streamFrameRef.current);
|
||||||
@ -855,7 +862,8 @@ export function useNanobotStream(
|
|||||||
const events = pendingStreamEventsRef.current;
|
const events = pendingStreamEventsRef.current;
|
||||||
const finalAnswerText = options?.finalAnswerText;
|
const finalAnswerText = options?.finalAnswerText;
|
||||||
const turn = options?.turn ?? {};
|
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();
|
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -873,6 +881,7 @@ export function useNanobotStream(
|
|||||||
content: finalAnswerText,
|
content: finalAnswerText,
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
...turn,
|
...turn,
|
||||||
|
...(source ? { source } : {}),
|
||||||
};
|
};
|
||||||
next = replaceMessageAt(next, targetIndex, merged);
|
next = replaceMessageAt(next, targetIndex, merged);
|
||||||
if (!options?.closeAnswerSegment) {
|
if (!options?.closeAnswerSegment) {
|
||||||
@ -890,6 +899,7 @@ export function useNanobotStream(
|
|||||||
content: finalAnswerText,
|
content: finalAnswerText,
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
...turn,
|
...turn,
|
||||||
|
...(source ? { source } : {}),
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -900,6 +910,18 @@ export function useNanobotStream(
|
|||||||
buffer.current = { messageId: id };
|
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();
|
if (options?.closeAnswerSegment) closeActiveAssistantStream();
|
||||||
return next;
|
return next;
|
||||||
@ -1027,6 +1049,7 @@ export function useNanobotStream(
|
|||||||
kind: "delta",
|
kind: "delta",
|
||||||
text: chunk,
|
text: chunk,
|
||||||
turn: turnFieldsFromEvent(ev, "answer"),
|
turn: turnFieldsFromEvent(ev, "answer"),
|
||||||
|
source: ev.source,
|
||||||
});
|
});
|
||||||
schedulePendingStreamFlush();
|
schedulePendingStreamFlush();
|
||||||
return;
|
return;
|
||||||
@ -1054,6 +1077,7 @@ export function useNanobotStream(
|
|||||||
closeAnswerSegment: !mergeNext,
|
closeAnswerSegment: !mergeNext,
|
||||||
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
...(typeof ev.text === "string" ? { finalAnswerText: ev.text } : {}),
|
||||||
turn,
|
turn,
|
||||||
|
source: ev.source,
|
||||||
});
|
});
|
||||||
if (suppressStreamUntilTurnEndRef.current) return;
|
if (suppressStreamUntilTurnEndRef.current) return;
|
||||||
if (ev.resuming) {
|
if (ev.resuming) {
|
||||||
|
|||||||
@ -1185,12 +1185,16 @@ export type InboundEvent =
|
|||||||
chat_id: string;
|
chat_id: string;
|
||||||
text: string;
|
text: string;
|
||||||
stream_id?: string;
|
stream_id?: string;
|
||||||
|
/** Lightweight provenance for proactive streamed assistant messages. */
|
||||||
|
source?: UIMessageSource;
|
||||||
} & InboundTurnMetadata)
|
} & InboundTurnMetadata)
|
||||||
| ({
|
| ({
|
||||||
event: "stream_end";
|
event: "stream_end";
|
||||||
chat_id: string;
|
chat_id: string;
|
||||||
stream_id?: string;
|
stream_id?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
|
/** Lightweight provenance for proactive streamed assistant messages. */
|
||||||
|
source?: UIMessageSource;
|
||||||
/** This answer segment ended, but the active agent turn will continue. */
|
/** This answer segment ended, but the active agent turn will continue. */
|
||||||
resuming?: boolean;
|
resuming?: boolean;
|
||||||
/** The next answer segment continues this same assistant message. */
|
/** The next answer segment continues this same assistant message. */
|
||||||
|
|||||||
@ -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", () => {
|
it("does not start streaming from completed trailing activity after an answer", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const initialMessages = [
|
const initialMessages = [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user