diff --git a/nanobot/webui/transcript.py b/nanobot/webui/transcript.py index ee2734283..0d19a7119 100644 --- a/nanobot/webui/transcript.py +++ b/nanobot/webui/transcript.py @@ -1823,6 +1823,29 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None: return None +def has_pending_tool_calls(lines: list[dict[str, Any]]) -> bool: + """Return True when the selected transcript tail looks like an unfinished turn.""" + for rec in reversed(lines): + ev = rec.get("event") + if ev == "turn_end": + return False + if ev == "user": + return False + if ev == "message": + return rec.get("kind") in {"tool_hint", "progress", "reasoning"} + if ev in { + "delta", + "stream_end", + "reasoning_delta", + "reasoning_end", + "file_edit", + }: + return True + if ev in {WEBUI_FORK_MARKER_EVENT}: + continue + return False + + def build_webui_thread_response( session_key: str, *, @@ -1855,6 +1878,7 @@ def build_webui_thread_response( "schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION, "sessionKey": session_key, "messages": msgs, + "has_pending_tool_calls": has_pending_tool_calls(lines), } if page is not None: page["loaded_message_count"] = len(msgs) diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py index 0675b659a..4f0173400 100644 --- a/tests/utils/test_webui_transcript.py +++ b/tests/utils/test_webui_transcript.py @@ -290,6 +290,91 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None: assert msgs[1]["latencyMs"] == 42 +def test_thread_response_does_not_mark_completed_message_tool_tail_pending( + tmp_path, + monkeypatch, +) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:cron-tail" + turn_id = "cron:job:run" + for ev in ( + { + "event": "message", + "chat_id": "cron-tail", + "text": 'message({"content":"Cron test"})', + "kind": "tool_hint", + "tool_events": [{ + "phase": "start", + "call_id": "call-message", + "name": "message", + "arguments": {"content": "Cron test"}, + }], + "turn_id": turn_id, + "turn_phase": "activity", + "turn_seq": 5, + }, + { + "event": "message", + "chat_id": "cron-tail", + "text": "Cron test", + "source": {"kind": "cron", "label": "one-min-test"}, + "turn_id": turn_id, + "turn_phase": "answer", + "turn_seq": 6, + }, + { + "event": "message", + "chat_id": "cron-tail", + "text": "", + "kind": "progress", + "tool_events": [{ + "phase": "end", + "call_id": "call-message", + "name": "message", + "arguments": {"content": "Cron test"}, + "result": "ok", + }], + "turn_id": turn_id, + "turn_phase": "activity", + "turn_seq": 7, + }, + { + "event": "turn_end", + "chat_id": "cron-tail", + "turn_id": turn_id, + "turn_phase": "complete", + "turn_seq": 8, + }, + ): + append_transcript_object(key, ev) + + out = build_webui_thread_response(key) + + assert out is not None + assert out["has_pending_tool_calls"] is False + assert out["messages"][-1]["kind"] == "trace" + assert out["messages"][-2]["content"] == "Cron test" + + +def test_thread_response_marks_unfinished_tool_tail_pending(tmp_path, monkeypatch) -> None: + monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) + key = "websocket:active-tail" + append_transcript_object( + key, + { + "event": "message", + "chat_id": "active-tail", + "text": 'exec({"command":"date"})', + "kind": "tool_hint", + }, + ) + + out = build_webui_thread_response(key) + + assert out is not None + assert out["has_pending_tool_calls"] is True + + def test_replay_preserves_turn_metadata(tmp_path, monkeypatch) -> None: monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path) key = "websocket:t-turn" diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index e53377bab..bac44ad00 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -8,6 +8,7 @@ import { normalizeToolProgressEvents, toolTraceLinesFromEvents, } from "@/lib/tool-traces"; +import { hasPendingAgentActivity } from "@/lib/activity-timeline"; import type { StreamError } from "@/lib/nanobot-client"; import type { InboundEvent, @@ -450,12 +451,8 @@ export function useNanobotStream( } { const { client } = useClient(); const [messages, setMessages] = useState(initialMessages); - /** If the last loaded message is a trace row (e.g. "Using 2 tools"), - * the model was still processing when the page loaded — keep the - * loading spinner alive so the user sees the model is active. */ - const initialStreaming = initialMessages.length > 0 - ? initialMessages[initialMessages.length - 1].kind === "trace" - : false; + /** If history ends in unfinished agent activity, keep the loading spinner alive. */ + const initialStreaming = hasPendingAgentActivity(initialMessages); const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls); /** Unix epoch seconds when the current user turn started; cleared on ``idle``. */ const [runStartedAt, setRunStartedAt] = useState(null); @@ -694,9 +691,7 @@ export function useNanobotStream( useEffect(() => { setMessages(initialMessages); setIsStreaming( - (initialMessages.length > 0 - ? initialMessages[initialMessages.length - 1].kind === "trace" - : false) || hasPendingToolCalls, + hasPendingAgentActivity(initialMessages) || hasPendingToolCalls, ); setStreamError(null); setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null); diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index 7618b7e17..43adaadd6 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -8,6 +8,7 @@ import { fetchWebuiThread, listSessions, } from "@/lib/api"; +import { hasPendingAgentActivity } from "@/lib/activity-timeline"; import { deriveTitle } from "@/lib/format"; import type { ChatSummary, @@ -29,6 +30,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] { })); } +function hasPendingToolCallsFromThread( + body: Awaited>, + messages: UIMessage[], +): boolean { + if (typeof body?.has_pending_tool_calls === "boolean") { + return body.has_pending_tool_calls; + } + return hasPendingAgentActivity(messages); +} + /** Sidebar state: fetches the full session list and exposes create / delete actions. */ export function useSessions(): { sessions: ChatSummary[]; @@ -257,8 +268,7 @@ export function useSessionHistory(key: string | null): { return; } const ui = persistedMessagesToUi(body.messages); - const last = ui[ui.length - 1]; - const hasPending = last?.kind === "trace"; + const hasPending = hasPendingToolCallsFromThread(body, ui); const forkBoundary = typeof body.fork_boundary_message_count === "number" ? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length)) : null; @@ -342,13 +352,12 @@ export function useSessionHistory(key: string | null): { ? null : prev.forkBoundaryMessageCount + older.length; const nextMessages = [...older, ...prev.messages]; - const last = nextMessages[nextMessages.length - 1]; return { ...prev, messages: nextMessages, loadingOlder: false, error: null, - hasPendingToolCalls: last?.kind === "trace", + hasPendingToolCalls: hasPendingAgentActivity(nextMessages), forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary, beforeCursor: body.page?.before_cursor ?? null, hasMoreBefore: body.page?.has_more_before === true, diff --git a/webui/src/lib/activity-timeline.ts b/webui/src/lib/activity-timeline.ts index 9f2b049b6..f1f912cef 100644 --- a/webui/src/lib/activity-timeline.ts +++ b/webui/src/lib/activity-timeline.ts @@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean { return isReasoningOnlyAssistant(message) || message.kind === "trace"; } +export function hasPendingAgentActivity(messages: UIMessage[]): boolean { + if (messages.length === 0) return false; + const last = messages[messages.length - 1]; + if (!isAgentActivityMember(last)) return false; + + let trailingStart = messages.length - 1; + while ( + trailingStart > 0 + && isAgentActivityMember(messages[trailingStart - 1]) + ) { + trailingStart -= 1; + } + + const trailing = messages.slice(trailingStart); + if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) { + return true; + } + + const previous = messages[trailingStart - 1]; + if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) { + return true; + } + + const trailingTurnIds = new Set( + trailing + .map((message) => message.turnId) + .filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0), + ); + if (!previous.turnId) return trailingTurnIds.size > 0; + return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId); +} + export function normalizeActivityTimeline( messages: UIMessage[], options: NormalizeActivityTimelineOptions = {}, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index f02a2e650..df51f0887 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -881,6 +881,7 @@ export interface WebuiThreadPersistedPayload { savedAt?: string; messages: UIMessage[]; fork_boundary_message_count?: number; + has_pending_tool_calls?: boolean; page?: WebuiThreadPagePayload; workspace_scope?: WorkspaceScopePayload; } diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index dcec94df5..b983fc193 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -180,6 +180,36 @@ describe("useNanobotStream", () => { }); }); + it("does not start streaming from completed trailing activity after an answer", () => { + const fake = fakeClient(); + const initialMessages = [ + { + id: "a1", + role: "assistant" as const, + content: "Cron test", + turnId: "cron:run", + createdAt: Date.now(), + }, + { + id: "t1", + role: "tool" as const, + kind: "trace" as const, + content: "message({})", + traces: ["message({})"], + turnId: "cron:run", + createdAt: Date.now(), + }, + ]; + + const { result } = renderHook( + () => useNanobotStream("chat-cron-done", initialMessages), + { wrapper: wrap(fake.client) }, + ); + + expect(result.current.messages.at(-1)?.kind).toBe("trace"); + expect(result.current.isStreaming).toBe(false); + }); + it("drops pending stream work when switching chats", async () => { const fake = fakeClient(); const { result, rerender } = renderHook( diff --git a/webui/src/tests/useSessions.test.tsx b/webui/src/tests/useSessions.test.tsx index 323f67ee7..148bf4628 100644 --- a/webui/src/tests/useSessions.test.tsx +++ b/webui/src/tests/useSessions.test.tsx @@ -416,6 +416,40 @@ describe("useSessions", () => { expect(result.current.hasPendingToolCalls).toBe(true); }); + it("uses the server pending flag for completed tails that still end with trace rows", async () => { + vi.mocked(api.fetchWebuiThread).mockResolvedValue({ + schemaVersion: 3, + has_pending_tool_calls: false, + messages: [ + { + id: "a1", + role: "assistant", + content: "Cron test", + turnId: "cron:run", + createdAt: 1, + }, + { + id: "t1", + role: "tool", + kind: "trace", + content: "message({})", + traces: ["message({})"], + turnId: "cron:run", + createdAt: 2, + }, + ], + }); + + const { result } = renderHook(() => useSessionHistory("websocket:chat-cron-done"), { + wrapper: wrap(fakeClient()), + }); + + await waitFor(() => expect(result.current.loading).toBe(false)); + + expect(result.current.messages.at(-1)?.kind).toBe("trace"); + expect(result.current.hasPendingToolCalls).toBe(false); + }); + it("does not flag transcript as pending when last row is not a trace", async () => { vi.mocked(api.fetchWebuiThread).mockResolvedValue({ schemaVersion: 3,