fix: avoid completed cron tail pending state
This commit is contained in:
@@ -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<UIMessage[]>(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<number | null>(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);
|
||||
|
||||
@@ -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<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. */
|
||||
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,
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user