feat(reasoning): stream reasoning content as a first-class channel
Reasoning now flows as its own stream — symmetric to the answer's ``delta`` / ``stream_end`` pair — instead of being shipped as one oversized progress message. This lets WebUI render a live "Thinking…" bubble that updates in place, then auto-collapses when the stream closes. Other channels remain plugin no-ops by default. ## Protocol New metadata: ``_reasoning_delta`` (chunk) and ``_reasoning_end`` (close marker). ChannelManager routes both to the dedicated plugin hooks below; the legacy one-shot ``_reasoning`` is kept for back-compat and BaseChannel expands it into a single delta + end pair so plugins only ever implement the streaming primitives. WebSocket emits two new events: - ``reasoning_delta`` (event, chat_id, text, optional stream_id) - ``reasoning_end`` (event, chat_id, optional stream_id) ## BaseChannel surface - ``send_reasoning_delta(chat_id, delta, metadata)`` — no-op default - ``send_reasoning_end(chat_id, metadata)`` — no-op default - ``send_reasoning(msg)`` — back-compat wrapper, base impl forwards to the streaming primitives A channel adds reasoning support by overriding the two streaming primitives. Telegram / Slack / Discord / Feishu / WeChat / Matrix keep the base no-ops until their bubble UIs are adapted; reasoning silently drops at dispatch, never as a stray text message. ## AgentHook Adds ``emit_reasoning_end`` to the hook lifecycle. ``_LoopHook`` tracks whether a reasoning segment is open and closes it on: - the first answer delta arriving (so the UI locks the bubble before the answer renders below), - ``on_stream_end``, - one-shot ``reasoning_content`` / ``thinking_blocks`` after a single non-streaming response. ## WebUI - ``UIMessage.reasoning`` is now a single accumulated string with a companion ``reasoningStreaming`` flag. - ``useNanobotStream`` consumes ``reasoning_delta`` / ``reasoning_end``; legacy ``kind: "reasoning"`` is auto-translated to a delta + end. - New ``ReasoningBubble``: shimmer header + auto-expanded while streaming, collapses to a clickable "Thinking" pill once closed, respects ``prefers-reduced-motion``. - Answer deltas adopt the reasoning placeholder so the bubble and the answer share one assistant row. ## Tests - ``tests/channels/test_channel_manager_reasoning.py`` — manager routes delta + end, drops on channel opt-out, expands one-shot back-compat. - ``tests/channels/test_websocket_channel.py`` — new ``reasoning_delta`` / ``reasoning_end`` frames, empty-chunk safety, no-subscriber safety, back-compat expansion. - ``tests/agent/test_runner_reasoning.py`` — runner closes the segment on streaming answer start and after one-shot reasoning. - WebUI ``useNanobotStream`` + ``message-bubble`` cover the new protocol and the shimmer styling. ## Docs ``docs/configuration.md`` and ``docs/websocket.md`` document the new events and the plugin contract. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Sparkles, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -85,12 +85,16 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
|
||||
const empty = message.content.trim().length === 0;
|
||||
const media = message.media ?? [];
|
||||
const reasoning = message.role === "assistant" ? message.reasoning ?? [] : [];
|
||||
const reasoning = message.role === "assistant" ? message.reasoning ?? "" : "";
|
||||
const reasoningStreaming = !!(message.role === "assistant" && message.reasoningStreaming);
|
||||
const hasReasoning = reasoning.length > 0 || reasoningStreaming;
|
||||
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
|
||||
return (
|
||||
<div className={cn("w-full text-[15px]", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{reasoning.length > 0 ? <ReasoningBubble lines={reasoning} /> : null}
|
||||
{empty && message.isStreaming && reasoning.length === 0 ? (
|
||||
{hasReasoning ? (
|
||||
<ReasoningBubble text={reasoning} streaming={reasoningStreaming} />
|
||||
) : null}
|
||||
{empty && message.isStreaming && !hasReasoning ? (
|
||||
<TypingDots />
|
||||
) : empty && message.isStreaming ? null : (
|
||||
<>
|
||||
@@ -437,33 +441,52 @@ function TraceGroup({ message, animClass }: TraceGroupProps) {
|
||||
}
|
||||
|
||||
interface ReasoningBubbleProps {
|
||||
lines: string[];
|
||||
text: string;
|
||||
streaming: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subordinate "thinking" trace shown above an assistant turn. Mirrors the
|
||||
* CLI's italic dim ``ChevronRight`` row visually; collapsible because
|
||||
* reasoning from models like DeepSeek-R1 / o-series can run long. Defaults
|
||||
* to expanded while the answer is still streaming (so the user sees the
|
||||
* model "thinking out loud"), but the toggle persists across rerenders.
|
||||
* Subordinate "thinking" trace shown above an assistant turn.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - While ``streaming`` is true (``reasoning_delta`` frames still arriving),
|
||||
* the bubble defaults to open and the header runs a shimmer + pulse so
|
||||
* the user sees the model "thinking out loud" in real time.
|
||||
* - On ``reasoning_end`` the bubble auto-collapses for prose density —
|
||||
* the user can re-expand to inspect the chain of thought. The local
|
||||
* toggle persists once the user interacts.
|
||||
*/
|
||||
function ReasoningBubble({ lines }: ReasoningBubbleProps) {
|
||||
function ReasoningBubble({ text, streaming }: ReasoningBubbleProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(true);
|
||||
const text = useMemo(() => lines.join("\n\n"), [lines]);
|
||||
const [userToggled, setUserToggled] = useState(false);
|
||||
const [openLocal, setOpenLocal] = useState(true);
|
||||
const open = userToggled ? openLocal : streaming;
|
||||
const onToggle = () => {
|
||||
setUserToggled(true);
|
||||
setOpenLocal((v) => (userToggled ? !v : !open));
|
||||
};
|
||||
return (
|
||||
<div className="mb-2 w-full animate-in fade-in-0 slide-in-from-top-1 duration-200">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
onClick={onToggle}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5",
|
||||
"text-xs text-muted-foreground transition-colors hover:bg-muted/45",
|
||||
streaming && "reasoning-shimmer",
|
||||
)}
|
||||
aria-expanded={open}
|
||||
aria-live={streaming ? "polite" : undefined}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" aria-hidden />
|
||||
<span className="font-medium">{t("message.reasoning", { defaultValue: "Thinking" })}</span>
|
||||
<Sparkles
|
||||
className={cn("h-3.5 w-3.5", streaming && "animate-pulse")}
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{streaming
|
||||
? t("message.reasoningStreaming", { defaultValue: "Thinking…" })
|
||||
: t("message.reasoning", { defaultValue: "Thinking" })}
|
||||
</span>
|
||||
<ChevronRight
|
||||
aria-hidden
|
||||
className={cn(
|
||||
@@ -472,7 +495,7 @@ function ReasoningBubble({ lines }: ReasoningBubbleProps) {
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{open && (
|
||||
{open && text.length > 0 && (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-1 whitespace-pre-wrap break-words border-l border-muted-foreground/20 pl-3",
|
||||
|
||||
@@ -117,6 +117,34 @@
|
||||
--cjk-line-height: 1.625;
|
||||
}
|
||||
|
||||
/* Shimmer band sweeping across the reasoning header while
|
||||
``reasoning_delta`` frames are arriving. Pure CSS, no JS animation,
|
||||
respects ``prefers-reduced-motion``. */
|
||||
@keyframes reasoning-shimmer-sweep {
|
||||
0% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
}
|
||||
.reasoning-shimmer {
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
hsl(var(--muted-foreground) / 0.18) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: reasoning-shimmer-sweep 2.2s linear infinite;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reasoning-shimmer {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Subtle scrollbar that doesn't fight the dark background. */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
|
||||
@@ -18,6 +18,82 @@ interface StreamBuffer {
|
||||
parts: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a reasoning chunk to the last open reasoning stream in ``prev``.
|
||||
*
|
||||
* Lookup rule: find the most recent assistant turn that is either still
|
||||
* streaming reasoning (``reasoningStreaming``) or has no answer text yet.
|
||||
* Anything else starts a fresh streaming placeholder so a new turn's
|
||||
* reasoning never bleeds into the previous answer.
|
||||
*/
|
||||
function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (candidate.role !== "assistant" || candidate.kind === "trace") continue;
|
||||
const hasAnswer = candidate.content.length > 0;
|
||||
if (candidate.reasoningStreaming || (!hasAnswer && candidate.reasoning !== undefined)) {
|
||||
const merged: UIMessage = {
|
||||
...candidate,
|
||||
reasoning: (candidate.reasoning ?? "") + chunk,
|
||||
reasoningStreaming: true,
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
if (!hasAnswer && candidate.isStreaming) {
|
||||
const merged: UIMessage = {
|
||||
...candidate,
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
break;
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
reasoning: chunk,
|
||||
reasoningStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the most recent assistant placeholder that an incoming answer
|
||||
* delta should adopt instead of spawning a parallel row. We look for an
|
||||
* empty-content assistant turn that is still marked ``isStreaming`` —
|
||||
* typically created earlier by ``reasoning_delta``. Anything else means
|
||||
* the model already produced an answer in a previous turn, so the new
|
||||
* delta belongs in a fresh row.
|
||||
*/
|
||||
function findActiveAssistantPlaceholder(prev: UIMessage[]): string | null {
|
||||
const last = prev[prev.length - 1];
|
||||
if (!last) return null;
|
||||
if (last.role !== "assistant" || last.kind === "trace") return null;
|
||||
if (last.content.length > 0) return null;
|
||||
if (!last.isStreaming) return null;
|
||||
return last.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the active reasoning stream segment, if any. Idempotent: a
|
||||
* ``reasoning_end`` with no preceding deltas is a harmless no-op.
|
||||
*/
|
||||
function closeReasoningStream(prev: UIMessage[]): UIMessage[] {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (!candidate.reasoningStreaming) continue;
|
||||
const merged: UIMessage = { ...candidate, reasoningStreaming: false };
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
return prev;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -122,27 +198,42 @@ export function useNanobotStream(
|
||||
|
||||
if (ev.event === "delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const id = buffer.current?.messageId ?? crypto.randomUUID();
|
||||
if (!buffer.current) {
|
||||
buffer.current = { messageId: id, parts: [] };
|
||||
setMessages((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
setIsStreaming(true);
|
||||
}
|
||||
buffer.current.parts.push(ev.text);
|
||||
const combined = buffer.current.parts.join("");
|
||||
const targetId = buffer.current.messageId;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) => (m.id === targetId ? { ...m, content: combined } : m)),
|
||||
);
|
||||
const chunk = ev.text;
|
||||
setIsStreaming(true);
|
||||
setMessages((prev) => {
|
||||
// Reuse an in-flight assistant placeholder (typically created by
|
||||
// ``reasoning_delta``) so the answer renders below its own
|
||||
// thinking trace instead of in a parallel row.
|
||||
const adopted = !buffer.current ? findActiveAssistantPlaceholder(prev) : null;
|
||||
let targetId: string;
|
||||
let next: UIMessage[];
|
||||
if (buffer.current) {
|
||||
targetId = buffer.current.messageId;
|
||||
next = prev;
|
||||
} else if (adopted) {
|
||||
targetId = adopted;
|
||||
buffer.current = { messageId: targetId, parts: [] };
|
||||
next = prev;
|
||||
} else {
|
||||
targetId = crypto.randomUUID();
|
||||
buffer.current = { messageId: targetId, parts: [] };
|
||||
next = [
|
||||
...prev,
|
||||
{
|
||||
id: targetId,
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
}
|
||||
buffer.current.parts.push(chunk);
|
||||
const combined = buffer.current.parts.join("");
|
||||
return next.map((m) =>
|
||||
m.id === targetId ? { ...m, content: combined, isStreaming: true } : m,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -159,6 +250,21 @@ export function useNanobotStream(
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "reasoning_delta") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
const chunk = ev.text;
|
||||
if (!chunk) return;
|
||||
setMessages((prev) => attachReasoningChunk(prev, chunk));
|
||||
setIsStreaming(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "reasoning_end") {
|
||||
if (suppressStreamUntilTurnEndRef.current) return;
|
||||
setMessages((prev) => closeReasoningStream(prev));
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.event === "turn_end") {
|
||||
// Definitive signal that the turn is fully complete. Cancel any
|
||||
// pending debounce timer and stop the loading indicator immediately.
|
||||
@@ -187,37 +293,13 @@ export function useNanobotStream(
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Model reasoning rides its own channel: stash it on the next
|
||||
// assistant turn so the bubble renders it as a subordinate trace.
|
||||
// If the assistant message hasn't materialized yet (typical, since
|
||||
// reasoning fires before tool calls/answers), park it on a sentinel
|
||||
// pending row that the next assistant message absorbs.
|
||||
// Back-compat: a legacy ``kind: "reasoning"`` message (no streaming
|
||||
// partner) is treated as one complete delta + immediate end so the
|
||||
// bubble renders identically to the streaming path.
|
||||
if (ev.kind === "reasoning") {
|
||||
const line = ev.text;
|
||||
if (!line) return;
|
||||
setMessages((prev) => {
|
||||
for (let i = prev.length - 1; i >= 0; i -= 1) {
|
||||
const candidate = prev[i];
|
||||
if (candidate.role === "assistant" && candidate.kind !== "trace") {
|
||||
const merged: UIMessage = {
|
||||
...candidate,
|
||||
reasoning: [...(candidate.reasoning ?? []), line],
|
||||
};
|
||||
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
|
||||
}
|
||||
}
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "assistant",
|
||||
content: "",
|
||||
isStreaming: true,
|
||||
reasoning: [line],
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
];
|
||||
});
|
||||
setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
|
||||
return;
|
||||
}
|
||||
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
|
||||
|
||||
@@ -333,6 +333,7 @@
|
||||
"toolSingle": "Using a tool",
|
||||
"toolMany": "Used {{count}} tools",
|
||||
"reasoning": "Thinking",
|
||||
"reasoningStreaming": "Thinking…",
|
||||
"imageAttachment": "Image attachment",
|
||||
"copyReply": "Copy reply",
|
||||
"copiedReply": "Copied reply"
|
||||
|
||||
@@ -320,7 +320,8 @@
|
||||
"assistantTyping": "助手正在输入",
|
||||
"toolSingle": "正在使用工具",
|
||||
"toolMany": "已使用 {{count}} 个工具",
|
||||
"reasoning": "思考中",
|
||||
"reasoning": "思考过程",
|
||||
"reasoningStreaming": "正在思考…",
|
||||
"imageAttachment": "图片附件",
|
||||
"copyReply": "复制回复",
|
||||
"copiedReply": "已复制回复"
|
||||
|
||||
+18
-4
@@ -44,10 +44,13 @@ export interface UIMessage {
|
||||
images?: UIImage[];
|
||||
/** Signed or local UI-renderable media attachments. */
|
||||
media?: UIMediaAttachment[];
|
||||
/** Assistant turn: model reasoning / thinking content collected from
|
||||
* `kind: "reasoning"` frames. Each entry is one emit cycle, joined with
|
||||
* blank lines on render. */
|
||||
reasoning?: string[];
|
||||
/** Assistant turn: accumulated model reasoning / thinking text. Built up
|
||||
* incrementally from ``reasoning_delta`` frames; finalized when
|
||||
* ``reasoning_end`` arrives. */
|
||||
reasoning?: string;
|
||||
/** True while ``reasoning_delta`` frames are still arriving for this turn.
|
||||
* Drives the shimmer header on ``ReasoningBubble``. */
|
||||
reasoningStreaming?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
@@ -158,6 +161,17 @@ export type InboundEvent =
|
||||
chat_id: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| {
|
||||
event: "reasoning_delta";
|
||||
chat_id: string;
|
||||
text: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| {
|
||||
event: "reasoning_end";
|
||||
chat_id: string;
|
||||
stream_id?: string;
|
||||
}
|
||||
| {
|
||||
event: "runtime_model_updated";
|
||||
model_name: string;
|
||||
|
||||
@@ -103,37 +103,41 @@ describe("MessageBubble", () => {
|
||||
expect(container.querySelector("video[controls]")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("surfaces reasoning content above the assistant answer when provided", () => {
|
||||
it("auto-expands the reasoning trace while streaming with a shimmer header", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-reasoning",
|
||||
id: "a-reasoning-streaming",
|
||||
role: "assistant",
|
||||
content: "",
|
||||
createdAt: Date.now(),
|
||||
reasoning: "Step 1: parse intent. Step 2: compute.",
|
||||
reasoningStreaming: true,
|
||||
};
|
||||
|
||||
const { container } = render(<MessageBubble message={message} />);
|
||||
|
||||
expect(screen.getByText("Thinking…")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
||||
expect(container.querySelector(".reasoning-shimmer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("collapses the reasoning section by default once streaming ends", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-reasoning-done",
|
||||
role: "assistant",
|
||||
content: "The answer is 42.",
|
||||
createdAt: Date.now(),
|
||||
reasoning: ["Step 1: parse intent.", "Step 2: compute."],
|
||||
reasoning: "hidden until expanded",
|
||||
reasoningStreaming: false,
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
expect(screen.getByText("Thinking")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 1: parse intent\./)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Step 2: compute\./)).toBeInTheDocument();
|
||||
expect(screen.getByText("The answer is 42.")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("hidden until expanded")).not.toBeInTheDocument();
|
||||
|
||||
it("collapses the reasoning section when toggled", () => {
|
||||
const message: UIMessage = {
|
||||
id: "a-reasoning-collapse",
|
||||
role: "assistant",
|
||||
content: "done",
|
||||
createdAt: Date.now(),
|
||||
reasoning: ["hidden after toggle"],
|
||||
};
|
||||
|
||||
render(<MessageBubble message={message} />);
|
||||
|
||||
expect(screen.getByText("hidden after toggle")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /thinking/i }));
|
||||
expect(screen.queryByText("hidden after toggle")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("hidden until expanded")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders assistant image media as a larger generated result", () => {
|
||||
|
||||
@@ -113,7 +113,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[1].kind).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parks reasoning frames on a placeholder assistant message until the answer arrives", () => {
|
||||
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -121,28 +121,31 @@ describe("useNanobotStream", () => {
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-r", {
|
||||
event: "message",
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-r",
|
||||
text: "Let me think step by step.",
|
||||
kind: "reasoning",
|
||||
text: "Let me think ",
|
||||
});
|
||||
fake.emit("chat-r", {
|
||||
event: "message",
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-r",
|
||||
text: "First, decompose the request.",
|
||||
kind: "reasoning",
|
||||
text: "step by step.",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].role).toBe("assistant");
|
||||
expect(result.current.messages[0].reasoning).toEqual([
|
||||
"Let me think step by step.",
|
||||
"First, decompose the request.",
|
||||
]);
|
||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(true);
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-r", { event: "reasoning_end", chat_id: "chat-r" });
|
||||
});
|
||||
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
expect(result.current.messages[0].reasoning).toBe("Let me think step by step.");
|
||||
});
|
||||
|
||||
it("attaches reasoning to the latest assistant turn rather than spawning a new one", () => {
|
||||
it("absorbs a streaming reasoning placeholder into the answer turn that follows", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r2", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -150,24 +153,26 @@ describe("useNanobotStream", () => {
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-r2", {
|
||||
event: "message",
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-r2",
|
||||
text: "Plan first.",
|
||||
});
|
||||
fake.emit("chat-r2", { event: "reasoning_end", chat_id: "chat-r2" });
|
||||
fake.emit("chat-r2", {
|
||||
event: "delta",
|
||||
chat_id: "chat-r2",
|
||||
text: "The answer is 42.",
|
||||
});
|
||||
fake.emit("chat-r2", {
|
||||
event: "message",
|
||||
chat_id: "chat-r2",
|
||||
text: "Reasoning surfaced post-hoc.",
|
||||
kind: "reasoning",
|
||||
});
|
||||
fake.emit("chat-r2", { event: "stream_end", chat_id: "chat-r2" });
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("The answer is 42.");
|
||||
expect(result.current.messages[0].reasoning).toEqual(["Reasoning surfaced post-hoc."]);
|
||||
expect(result.current.messages[0].reasoning).toBe("Plan first.");
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores empty reasoning frames", () => {
|
||||
it("ignores empty reasoning_delta frames", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r3", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -175,16 +180,35 @@ describe("useNanobotStream", () => {
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-r3", {
|
||||
event: "message",
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-r3",
|
||||
text: "",
|
||||
kind: "reasoning",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("treats legacy kind=reasoning messages as a complete delta + end pair", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r4", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-r4", {
|
||||
event: "message",
|
||||
chat_id: "chat-r4",
|
||||
text: "one-shot reasoning",
|
||||
kind: "reasoning",
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].reasoning).toBe("one-shot reasoning");
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches assistant media_urls to complete messages", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-m", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user