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:
@@ -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