mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-21 00:52:34 +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
|
payload["media_urls"] = urls
|
||||||
if msg.reply_to:
|
if msg.reply_to:
|
||||||
payload["reply_to"] = 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
|
# Mark intermediate agent breadcrumbs (tool-call hints, generic
|
||||||
# progress strings) so WS clients can render them as subordinate
|
# progress strings) so WS clients can render them as subordinate
|
||||||
# trace rows rather than conversational replies.
|
# 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
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_send_delta_removes_connection_on_connection_closed() -> None:
|
async def test_send_delta_removes_connection_on_connection_closed() -> None:
|
||||||
bus = MagicMock()
|
bus = MagicMock()
|
||||||
|
|||||||
@ -31,8 +31,10 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
const normalizedQuery = query.trim().toLowerCase();
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
const filteredSessions = useMemo(() => {
|
const filteredSessions = useMemo(() => {
|
||||||
if (!normalizedQuery) return props.sessions;
|
if (!normalizedQuery) return props.sessions;
|
||||||
|
const terms = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||||
return props.sessions.filter((session) => {
|
return props.sessions.filter((session) => {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
|
session.title,
|
||||||
session.preview,
|
session.preview,
|
||||||
session.chatId,
|
session.chatId,
|
||||||
session.channel,
|
session.channel,
|
||||||
@ -41,7 +43,7 @@ export function Sidebar(props: SidebarProps) {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" ")
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return haystack.includes(normalizedQuery);
|
return terms.every((term) => haystack.includes(term));
|
||||||
});
|
});
|
||||||
}, [normalizedQuery, props.sessions]);
|
}, [normalizedQuery, props.sessions]);
|
||||||
|
|
||||||
|
|||||||
@ -81,19 +81,33 @@ export function ThreadShell({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const chatId = session?.chatId ?? null;
|
const chatId = session?.chatId ?? null;
|
||||||
const historyKey = session?.key ?? null;
|
const historyKey = session?.key ?? null;
|
||||||
const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey);
|
const {
|
||||||
const { modelName, token } = useClient();
|
messages: historical,
|
||||||
|
loading,
|
||||||
|
hasPendingToolCalls,
|
||||||
|
refresh: refreshHistory,
|
||||||
|
version: historyVersion,
|
||||||
|
} = useSessionHistory(historyKey);
|
||||||
|
const { client, modelName, token } = useClient();
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const [heroImageMode, setHeroImageMode] = useState(false);
|
const [heroImageMode, setHeroImageMode] = useState(false);
|
||||||
|
const [scrollToBottomSignal, setScrollToBottomSignal] = useState(0);
|
||||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||||
const lastCachedChatIdRef = useRef<string | null>(null);
|
const lastCachedChatIdRef = useRef<string | null>(null);
|
||||||
|
const appliedHistoryVersionRef = useRef<Map<string, number>>(new Map());
|
||||||
|
const pendingCanonicalHydrateRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
const initial = useMemo(() => {
|
const initial = useMemo(() => {
|
||||||
if (!chatId) return historical;
|
if (!chatId) return historical;
|
||||||
return messageCacheRef.current.get(chatId) ?? historical;
|
return messageCacheRef.current.get(chatId) ?? historical;
|
||||||
}, [chatId, historical]);
|
}, [chatId, historical]);
|
||||||
|
const handleTurnEnd = useCallback(() => {
|
||||||
|
if (chatId) pendingCanonicalHydrateRef.current.add(chatId);
|
||||||
|
refreshHistory();
|
||||||
|
onTurnEnd?.();
|
||||||
|
}, [chatId, onTurnEnd, refreshHistory]);
|
||||||
const {
|
const {
|
||||||
messages,
|
messages,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
@ -102,22 +116,48 @@ export function ThreadShell({
|
|||||||
setMessages,
|
setMessages,
|
||||||
streamError,
|
streamError,
|
||||||
dismissStreamError,
|
dismissStreamError,
|
||||||
} = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
|
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
|
||||||
const showHeroComposer = messages.length === 0 && !loading;
|
const showHeroComposer = messages.length === 0 && !loading;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chatId || loading) return;
|
if (!chatId || loading) return;
|
||||||
const cached = messageCacheRef.current.get(chatId);
|
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
|
// When the user switches away and back, keep the local in-memory thread
|
||||||
// state (including not-yet-persisted messages) instead of replacing it with
|
// 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) => {
|
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 (cached && cached.length > 0) return cached;
|
||||||
if (historical.length === 0 && prev.length > 0) return prev;
|
if (historical.length === 0 && prev.length > 0) return prev;
|
||||||
|
appliedHistoryVersionRef.current.set(chatId, historyVersion);
|
||||||
return historical;
|
return historical;
|
||||||
});
|
});
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// 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(() => {
|
useEffect(() => {
|
||||||
if (chatId) return;
|
if (chatId) return;
|
||||||
@ -148,6 +188,7 @@ export function ThreadShell({
|
|||||||
const pending = pendingFirstRef.current;
|
const pending = pendingFirstRef.current;
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
pendingFirstRef.current = null;
|
pendingFirstRef.current = null;
|
||||||
|
setScrollToBottomSignal((value) => value + 1);
|
||||||
send(pending.content, pending.images, pending.options);
|
send(pending.content, pending.images, pending.options);
|
||||||
setBooting(false);
|
setBooting(false);
|
||||||
}, [chatId, send]);
|
}, [chatId, send]);
|
||||||
@ -181,18 +222,26 @@ export function ThreadShell({
|
|||||||
[booting, onCreateChat],
|
[booting, onCreateChat],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleThreadSend = useCallback(
|
||||||
|
(content: string, images?: SendImage[], options?: SendOptions) => {
|
||||||
|
setScrollToBottomSignal((value) => value + 1);
|
||||||
|
send(content, images, options);
|
||||||
|
},
|
||||||
|
[send],
|
||||||
|
);
|
||||||
|
|
||||||
const handleQuickAction = useCallback(
|
const handleQuickAction = useCallback(
|
||||||
(prompt: string) => {
|
(prompt: string) => {
|
||||||
const options: SendOptions | undefined = heroImageMode
|
const options: SendOptions | undefined = heroImageMode
|
||||||
? { imageGeneration: { enabled: true, aspect_ratio: null } }
|
? { imageGeneration: { enabled: true, aspect_ratio: null } }
|
||||||
: undefined;
|
: undefined;
|
||||||
if (session) {
|
if (session) {
|
||||||
send(prompt, undefined, options);
|
handleThreadSend(prompt, undefined, options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void handleWelcomeSend(prompt, undefined, options);
|
void handleWelcomeSend(prompt, undefined, options);
|
||||||
},
|
},
|
||||||
[handleWelcomeSend, heroImageMode, send, session],
|
[handleThreadSend, handleWelcomeSend, heroImageMode, session],
|
||||||
);
|
);
|
||||||
|
|
||||||
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
|
const quickActionItems = heroImageMode ? IMAGE_QUICK_ACTION_KEYS : QUICK_ACTION_KEYS;
|
||||||
@ -233,7 +282,7 @@ export function ThreadShell({
|
|||||||
) : null}
|
) : null}
|
||||||
{session ? (
|
{session ? (
|
||||||
<ThreadComposer
|
<ThreadComposer
|
||||||
onSend={send}
|
onSend={handleThreadSend}
|
||||||
disabled={!chatId}
|
disabled={!chatId}
|
||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
placeholder={
|
placeholder={
|
||||||
@ -296,6 +345,8 @@ export function ThreadShell({
|
|||||||
isStreaming={isStreaming}
|
isStreaming={isStreaming}
|
||||||
emptyState={emptyState}
|
emptyState={emptyState}
|
||||||
composer={composer}
|
composer={composer}
|
||||||
|
scrollToBottomSignal={scrollToBottomSignal}
|
||||||
|
conversationKey={historyKey}
|
||||||
/>
|
/>
|
||||||
</section>
|
</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 { ArrowDown } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
@ -12,6 +12,8 @@ interface ThreadViewportProps {
|
|||||||
isStreaming: boolean;
|
isStreaming: boolean;
|
||||||
composer: ReactNode;
|
composer: ReactNode;
|
||||||
emptyState?: ReactNode;
|
emptyState?: ReactNode;
|
||||||
|
scrollToBottomSignal?: number;
|
||||||
|
conversationKey?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NEAR_BOTTOM_PX = 48;
|
const NEAR_BOTTOM_PX = 48;
|
||||||
@ -21,26 +23,92 @@ export function ThreadViewport({
|
|||||||
isStreaming,
|
isStreaming,
|
||||||
composer,
|
composer,
|
||||||
emptyState,
|
emptyState,
|
||||||
|
scrollToBottomSignal = 0,
|
||||||
|
conversationKey = null,
|
||||||
}: ThreadViewportProps) {
|
}: ThreadViewportProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
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 [atBottom, setAtBottom] = useState(true);
|
||||||
const hasMessages = messages.length > 0;
|
const hasMessages = messages.length > 0;
|
||||||
|
|
||||||
const scrollToBottom = useCallback((smooth = false) => {
|
const cancelScheduledBottomScroll = useCallback(() => {
|
||||||
const el = scrollRef.current;
|
for (const id of scrollFrameIdsRef.current) {
|
||||||
if (!el) return;
|
window.cancelAnimationFrame(id);
|
||||||
el.scrollTo({
|
}
|
||||||
top: el.scrollHeight,
|
scrollFrameIdsRef.current = [];
|
||||||
behavior: smooth ? "smooth" : "auto",
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
if (!atBottom) return;
|
if (!atBottom) return;
|
||||||
scrollToBottom(!isStreaming);
|
scrollToBottom(!isStreaming);
|
||||||
}, [messages, isStreaming, atBottom, scrollToBottom]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@ -68,7 +136,7 @@ export function ThreadViewport({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasMessages ? (
|
{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="flex-1 px-4 pb-20 pt-4">
|
||||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||||
<ThreadMessages messages={messages} />
|
<ThreadMessages messages={messages} />
|
||||||
@ -82,7 +150,7 @@ export function ThreadViewport({
|
|||||||
</div>
|
</div>
|
||||||
</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 flex-1 items-center justify-center pb-[7vh] pt-8">
|
||||||
<div className="flex w-full max-w-[58rem] flex-col gap-6">
|
<div className="flex w-full max-w-[58rem] flex-col gap-6">
|
||||||
{emptyState}
|
{emptyState}
|
||||||
@ -91,6 +159,7 @@ export function ThreadViewport({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div ref={bottomRef} aria-hidden className="h-px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|||||||
|
|
||||||
import { useClient } from "@/providers/ClientProvider";
|
import { useClient } from "@/providers/ClientProvider";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
|
import { toolTraceLinesFromEvents } from "@/lib/tool-traces";
|
||||||
import type { StreamError } from "@/lib/nanobot-client";
|
import type { StreamError } from "@/lib/nanobot-client";
|
||||||
import type {
|
import type {
|
||||||
InboundEvent,
|
InboundEvent,
|
||||||
@ -107,6 +108,59 @@ function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
|
|||||||
return prev;
|
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,
|
* 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
|
* a streaming flag, and a ``send`` function. Initial history must be seeded
|
||||||
@ -286,9 +340,10 @@ export function useNanobotStream(
|
|||||||
streamEndTimerRef.current = null;
|
streamEndTimerRef.current = null;
|
||||||
}
|
}
|
||||||
setIsStreaming(false);
|
setIsStreaming(false);
|
||||||
setMessages((prev) =>
|
setMessages((prev) => {
|
||||||
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
|
const finalized = prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||||
);
|
return pruneReasoningOnlyPlaceholders(finalized);
|
||||||
|
});
|
||||||
suppressStreamUntilTurnEndRef.current = false;
|
suppressStreamUntilTurnEndRef.current = false;
|
||||||
onTurnEnd?.();
|
onTurnEnd?.();
|
||||||
return;
|
return;
|
||||||
@ -314,14 +369,20 @@ export function useNanobotStream(
|
|||||||
// Attach them to the last trace row if it was the last emitted item
|
// 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.
|
// so a sequence of calls collapses into one compact trace group.
|
||||||
if (ev.kind === "tool_hint" || ev.kind === "progress") {
|
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) => {
|
setMessages((prev) => {
|
||||||
const last = prev[prev.length - 1];
|
const last = prev[prev.length - 1];
|
||||||
if (last && last.kind === "trace" && !last.isStreaming) {
|
if (last && last.kind === "trace" && !last.isStreaming) {
|
||||||
const merged: UIMessage = {
|
const merged: UIMessage = {
|
||||||
...last,
|
...last,
|
||||||
traces: [...(last.traces ?? [last.content]), line],
|
traces: [...(last.traces ?? [last.content]), ...lines],
|
||||||
content: line,
|
content: lines[lines.length - 1],
|
||||||
};
|
};
|
||||||
return [...prev.slice(0, -1), merged];
|
return [...prev.slice(0, -1), merged];
|
||||||
}
|
}
|
||||||
@ -331,8 +392,8 @@ export function useNanobotStream(
|
|||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: "tool",
|
role: "tool",
|
||||||
kind: "trace",
|
kind: "trace",
|
||||||
content: line,
|
content: lines[lines.length - 1],
|
||||||
traces: [line],
|
traces: lines,
|
||||||
createdAt: Date.now(),
|
createdAt: Date.now(),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@ -354,16 +415,10 @@ export function useNanobotStream(
|
|||||||
setMessages((prev) => {
|
setMessages((prev) => {
|
||||||
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
const filtered = activeId ? prev.filter((m) => m.id !== activeId) : prev;
|
||||||
const content = ev.text;
|
const content = ev.text;
|
||||||
return [
|
return absorbCompleteAssistantMessage(filtered, {
|
||||||
...filtered,
|
content,
|
||||||
{
|
...(hasMedia ? { media } : {}),
|
||||||
id: crypto.randomUUID(),
|
});
|
||||||
role: "assistant",
|
|
||||||
content,
|
|
||||||
createdAt: Date.now(),
|
|
||||||
...(hasMedia ? { media } : {}),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
});
|
||||||
if (hasMedia) {
|
if (hasMedia) {
|
||||||
suppressStreamUntilTurnEndRef.current = true;
|
suppressStreamUntilTurnEndRef.current = true;
|
||||||
@ -395,7 +450,7 @@ export function useNanobotStream(
|
|||||||
|
|
||||||
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
|
||||||
setMessages((prev) => [
|
setMessages((prev) => [
|
||||||
...prev,
|
...pruneReasoningOnlyPlaceholders(prev),
|
||||||
{
|
{
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
role: "user",
|
role: "user",
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
import { deriveTitle } from "@/lib/format";
|
import { deriveTitle } from "@/lib/format";
|
||||||
import { toMediaAttachment } from "@/lib/media";
|
import { toMediaAttachment } from "@/lib/media";
|
||||||
|
import { formatToolCallTrace } from "@/lib/tool-traces";
|
||||||
import type { ChatSummary, UIMessage } from "@/lib/types";
|
import type { ChatSummary, UIMessage } from "@/lib/types";
|
||||||
|
|
||||||
const EMPTY_MESSAGES: UIMessage[] = [];
|
const EMPTY_MESSAGES: UIMessage[] = [];
|
||||||
@ -31,24 +32,6 @@ function reasoningFromHistory(message: HistoryMessage): string | undefined {
|
|||||||
return parts.length > 0 ? parts.join("\n\n") : 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[] {
|
function toolTracesFromHistory(message: HistoryMessage): string[] {
|
||||||
if (!Array.isArray(message.tool_calls)) return [];
|
if (!Array.isArray(message.tool_calls)) return [];
|
||||||
return message.tool_calls
|
return message.tool_calls
|
||||||
@ -133,23 +116,31 @@ export function useSessionHistory(key: string | null): {
|
|||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
refresh: () => void;
|
||||||
|
version: number;
|
||||||
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
|
/** ``true`` when the last persisted assistant turn has ``tool_calls`` but no
|
||||||
* final text yet — the model was still processing when the page loaded. */
|
* final text yet — the model was still processing when the page loaded. */
|
||||||
hasPendingToolCalls: boolean;
|
hasPendingToolCalls: boolean;
|
||||||
} {
|
} {
|
||||||
const { token } = useClient();
|
const { token } = useClient();
|
||||||
|
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
setRefreshSeq((value) => value + 1);
|
||||||
|
}, []);
|
||||||
const [state, setState] = useState<{
|
const [state, setState] = useState<{
|
||||||
key: string | null;
|
key: string | null;
|
||||||
messages: UIMessage[];
|
messages: UIMessage[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
hasPendingToolCalls: boolean;
|
hasPendingToolCalls: boolean;
|
||||||
|
version: number;
|
||||||
}>({
|
}>({
|
||||||
key: null,
|
key: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
version: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -160,19 +151,23 @@ export function useSessionHistory(key: string | null): {
|
|||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
|
version: 0,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
// Mark the new key as loading immediately so callers never see stale
|
// Mark the new key as loading immediately so callers never see stale
|
||||||
// messages from the previous session during the render right after a switch.
|
// messages from the previous session during the render right after a switch.
|
||||||
setState({
|
setState((prev) => prev.key === key
|
||||||
key,
|
? { ...prev, loading: true, error: null }
|
||||||
messages: [],
|
: {
|
||||||
loading: true,
|
key,
|
||||||
error: null,
|
messages: [],
|
||||||
hasPendingToolCalls: false,
|
loading: true,
|
||||||
});
|
error: null,
|
||||||
|
hasPendingToolCalls: false,
|
||||||
|
version: 0,
|
||||||
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const body = await fetchSessionMessages(token, key);
|
const body = await fetchSessionMessages(token, key);
|
||||||
@ -203,7 +198,9 @@ export function useSessionHistory(key: string | null): {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
const traces = m.role === "assistant" ? toolTracesFromHistory(m) : [];
|
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 [
|
return [
|
||||||
...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []),
|
...(row.content.trim() || row.reasoning || row.media?.length ? [row] : []),
|
||||||
{
|
{
|
||||||
@ -225,55 +222,74 @@ export function useSessionHistory(key: string | null): {
|
|||||||
lastRaw?.role === "assistant" &&
|
lastRaw?.role === "assistant" &&
|
||||||
Array.isArray(lastRaw.tool_calls) &&
|
Array.isArray(lastRaw.tool_calls) &&
|
||||||
lastRaw.tool_calls.length > 0;
|
lastRaw.tool_calls.length > 0;
|
||||||
setState({
|
setState((prev) => ({
|
||||||
key,
|
key,
|
||||||
messages: ui,
|
messages: ui,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: hasPending,
|
hasPendingToolCalls: hasPending,
|
||||||
});
|
version: prev.key === key ? prev.version + 1 : 1,
|
||||||
|
}));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
// A 404 just means the session hasn't been persisted yet (brand-new
|
// 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.
|
// chat, first message not sent). That's a normal state, not an error.
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
setState({
|
setState((prev) => ({
|
||||||
key,
|
key,
|
||||||
messages: [],
|
messages: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
});
|
version: prev.key === key ? prev.version + 1 : 1,
|
||||||
|
}));
|
||||||
} else {
|
} else {
|
||||||
setState({
|
setState((prev) => ({
|
||||||
key,
|
key,
|
||||||
messages: [],
|
messages: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: (e as Error).message,
|
error: (e as Error).message,
|
||||||
hasPendingToolCalls: false,
|
hasPendingToolCalls: false,
|
||||||
});
|
version: prev.key === key ? prev.version : 0,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [key, token]);
|
}, [key, token, refreshSeq]);
|
||||||
|
|
||||||
if (!key) {
|
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
|
// Even before the effect above commits its loading state, never surface the
|
||||||
// previous session's payload for a brand-new key.
|
// previous session's payload for a brand-new key.
|
||||||
if (state.key !== 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 {
|
return {
|
||||||
messages: state.messages,
|
messages: state.messages,
|
||||||
loading: state.loading,
|
loading: state.loading,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
refresh,
|
||||||
|
version: state.version,
|
||||||
hasPendingToolCalls: state.hasPendingToolCalls,
|
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;
|
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 {
|
export interface ChatSummary {
|
||||||
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
/** Server-side session key, e.g. ``websocket:abcd-...``. */
|
||||||
key: string;
|
key: string;
|
||||||
@ -146,6 +158,7 @@ export type InboundEvent =
|
|||||||
reply_to?: string;
|
reply_to?: string;
|
||||||
media?: string[];
|
media?: string[];
|
||||||
media_urls?: Array<{ url: string; name?: string }>;
|
media_urls?: Array<{ url: string; name?: string }>;
|
||||||
|
tool_events?: ToolProgressEvent[];
|
||||||
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
/** Present when the frame is an agent breadcrumb (e.g. tool hint,
|
||||||
* generic progress line) rather than a conversational reply. */
|
* generic progress line) rather than a conversational reply. */
|
||||||
kind?: "tool_hint" | "progress" | "reasoning";
|
kind?: "tool_hint" | "progress" | "reasoning";
|
||||||
|
|||||||
@ -342,6 +342,7 @@ describe("App layout", () => {
|
|||||||
chatId: "chat-alpha",
|
chatId: "chat-alpha",
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
|
title: "Q2 roadmap",
|
||||||
preview: "Project planning notes",
|
preview: "Project planning notes",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -358,15 +359,22 @@ describe("App layout", () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
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();
|
expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), {
|
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("Q2 roadmap")).toBeInTheDocument();
|
||||||
expect(within(sidebar).getByText("Travel ideas")).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 () => {
|
it("opens a blank start page without creating an empty chat", async () => {
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { ClientProvider } from "@/providers/ClientProvider";
|
|||||||
function makeClient() {
|
function makeClient() {
|
||||||
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
const errorHandlers = new Set<(err: { kind: string }) => void>();
|
||||||
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
const chatHandlers = new Map<string, Set<(ev: import("@/lib/types").InboundEvent) => void>>();
|
||||||
|
const sessionUpdateHandlers = new Set<(chatId: string) => void>();
|
||||||
return {
|
return {
|
||||||
status: "open" as const,
|
status: "open" as const,
|
||||||
defaultChatId: null as string | null,
|
defaultChatId: null as string | null,
|
||||||
@ -30,12 +31,21 @@ function makeClient() {
|
|||||||
errorHandlers.delete(handler);
|
errorHandlers.delete(handler);
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
onSessionUpdate: (handler: (chatId: string) => void) => {
|
||||||
|
sessionUpdateHandlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
sessionUpdateHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
},
|
||||||
_emitError(err: { kind: string }) {
|
_emitError(err: { kind: string }) {
|
||||||
for (const h of errorHandlers) h(err);
|
for (const h of errorHandlers) h(err);
|
||||||
},
|
},
|
||||||
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
_emitChat(chatId: string, ev: import("@/lib/types").InboundEvent) {
|
||||||
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
for (const h of chatHandlers.get(chatId) ?? []) h(ev);
|
||||||
},
|
},
|
||||||
|
_emitSessionUpdate(chatId: string) {
|
||||||
|
for (const h of sessionUpdateHandlers) h(chatId);
|
||||||
|
},
|
||||||
sendMessage: vi.fn(),
|
sendMessage: vi.fn(),
|
||||||
newChat: vi.fn(),
|
newChat: vi.fn(),
|
||||||
attach: vi.fn(),
|
attach: vi.fn(),
|
||||||
@ -573,6 +583,134 @@ describe("ThreadShell", () => {
|
|||||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
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 () => {
|
it("opens slash commands on the blank welcome page", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
vi.stubGlobal(
|
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();
|
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", () => {
|
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||||
@ -315,6 +352,148 @@ describe("useNanobotStream", () => {
|
|||||||
expect(result.current.messages[2].reasoning).toBe("Second reasoning.");
|
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", () => {
|
it("attaches assistant media_urls to complete messages", () => {
|
||||||
const fake = fakeClient();
|
const fake = fakeClient();
|
||||||
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
|
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
|
||||||
|
|||||||
@ -245,6 +245,30 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
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 () => {
|
it("hydrates historical assistant tool calls into a replay trace row", async () => {
|
||||||
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
vi.mocked(api.fetchSessionMessages).mockResolvedValue({
|
||||||
key: "websocket:chat-tools",
|
key: "websocket:chat-tools",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user