mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
fix(webui): stabilize live thread rendering and navigation
This commit is contained in:
parent
6a4ed255de
commit
5d7f3f2751
@ -1476,6 +1476,8 @@ class WebSocketChannel(BaseChannel):
|
||||
payload["media_urls"] = urls
|
||||
if msg.reply_to:
|
||||
payload["reply_to"] = msg.reply_to
|
||||
if msg.metadata.get("_tool_events"):
|
||||
payload["tool_events"] = msg.metadata["_tool_events"]
|
||||
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||
# progress strings) so WS clients can render them as subordinate
|
||||
# trace rows rather than conversational replies.
|
||||
|
||||
@ -322,6 +322,54 @@ async def test_send_removes_connection_on_connection_closed() -> None:
|
||||
assert mock_ws not in channel._conn_chats
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_progress_includes_structured_tool_events() -> None:
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
|
||||
mock_ws = AsyncMock()
|
||||
channel._attach(mock_ws, "chat-1")
|
||||
|
||||
await channel.send(OutboundMessage(
|
||||
channel="websocket",
|
||||
chat_id="chat-1",
|
||||
content='search "hermes"',
|
||||
metadata={
|
||||
"_progress": True,
|
||||
"_tool_hint": True,
|
||||
"_tool_events": [
|
||||
{
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": "call-1",
|
||||
"name": "web_search",
|
||||
"arguments": {"query": "hermes", "count": 8},
|
||||
"result": None,
|
||||
"error": None,
|
||||
"files": [],
|
||||
"embeds": [],
|
||||
}
|
||||
],
|
||||
},
|
||||
))
|
||||
|
||||
payload = json.loads(mock_ws.send.await_args.args[0])
|
||||
assert payload["event"] == "message"
|
||||
assert payload["kind"] == "tool_hint"
|
||||
assert payload["tool_events"] == [
|
||||
{
|
||||
"version": 1,
|
||||
"phase": "start",
|
||||
"call_id": "call-1",
|
||||
"name": "web_search",
|
||||
"arguments": {"query": "hermes", "count": 8},
|
||||
"result": None,
|
||||
"error": None,
|
||||
"files": [],
|
||||
"embeds": [],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_delta_removes_connection_on_connection_closed() -> None:
|
||||
bus = MagicMock()
|
||||
|
||||
@ -31,8 +31,10 @@ export function Sidebar(props: SidebarProps) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const filteredSessions = useMemo(() => {
|
||||
if (!normalizedQuery) return props.sessions;
|
||||
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
return props.sessions.filter((session) => {
|
||||
const haystack = [
|
||||
session.title,
|
||||
session.preview,
|
||||
session.chatId,
|
||||
session.channel,
|
||||
@ -41,7 +43,7 @@ export function Sidebar(props: SidebarProps) {
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
});
|
||||
}, [normalizedQuery, props.sessions]);
|
||||
|
||||
|
||||
@ -81,19 +81,33 @@ export function ThreadShell({
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
|
||||
const { modelName, token } = useClient();
|
||||
const {
|
||||
messages: historical,
|
||||
loading,
|
||||
hasPendingToolCalls,
|
||||
refresh: refreshHistory,
|
||||
version: historyVersion,
|
||||
} = useSessionHistory(historyKey);
|
||||
const { client, modelName, token } = useClient();
|
||||
const [booting, setBooting] = useState(false);
|
||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const lastCachedChatIdRef = useRef<string | null>(null);
|
||||
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
return messageCacheRef.current.get(chatId) ?? historical;
|
||||
}, [chatId, historical]);
|
||||
const handleTurnEnd = useCallback(() => {
|
||||
if (chatId) pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
onTurnEnd?.();
|
||||
}, [chatId, onTurnEnd, refreshHistory]);
|
||||
const {
|
||||
messages,
|
||||
isStreaming,
|
||||
@ -102,22 +116,48 @@ export function ThreadShell({
|
||||
setMessages,
|
||||
streamError,
|
||||
dismissStreamError,
|
||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
|
||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||
const showHeroComposer = messages.length === 0 && !loading;
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const appliedVersion = appliedHistoryVersionRef.current.get(chatId) ?? 0;
|
||||
const hasPendingCanonicalHydrate = pendingCanonicalHydrateRef.current.has(chatId);
|
||||
const hasNewCanonicalHistory = hasPendingCanonicalHydrate && historyVersion > appliedVersion;
|
||||
// When the user switches away and back, keep the local in-memory thread
|
||||
// state (including not-yet-persisted messages) instead of replacing it with
|
||||
// whatever the history endpoint currently knows about.
|
||||
// whatever the history endpoint currently knows about. Once a fresh
|
||||
// canonical replay arrives after turn_end, prefer it so live Markdown/tool
|
||||
// rendering converges to the same shape as a manual refresh.
|
||||
setMessages((prev) => {
|
||||
if (hasNewCanonicalHistory && historical.length > 0) {
|
||||
pendingCanonicalHydrateRef.current.delete(chatId);
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
messageCacheRef.current.set(chatId, historical);
|
||||
return historical;
|
||||
}
|
||||
if (cached && cached.length > 0) return cached;
|
||||
if (historical.length === 0 && prev.length > 0) return prev;
|
||||
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||
return historical;
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, chatId, historical]);
|
||||
}, [loading, chatId, historical, historyVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
pendingCanonicalHydrateRef.current.add(chatId);
|
||||
refreshHistory();
|
||||
});
|
||||
}, [chatId, client, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
}, [chatId, loading, historical]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chatId) return;
|
||||
@ -148,6 +188,7 @@ export function ThreadShell({
|
||||
const pending = pendingFirstRef.current;
|
||||
if (!pending) return;
|
||||
pendingFirstRef.current = null;
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
send(pending.content, pending.images, pending.options);
|
||||
setBooting(false);
|
||||
}, [chatId, send]);
|
||||
@ -181,18 +222,26 @@ export function ThreadShell({
|
||||
[booting, onCreateChat],
|
||||
);
|
||||
|
||||
const handleThreadSend = useCallback(
|
||||
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||
setScrollToBottomSignal((value) => value + 1);
|
||||
send(content, images, options);
|
||||
},
|
||||
[send],
|
||||
);
|
||||
|
||||
const handleQuickAction = useCallback(
|
||||
(prompt: string) => {
|
||||
const options: SendOptions | undefined = heroImageMode
|
||||
? { imageGeneration: { enabled: true, aspect_ratio: null } }
|
||||
: undefined;
|
||||
if (session) {
|
||||
send(prompt, undefined, options);
|
||||
handleThreadSend(prompt, undefined, options);
|
||||
return;
|
||||
}
|
||||
void handleWelcomeSend(prompt, undefined, options);
|
||||
},
|
||||
[handleWelcomeSend, heroImageMode, send, session],
|
||||
[handleThreadSend, handleWelcomeSend, heroImageMode, session],
|
||||
);
|
||||
|
||||
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
|
||||
@ -233,7 +282,7 @@ export function ThreadShell({
|
||||
) : null}
|
||||
{session ? (
|
||||
<ThreadComposer
|
||||
onSend={send}
|
||||
onSend={handleThreadSend}
|
||||
disabled={!chatId}
|
||||
isStreaming={isStreaming}
|
||||
placeholder={
|
||||
@ -296,6 +345,8 @@ export function ThreadShell({
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
scrollToBottomSignal={scrollToBottomSignal}
|
||||
conversationKey={historyKey}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { type ReactNode, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@ -12,6 +12,8 @@ interface ThreadViewportProps {
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
scrollToBottomSignal?: number;
|
||||
conversationKey?: string | null;
|
||||
}
|
||||
|
||||
const NEAR_BOTTOM_PX = 48;
|
||||
@ -21,26 +23,92 @@ export function ThreadViewport({
|
||||
isStreaming,
|
||||
composer,
|
||||
emptyState,
|
||||
scrollToBottomSignal = 0,
|
||||
conversationKey = null,
|
||||
}: ThreadViewportProps) {
|
||||
const { t } = useTranslation();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const lastConversationKeyRef = useRef<string | null>(conversationKey);
|
||||
const pendingConversationScrollRef = useRef(true);
|
||||
const scrollFrameIdsRef = useRef<number[]>([]);
|
||||
const forceBottomUntilRef = useRef(0);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const hasMessages = messages.length > 0;
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false) => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.scrollTo({
|
||||
top: el.scrollHeight,
|
||||
behavior: smooth ? "smooth" : "auto",
|
||||
});
|
||||
const cancelScheduledBottomScroll = useCallback(() => {
|
||||
for (const id of scrollFrameIdsRef.current) {
|
||||
window.cancelAnimationFrame(id);
|
||||
}
|
||||
scrollFrameIdsRef.current = [];
|
||||
}, []);
|
||||
|
||||
const scrollToBottomNow = useCallback((smooth = false) => {
|
||||
const el = scrollRef.current;
|
||||
const marker = bottomRef.current;
|
||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||
if (marker) {
|
||||
marker.scrollIntoView({ block: "end", behavior });
|
||||
} else if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior });
|
||||
}
|
||||
setAtBottom(true);
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback((smooth = false, frames = 1) => {
|
||||
cancelScheduledBottomScroll();
|
||||
scrollToBottomNow(smooth);
|
||||
for (let i = 1; i < frames; i += 1) {
|
||||
const id = window.requestAnimationFrame(() => scrollToBottomNow(smooth));
|
||||
scrollFrameIdsRef.current.push(id);
|
||||
}
|
||||
}, [cancelScheduledBottomScroll, scrollToBottomNow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atBottom) return;
|
||||
scrollToBottom(!isStreaming);
|
||||
}, [messages, isStreaming, atBottom, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToBottomSignal <= 0) return;
|
||||
forceBottomUntilRef.current = Date.now() + 2_000;
|
||||
scrollToBottom(true, 8);
|
||||
}, [scrollToBottomSignal, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (lastConversationKeyRef.current === conversationKey) return;
|
||||
lastConversationKeyRef.current = conversationKey;
|
||||
pendingConversationScrollRef.current = true;
|
||||
forceBottomUntilRef.current = Date.now() + 2_000;
|
||||
setAtBottom(true);
|
||||
}, [conversationKey]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!pendingConversationScrollRef.current) return;
|
||||
if (!conversationKey) {
|
||||
pendingConversationScrollRef.current = false;
|
||||
scrollToBottom(false, 4);
|
||||
return;
|
||||
}
|
||||
scrollToBottom(false, 8);
|
||||
if (!hasMessages) return;
|
||||
pendingConversationScrollRef.current = false;
|
||||
}, [conversationKey, hasMessages, messages, scrollToBottom]);
|
||||
|
||||
useEffect(() => cancelScheduledBottomScroll, [cancelScheduledBottomScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
const target = contentRef.current;
|
||||
if (!target || typeof ResizeObserver === "undefined") return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (!atBottom && Date.now() > forceBottomUntilRef.current) return;
|
||||
scrollToBottom(false, 4);
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => observer.disconnect();
|
||||
}, [atBottom, hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
@ -68,7 +136,7 @@ export function ThreadViewport({
|
||||
)}
|
||||
>
|
||||
{hasMessages ? (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div className="flex-1 px-4 pb-20 pt-4">
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages messages={messages} />
|
||||
@ -82,7 +150,7 @@ export function ThreadViewport({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
|
||||
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
|
||||
<div className="flex w-full max-w-[58rem] flex-col gap-6">
|
||||
{emptyState}
|
||||
@ -91,6 +159,7 @@ export function ThreadViewport({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} aria-hidden className="h-px" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { useClient } from "@/providers/ClientProvider";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
||||
import type { StreamError } from "@/lib/nanobot-client";
|
||||
import type {
|
||||
InboundEvent,
|
||||
@ -107,6 +108,59 @@ function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
|
||||
return prev;
|
||||
}
|
||||
|
||||
function isReasoningOnlyPlaceholder(message: UIMessage): boolean {
|
||||
return (
|
||||
message.role === "assistant"
|
||||
&& message.kind !== "trace"
|
||||
&& message.content.trim().length === 0
|
||||
&& !!message.reasoning
|
||||
&& !message.reasoningStreaming
|
||||
&& !message.media?.length
|
||||
);
|
||||
}
|
||||
|
||||
function isToolTrace(message: UIMessage | undefined): boolean {
|
||||
return message?.kind === "trace";
|
||||
}
|
||||
|
||||
function pruneReasoningOnlyPlaceholders(prev: UIMessage[]): UIMessage[] {
|
||||
return prev.filter((message, index) => {
|
||||
if (!isReasoningOnlyPlaceholder(message)) return true;
|
||||
// A reasoning-only assistant row immediately followed by tool traces is
|
||||
// the live equivalent of a persisted assistant tool-call message with
|
||||
// empty content, reasoning_content, and tool_calls. Keep it so live render
|
||||
// and history replay stay isomorphic.
|
||||
return isToolTrace(prev[index + 1]);
|
||||
});
|
||||
}
|
||||
|
||||
function absorbCompleteAssistantMessage(
|
||||
prev: UIMessage[],
|
||||
message: Omit<UIMessage, "id" | "role" | "createdAt">,
|
||||
): UIMessage[] {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last || !isReasoningOnlyPlaceholder(last)) {
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
createdAt: Date.now(),
|
||||
...message,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
...prev.slice(0, -1),
|
||||
{
|
||||
...last,
|
||||
...message,
|
||||
isStreaming: false,
|
||||
reasoningStreaming: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
|
||||
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||
@ -286,9 +340,10 @@ export function useNanobotStream(
|
||||
streamEndTimerRef.current = null;
|
||||
}
|
||||
setIsStreaming(false);
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
||||
);
|
||||
setMessages((prev) => {
|
||||
const finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
return pruneReasoningOnlyPlaceholders(finalized);
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
onTurnEnd?.();
|
||||
return;
|
||||
@ -314,14 +369,20 @@ export function useNanobotStream(
|
||||
// Attach them to the last trace row if it was the last emitted item
|
||||
// so a sequence of calls collapses into one compact trace group.
|
||||
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
||||
const line = ev.text;
|
||||
const structuredLines = toolTraceLinesFromEvents(ev.tool_events);
|
||||
const lines = structuredLines.length > 0
|
||||
? structuredLines
|
||||
: ev.text
|
||||
? [ev.text]
|
||||
: [];
|
||||
if (lines.length === 0) return;
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.kind === "trace" && !last.isStreaming) {
|
||||
const merged: UIMessage = {
|
||||
...last,
|
||||
traces: [...(last.traces ?? [last.content]), line],
|
||||
content: line,
|
||||
traces: [...(last.traces ?? [last.content]), ...lines],
|
||||
content: lines[lines.length - 1],
|
||||
};
|
||||
return [...prev.slice(0, -1), merged];
|
||||
}
|
||||
@ -331,8 +392,8 @@ export function useNanobotStream(
|
||||
id: crypto.randomUUID(),
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: line,
|
||||
traces: [line],
|
||||
content: lines[lines.length - 1],
|
||||
traces: lines,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
@ -354,16 +415,10 @@ export function useNanobotStream(
|
||||
setMessages((prev) => {
|
||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||
const content = ev.text;
|
||||
return [
|
||||
...filtered,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content,
|
||||
createdAt: Date.now(),
|
||||
...(hasMedia ? { media } : {}),
|
||||
},
|
||||
];
|
||||
return absorbCompleteAssistantMessage(filtered, {
|
||||
content,
|
||||
...(hasMedia ? { media } : {}),
|
||||
});
|
||||
});
|
||||
if (hasMedia) {
|
||||
suppressStreamUntilTurnEndRef.current = true;
|
||||
@ -395,7 +450,7 @@ export function useNanobotStream(
|
||||
|
||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
...pruneReasoningOnlyPlaceholders(prev),
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { deriveTitle } from "@/lib/format";
|
||||
import { toMediaAttachment } from "@/lib/media";
|
||||
import { formatToolCallTrace } from "@/lib/tool-traces";
|
||||
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||
|
||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||
@ -31,24 +32,6 @@ function reasoningFromHistory(message: HistoryMessage): string | undefined {
|
||||
return parts.length > 0 ? parts.join("\n\n") : undefined;
|
||||
}
|
||||
|
||||
function formatToolCallTrace(call: unknown): string | null {
|
||||
if (!call || typeof call !== "object") return null;
|
||||
const item = call as {
|
||||
name?: unknown;
|
||||
function?: { name?: unknown; arguments?: unknown };
|
||||
};
|
||||
const name =
|
||||
typeof item.function?.name === "string"
|
||||
? item.function.name
|
||||
: typeof item.name === "string"
|
||||
? item.name
|
||||
: "";
|
||||
if (!name) return null;
|
||||
const args = item.function?.arguments;
|
||||
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
|
||||
return `${name}()`;
|
||||
}
|
||||
|
||||
function toolTracesFromHistory(message: HistoryMessage): string[] {
|
||||
if (!Array.isArray(message.tool_calls)) return [];
|
||||
return message.tool_calls
|
||||
@ -133,23 +116,31 @@ export function useSessionHistory(key: string | null): {
|
||||
messages: UIMessage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
refresh: () => void;
|
||||
version: number;
|
||||
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
|
||||
* final text yet — the model was still processing when the page loaded. */
|
||||
hasPendingToolCalls: boolean;
|
||||
} {
|
||||
const { token } = useClient();
|
||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||
const refresh = useCallback(() => {
|
||||
setRefreshSeq((value) => value + 1);
|
||||
}, []);
|
||||
const [state, setState] = useState<{
|
||||
key: string | null;
|
||||
messages: UIMessage[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hasPendingToolCalls: boolean;
|
||||
version: number;
|
||||
}>({
|
||||
key: null,
|
||||
messages: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
version: 0,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -160,19 +151,23 @@ export function useSessionHistory(key: string | null): {
|
||||
loading: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
version: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
// Mark the new key as loading immediately so callers never see stale
|
||||
// messages from the previous session during the render right after a switch.
|
||||
setState({
|
||||
key,
|
||||
messages: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
});
|
||||
setState((prev) => prev.key === key
|
||||
? { ...prev, loading: true, error: null }
|
||||
: {
|
||||
key,
|
||||
messages: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
version: 0,
|
||||
});
|
||||
(async () => {
|
||||
try {
|
||||
const body = await fetchSessionMessages(token, key);
|
||||
@ -203,7 +198,9 @@ export function useSessionHistory(key: string | null): {
|
||||
: {}),
|
||||
};
|
||||
const traces = m.role === "assistant" ? toolTracesFromHistory(m) : [];
|
||||
if (traces.length === 0) return [row];
|
||||
if (traces.length === 0) {
|
||||
return row.content.trim() || row.media?.length ? [row] : [];
|
||||
}
|
||||
return [
|
||||
...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []),
|
||||
{
|
||||
@ -225,55 +222,74 @@ export function useSessionHistory(key: string | null): {
|
||||
lastRaw?.role === "assistant" &&
|
||||
Array.isArray(lastRaw.tool_calls) &&
|
||||
lastRaw.tool_calls.length > 0;
|
||||
setState({
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: ui,
|
||||
loading: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: hasPending,
|
||||
});
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
// A 404 just means the session hasn't been persisted yet (brand-new
|
||||
// chat, first message not sent). That's a normal state, not an error.
|
||||
if (e instanceof ApiError && e.status === 404) {
|
||||
setState({
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
hasPendingToolCalls: false,
|
||||
});
|
||||
version: prev.key === key ? prev.version + 1 : 1,
|
||||
}));
|
||||
} else {
|
||||
setState({
|
||||
setState((prev) => ({
|
||||
key,
|
||||
messages: [],
|
||||
loading: false,
|
||||
error: (e as Error).message,
|
||||
hasPendingToolCalls: false,
|
||||
});
|
||||
version: prev.key === key ? prev.version : 0,
|
||||
}));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [key, token]);
|
||||
}, [key, token, refreshSeq]);
|
||||
|
||||
if (!key) {
|
||||
return { messages: EMPTY_MESSAGES, loading: false, error: null, hasPendingToolCalls: false };
|
||||
return {
|
||||
messages: EMPTY_MESSAGES,
|
||||
loading: false,
|
||||
error: null,
|
||||
refresh,
|
||||
version: 0,
|
||||
hasPendingToolCalls: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Even before the effect above commits its loading state, never surface the
|
||||
// previous session's payload for a brand-new key.
|
||||
if (state.key !== key) {
|
||||
return { messages: EMPTY_MESSAGES, loading: true, error: null, hasPendingToolCalls: false };
|
||||
return {
|
||||
messages: EMPTY_MESSAGES,
|
||||
loading: true,
|
||||
error: null,
|
||||
refresh,
|
||||
version: 0,
|
||||
hasPendingToolCalls: false,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
loading: state.loading,
|
||||
error: state.error,
|
||||
refresh,
|
||||
version: state.version,
|
||||
hasPendingToolCalls: state.hasPendingToolCalls,
|
||||
};
|
||||
}
|
||||
|
||||
30
webui/src/lib/tool-traces.ts
Normal file
30
webui/src/lib/tool-traces.ts
Normal file
@ -0,0 +1,30 @@
|
||||
export function formatToolCallTrace(call: unknown): string | null {
|
||||
if (!call || typeof call !== "object") return null;
|
||||
const item = call as {
|
||||
name?: unknown;
|
||||
arguments?: unknown;
|
||||
function?: { name?: unknown; arguments?: unknown };
|
||||
};
|
||||
const name =
|
||||
typeof item.function?.name === "string"
|
||||
? item.function.name
|
||||
: typeof item.name === "string"
|
||||
? item.name
|
||||
: "";
|
||||
if (!name) return null;
|
||||
const args = item.function?.arguments ?? item.arguments;
|
||||
if (typeof args === "string" && args.trim()) return `${name}(${args})`;
|
||||
if (args && typeof args === "object") return `${name}(${JSON.stringify(args)})`;
|
||||
return `${name}()`;
|
||||
}
|
||||
|
||||
export function toolTraceLinesFromEvents(events: unknown): string[] {
|
||||
if (!Array.isArray(events)) return [];
|
||||
return events
|
||||
.filter((event) => {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
return (event as { phase?: unknown }).phase === "start";
|
||||
})
|
||||
.map(formatToolCallTrace)
|
||||
.filter((trace): trace is string => !!trace);
|
||||
}
|
||||
@ -53,6 +53,18 @@ export interface UIMessage {
|
||||
reasoningStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolProgressEvent {
|
||||
version?: number;
|
||||
phase?: "start" | "end" | "error" | string;
|
||||
call_id?: string;
|
||||
name?: string;
|
||||
arguments?: unknown;
|
||||
result?: unknown;
|
||||
error?: unknown;
|
||||
files?: unknown[];
|
||||
embeds?: unknown[];
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||
key: string;
|
||||
@ -146,6 +158,7 @@ export type InboundEvent =
|
||||
reply_to?: string;
|
||||
media?: string[];
|
||||
media_urls?: Array<{ url: string; name?: string }>;
|
||||
tool_events?: ToolProgressEvent[];
|
||||
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
||||
* generic progress line) rather than a conversational reply. */
|
||||
kind?: "tool_hint" | "progress" | "reasoning";
|
||||
|
||||
@ -342,6 +342,7 @@ describe("App layout", () => {
|
||||
chatId: "chat-alpha",
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
title: "Q2 roadmap",
|
||||
preview: "Project planning notes",
|
||||
},
|
||||
{
|
||||
@ -358,15 +359,22 @@ describe("App layout", () => {
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
|
||||
target: { value: "travel" },
|
||||
target: { value: "planning" },
|
||||
});
|
||||
|
||||
expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
|
||||
expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
|
||||
target: { value: "road q2" },
|
||||
});
|
||||
|
||||
expect(within(sidebar).getByText("Q2 roadmap")).toBeInTheDocument();
|
||||
expect(within(sidebar).queryByText("Travel ideas")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a blank start page without creating an empty chat", async () => {
|
||||
|
||||
@ -8,6 +8,7 @@ import { ClientProvider } from "@/providers/ClientProvider";
|
||||
function makeClient() {
|
||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
||||
return {
|
||||
status: "open" as const,
|
||||
defaultChatId: null as string | null,
|
||||
@ -30,12 +31,21 @@ function makeClient() {
|
||||
errorHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
onSessionUpdate: (handler: (chatId: string) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => {
|
||||
sessionUpdateHandlers.delete(handler);
|
||||
};
|
||||
},
|
||||
_emitError(err: { kind: string }) {
|
||||
for (const h of errorHandlers) h(err);
|
||||
},
|
||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||
},
|
||||
_emitSessionUpdate(chatId: string) {
|
||||
for (const h of sessionUpdateHandlers) h(chatId);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
@ -573,6 +583,134 @@ describe("ThreadShell", () => {
|
||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("replaces live streamed content with canonical history after turn end", async () => {
|
||||
const client = makeClient();
|
||||
let historyCalls = 0;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/messages")) {
|
||||
historyCalls += 1;
|
||||
return httpJson({
|
||||
key: "websocket:chat-a",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
messages: historyCalls === 1
|
||||
? [{ role: "user", content: "question" }]
|
||||
: [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "canonical markdown answer" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||
await act(async () => {
|
||||
client._emitChat("chat-a", {
|
||||
event: "delta",
|
||||
chat_id: "chat-a",
|
||||
text: "live half-parsed | markdown",
|
||||
});
|
||||
client._emitChat("chat-a", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-a",
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("canonical markdown answer")).toBeInTheDocument());
|
||||
expect(screen.queryByText("live half-parsed | markdown")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls to the bottom after loading a session from the blank new-chat page", async () => {
|
||||
const client = makeClient();
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes("websocket%3Achat-a/messages")) {
|
||||
return httpJson({
|
||||
key: "websocket:chat-a",
|
||||
created_at: null,
|
||||
updated_at: null,
|
||||
messages: [
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: "loaded answer" },
|
||||
],
|
||||
});
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
const { rerender } = render(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={null}
|
||||
title="nanobot"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
expect(screen.getByText("What can I do for you?")).toBeInTheDocument();
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
await act(async () => {
|
||||
rerender(
|
||||
wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session("chat-a")}
|
||||
title="Chat chat-a"
|
||||
onToggleSidebar={() => {}}
|
||||
onNewChat={() => {}}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText("loaded answer")).toBeInTheDocument());
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "smooth",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it("opens slash commands on the blank welcome page", async () => {
|
||||
const client = makeClient();
|
||||
vi.stubGlobal(
|
||||
|
||||
164
webui/src/tests/thread-viewport.test.tsx
Normal file
164
webui/src/tests/thread-viewport.test.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { act, render, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ThreadViewport } from "@/components/thread/ThreadViewport";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "hello",
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
|
||||
const emptyMessages: UIMessage[] = [];
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("resets to the bottom when opening a different conversation", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-b"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey={null}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 0,
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
});
|
||||
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
});
|
||||
|
||||
it("scrolls to the bottom when explicitly signalled after send", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
scrollToBottomSignal={0}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
scrollToBottomSignal={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "smooth",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -113,6 +113,43 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[1].kind).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders live tool traces from structured tool events", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-tool-events", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-tool-events", {
|
||||
event: "message",
|
||||
chat_id: "chat-tool-events",
|
||||
text: 'search "hermes"',
|
||||
kind: "tool_hint",
|
||||
tool_events: [
|
||||
{
|
||||
phase: "start",
|
||||
name: "web_search",
|
||||
arguments: { query: "NousResearch hermes-agent", count: 8 },
|
||||
},
|
||||
{
|
||||
phase: "start",
|
||||
name: "web_search",
|
||||
arguments: { query: "hermes-agent GitHub stars", count: 8 },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].traces).toEqual([
|
||||
'web_search({"query":"NousResearch hermes-agent","count":8})',
|
||||
'web_search({"query":"hermes-agent GitHub stars","count":8})',
|
||||
]);
|
||||
expect(result.current.messages[0].content).toBe(
|
||||
'web_search({"query":"hermes-agent GitHub stars","count":8})',
|
||||
);
|
||||
});
|
||||
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
@ -315,6 +352,148 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[2].reasoning).toBe("Second reasoning.");
|
||||
});
|
||||
|
||||
it("keeps tool-call reasoning before the matching live tool trace", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-tool-reasoning", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-tool-reasoning", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-tool-reasoning",
|
||||
text: "I should search first.",
|
||||
});
|
||||
fake.emit("chat-tool-reasoning", {
|
||||
event: "reasoning_end",
|
||||
chat_id: "chat-tool-reasoning",
|
||||
});
|
||||
fake.emit("chat-tool-reasoning", {
|
||||
event: "message",
|
||||
chat_id: "chat-tool-reasoning",
|
||||
text: "web_search({\"query\":\"hermes\"})",
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-tool-reasoning", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-tool-reasoning",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I should search first.",
|
||||
reasoningStreaming: false,
|
||||
isStreaming: false,
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ["web_search({\"query\":\"hermes\"})"],
|
||||
});
|
||||
});
|
||||
|
||||
it("absorbs non-streamed final answers into the preceding reasoning placeholder", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-final-reasoning", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-final-reasoning", {
|
||||
event: "message",
|
||||
chat_id: "chat-final-reasoning",
|
||||
text: "web_search({\"query\":\"hermes\"})",
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-final-reasoning", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-final-reasoning",
|
||||
text: "Got results; now summarize.",
|
||||
});
|
||||
fake.emit("chat-final-reasoning", {
|
||||
event: "reasoning_end",
|
||||
chat_id: "chat-final-reasoning",
|
||||
});
|
||||
fake.emit("chat-final-reasoning", {
|
||||
event: "message",
|
||||
chat_id: "chat-final-reasoning",
|
||||
text: "Hermes is an open-source agent project.",
|
||||
});
|
||||
fake.emit("chat-final-reasoning", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-final-reasoning",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Hermes is an open-source agent project.",
|
||||
reasoning: "Got results; now summarize.",
|
||||
reasoningStreaming: false,
|
||||
isStreaming: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("prunes reasoning-only placeholders when a turn ends without an answer", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-empty-thinking", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-empty-thinking", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-empty-thinking",
|
||||
text: "thinking without final text",
|
||||
});
|
||||
fake.emit("chat-empty-thinking", {
|
||||
event: "reasoning_end",
|
||||
chat_id: "chat-empty-thinking",
|
||||
});
|
||||
fake.emit("chat-empty-thinking", {
|
||||
event: "turn_end",
|
||||
chat_id: "chat-empty-thinking",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops stale reasoning-only placeholders before sending the next user turn", () => {
|
||||
const fake = fakeClient();
|
||||
const initialMessages = [
|
||||
{
|
||||
id: "stale-thinking",
|
||||
role: "assistant" as const,
|
||||
content: "",
|
||||
reasoning: "leftover thinking",
|
||||
reasoningStreaming: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
const { result } = renderHook(
|
||||
() => useNanobotStream("chat-stale-thinking", initialMessages),
|
||||
{ wrapper: wrap(fake.client) },
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.send("fine");
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("user");
|
||||
expect(result.current.messages[0].content).toBe("fine");
|
||||
});
|
||||
|
||||
it("attaches assistant media_urls to complete messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
|
||||
|
||||
@ -245,6 +245,30 @@ describe("useSessions", () => {
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("drops replayed assistant turns that only contain reasoning", async () => {
|
||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
||||
key: "websocket:chat-empty-reasoning",
|
||||
created_at: "2026-04-20T10:00:00Z",
|
||||
updated_at: "2026-04-20T10:05:00Z",
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: "",
|
||||
timestamp: "2026-04-20T10:00:01Z",
|
||||
reasoning_content: "orphan reasoning",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSessionHistory("websocket:chat-empty-reasoning"), {
|
||||
wrapper: wrap(fakeClient()),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("hydrates historical assistant tool calls into a replay trace row", async () => {
|
||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
||||
key: "websocket:chat-tools",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user