mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-06-15 23:34:00 +00:00
fix: avoid completed cron tail pending state
This commit is contained in:
parent
e46a99ced9
commit
1ad9d77bc7
@ -1823,6 +1823,29 @@ def fork_boundary_message_count(lines: list[dict[str, Any]]) -> int | None:
|
|||||||
return 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(
|
def build_webui_thread_response(
|
||||||
session_key: str,
|
session_key: str,
|
||||||
*,
|
*,
|
||||||
@ -1855,6 +1878,7 @@ def build_webui_thread_response(
|
|||||||
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
"schemaVersion": WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||||
"sessionKey": session_key,
|
"sessionKey": session_key,
|
||||||
"messages": msgs,
|
"messages": msgs,
|
||||||
|
"has_pending_tool_calls": has_pending_tool_calls(lines),
|
||||||
}
|
}
|
||||||
if page is not None:
|
if page is not None:
|
||||||
page["loaded_message_count"] = len(msgs)
|
page["loaded_message_count"] = len(msgs)
|
||||||
|
|||||||
@ -290,6 +290,91 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
|||||||
assert msgs[1]["latencyMs"] == 42
|
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:
|
def test_replay_preserves_turn_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-turn"
|
key = "websocket:t-turn"
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
normalizeToolProgressEvents,
|
normalizeToolProgressEvents,
|
||||||
toolTraceLinesFromEvents,
|
toolTraceLinesFromEvents,
|
||||||
} from "@/lib/tool-traces";
|
} from "@/lib/tool-traces";
|
||||||
|
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
@ -450,12 +451,8 @@ export function useNanobotStream(
|
|||||||
} {
|
} {
|
||||||
const { client } = useClient();
|
const { client } = useClient();
|
||||||
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
|
||||||
/** If the last loaded message is a trace row (e.g. "Using 2 tools"),
|
/** If history ends in unfinished agent activity, keep the loading spinner alive. */
|
||||||
* the model was still processing when the page loaded — keep the
|
const initialStreaming = hasPendingAgentActivity(initialMessages);
|
||||||
* loading spinner alive so the user sees the model is active. */
|
|
||||||
const initialStreaming = initialMessages.length > 0
|
|
||||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
|
||||||
: false;
|
|
||||||
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
|
||||||
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
|
||||||
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
|
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
|
||||||
@ -694,9 +691,7 @@ export function useNanobotStream(
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMessages(initialMessages);
|
setMessages(initialMessages);
|
||||||
setIsStreaming(
|
setIsStreaming(
|
||||||
(initialMessages.length > 0
|
hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
|
||||||
? initialMessages[initialMessages.length - 1].kind === "trace"
|
|
||||||
: false) || hasPendingToolCalls,
|
|
||||||
);
|
);
|
||||||
setStreamError(null);
|
setStreamError(null);
|
||||||
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import {
|
|||||||
fetchWebuiThread,
|
fetchWebuiThread,
|
||||||
listSessions,
|
listSessions,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
ChatSummary,
|
ChatSummary,
|
||||||
@ -29,6 +30,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPendingToolCallsFromThread(
|
||||||
|
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
|
||||||
|
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. */
|
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
|
||||||
export function useSessions(): {
|
export function useSessions(): {
|
||||||
sessions: ChatSummary[];
|
sessions: ChatSummary[];
|
||||||
@ -257,8 +268,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ui = persistedMessagesToUi(body.messages);
|
const ui = persistedMessagesToUi(body.messages);
|
||||||
const last = ui[ui.length - 1];
|
const hasPending = hasPendingToolCallsFromThread(body, ui);
|
||||||
const hasPending = last?.kind === "trace";
|
|
||||||
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
const forkBoundary = typeof body.fork_boundary_message_count === "number"
|
||||||
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
|
||||||
: null;
|
: null;
|
||||||
@ -342,13 +352,12 @@ export function useSessionHistory(key: string | null): {
|
|||||||
? null
|
? null
|
||||||
: prev.forkBoundaryMessageCount + older.length;
|
: prev.forkBoundaryMessageCount + older.length;
|
||||||
const nextMessages = [...older, ...prev.messages];
|
const nextMessages = [...older, ...prev.messages];
|
||||||
const last = nextMessages[nextMessages.length - 1];
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
messages: nextMessages,
|
messages: nextMessages,
|
||||||
loadingOlder: false,
|
loadingOlder: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: last?.kind === "trace",
|
hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
|
||||||
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
|
||||||
beforeCursor: body.page?.before_cursor ?? null,
|
beforeCursor: body.page?.before_cursor ?? null,
|
||||||
hasMoreBefore: body.page?.has_more_before === true,
|
hasMoreBefore: body.page?.has_more_before === true,
|
||||||
|
|||||||
@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean {
|
|||||||
return isReasoningOnlyAssistant(message) || message.kind === "trace";
|
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(
|
export function normalizeActivityTimeline(
|
||||||
messages: UIMessage[],
|
messages: UIMessage[],
|
||||||
options: NormalizeActivityTimelineOptions = {},
|
options: NormalizeActivityTimelineOptions = {},
|
||||||
|
|||||||
@ -881,6 +881,7 @@ export interface WebuiThreadPersistedPayload {
|
|||||||
savedAt?: string;
|
savedAt?: string;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
fork_boundary_message_count?: number;
|
fork_boundary_message_count?: number;
|
||||||
|
has_pending_tool_calls?: boolean;
|
||||||
page?: WebuiThreadPagePayload;
|
page?: WebuiThreadPagePayload;
|
||||||
workspace_scope?: WorkspaceScopePayload;
|
workspace_scope?: WorkspaceScopePayload;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 () => {
|
it("drops pending stream work when switching chats", async () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result, rerender } = renderHook(
|
const { result, rerender } = renderHook(
|
||||||
|
|||||||
@ -416,6 +416,40 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.hasPendingToolCalls).toBe(true);
|
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 () => {
|
it("does not flag transcript as pending when last row is not a trace", async () => {
|
||||||
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
schemaVersion: 3,
|
schemaVersion: 3,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user