mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Show reasoning traces: live Thinking block + persisted disclosure
New reasoning_delta event; traces persist as a display sidecar stripped from provider feeds. Sources: compat vendors' reasoning_content and Gemini thought summaries (include_thoughts). Live-verified on GLM via Together and Gemini 3; Gemini tool loops stay healthy.
This commit is contained in:
+37
-21
@@ -296,8 +296,23 @@ class TurnEngine:
|
||||
|
||||
turn: Optional[AssistantTurn] = None
|
||||
streamed: list[str] = []
|
||||
streamed_reasoning: list[str] = []
|
||||
|
||||
def _partial_turn() -> AssistantTurn:
|
||||
# What the user watched arrive — text and thinking, NO tool calls (any
|
||||
# half-formed calls would either orphan or execute against the stop).
|
||||
return AssistantTurn(
|
||||
text="".join(streamed) or None,
|
||||
reasoning="".join(streamed_reasoning) or None,
|
||||
)
|
||||
|
||||
try:
|
||||
async for chunk in self._astream():
|
||||
if chunk.reasoning_delta:
|
||||
streamed_reasoning.append(chunk.reasoning_delta)
|
||||
yield Event(
|
||||
EventType.REASONING_DELTA, {"text": chunk.reasoning_delta}
|
||||
)
|
||||
if chunk.text_delta:
|
||||
streamed.append(chunk.text_delta)
|
||||
yield Event(
|
||||
@@ -306,12 +321,10 @@ class TurnEngine:
|
||||
if chunk.turn is not None:
|
||||
turn = chunk.turn
|
||||
except Exception as exc: # provider failure
|
||||
# Same contract as the stop path below: the partial text the user
|
||||
# watched arrive survives the failure (no tool calls — none finalized).
|
||||
if streamed:
|
||||
self.messages.append(
|
||||
_assistant_message(AssistantTurn(text="".join(streamed)))
|
||||
)
|
||||
# Same contract as the stop path below: the partial the user watched
|
||||
# arrive survives the failure.
|
||||
if streamed or streamed_reasoning:
|
||||
self.messages.append(_assistant_message(_partial_turn()))
|
||||
friendly = friendly_model_error(self.model, exc)
|
||||
payload = {
|
||||
"error": friendly or str(exc),
|
||||
@@ -323,13 +336,9 @@ class TurnEngine:
|
||||
yield Event(EventType.ERROR, payload)
|
||||
return
|
||||
if self._cancel.is_set() and turn is None:
|
||||
# Stopped mid-stream: persist exactly what the user watched arrive —
|
||||
# the partial text, NO tool calls (any half-formed calls would either
|
||||
# orphan or execute against the user's explicit stop).
|
||||
if streamed:
|
||||
self.messages.append(
|
||||
_assistant_message(AssistantTurn(text="".join(streamed)))
|
||||
)
|
||||
# Stopped mid-stream: persist exactly what the user watched arrive.
|
||||
if streamed or streamed_reasoning:
|
||||
self.messages.append(_assistant_message(_partial_turn()))
|
||||
self._append_notice("interrupted")
|
||||
yield Event(EventType.INTERRUPTED, {"iterations": iterations})
|
||||
return
|
||||
@@ -337,10 +346,13 @@ class TurnEngine:
|
||||
turn = AssistantTurn()
|
||||
|
||||
self.messages.append(_assistant_message(turn))
|
||||
yield Event(
|
||||
EventType.ASSISTANT_MESSAGE,
|
||||
{"text": turn.text, "tool_calls": [tc.name for tc in turn.tool_calls]},
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"text": turn.text,
|
||||
"tool_calls": [tc.name for tc in turn.tool_calls],
|
||||
}
|
||||
if turn.reasoning:
|
||||
payload["reasoning"] = turn.reasoning
|
||||
yield Event(EventType.ASSISTANT_MESSAGE, payload)
|
||||
|
||||
if not turn.tool_calls:
|
||||
if self._steering:
|
||||
@@ -866,10 +878,10 @@ class TurnEngine:
|
||||
persisted/replayed.
|
||||
"""
|
||||
# Strip the display-only sidecars — `source` (connector cards), `_display`
|
||||
# (e.g. filter-hidden counts), and `ts` (append-time timestamps) — copying only
|
||||
# messages that carry one. Whole `notice` messages (error/interrupted markers)
|
||||
# are display-only too: dropped entirely.
|
||||
_SIDECARS = ("source", "_display", "ts")
|
||||
# (e.g. filter-hidden counts), `ts` (append-time timestamps), and `reasoning`
|
||||
# (thinking text) — copying only messages that carry one. Whole `notice` messages
|
||||
# (error/interrupted/model-switch markers) are display-only too: dropped entirely.
|
||||
_SIDECARS = ("source", "_display", "ts", "reasoning")
|
||||
out = [
|
||||
(
|
||||
{k: v for k, v in msg.items() if k not in _SIDECARS}
|
||||
@@ -968,6 +980,10 @@ def _assistant_message(turn: AssistantTurn) -> dict[str, Any]:
|
||||
"content": turn.text or "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
if turn.reasoning:
|
||||
# Display-only thinking text — rendered by the GUI, stripped for every provider
|
||||
# (`_outbound_messages`); provider-private replay blocks go via `extras` instead.
|
||||
message["reasoning"] = turn.reasoning
|
||||
if turn.extras:
|
||||
# Provider-private sidecars (e.g. `_gemini` thought signatures) persist with the
|
||||
# message; the owning provider reattaches them, the rest strip them (base.py).
|
||||
|
||||
@@ -14,6 +14,7 @@ from typing import Any
|
||||
class EventType(str, Enum):
|
||||
TURN_START = "turn_start"
|
||||
ASSISTANT_DELTA = "assistant_delta"
|
||||
REASONING_DELTA = "reasoning_delta" # model thinking text (display-only, never replayed)
|
||||
ASSISTANT_MESSAGE = "assistant_message"
|
||||
TOOL_PROPOSED = "tool_proposed"
|
||||
PERMISSION_REQUIRED = "permission_required"
|
||||
|
||||
@@ -29,6 +29,10 @@ class AssistantTurn:
|
||||
tool_calls: list[ToolCall] = field(default_factory=list)
|
||||
finish_reason: Optional[str] = None
|
||||
raw: Any = field(default=None, repr=False, compare=False)
|
||||
# The model's thinking text (DeepSeek reasoning_content, Gemini thought summaries, …).
|
||||
# Display-only: persisted on the assistant message as the `reasoning` sidecar and shown
|
||||
# in the GUI, but stripped before every provider call — never replayed as context.
|
||||
reasoning: Optional[str] = None
|
||||
# Provider-private sidecars to persist on the canonical assistant message
|
||||
# (underscore-prefixed keys, e.g. `_gemini` thought signatures). Contract: the
|
||||
# owning provider consumes its own key when converting history; every other
|
||||
@@ -55,9 +59,10 @@ class ModelCapabilities:
|
||||
|
||||
@dataclass
|
||||
class StreamChunk:
|
||||
"""One streamed piece: a text delta, and/or (on the final chunk) the full turn."""
|
||||
"""One streamed piece: a text and/or reasoning delta, and/or (final) the full turn."""
|
||||
|
||||
text_delta: Optional[str] = None
|
||||
reasoning_delta: Optional[str] = None
|
||||
turn: Optional[AssistantTurn] = None
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field as dataclass_field
|
||||
from typing import Any, Optional
|
||||
|
||||
from .base import (
|
||||
@@ -310,47 +311,55 @@ def _signature_extras(
|
||||
return {"_gemini": {"text_sig": text_sig, "call_sigs": call_sigs}}
|
||||
|
||||
|
||||
def _parse_candidate(
|
||||
response: Any,
|
||||
) -> tuple[list[str], list[ToolCall], Optional[str], tuple[Optional[str], list[Optional[str]]]]:
|
||||
"""Pull answer text parts, function calls (with synthesized ids), the finish reason, and
|
||||
thought signatures out of a GenerateContentResponse (or one streamed chunk of it).
|
||||
Parts flagged `thought` are reasoning summaries — their signature is kept, their text is
|
||||
NOT answer text."""
|
||||
texts: list[str] = []
|
||||
calls: list[ToolCall] = []
|
||||
finish = None
|
||||
@dataclass
|
||||
class _Parsed:
|
||||
"""One GenerateContentResponse (or streamed chunk), split into our concerns."""
|
||||
|
||||
texts: list[str] = dataclass_field(default_factory=list)
|
||||
thoughts: list[str] = dataclass_field(default_factory=list) # `thought` summary parts
|
||||
calls: list[ToolCall] = dataclass_field(default_factory=list)
|
||||
finish: Optional[str] = None
|
||||
text_sig: Optional[str] = None
|
||||
call_sigs: list[Optional[str]] = []
|
||||
call_sigs: list[Optional[str]] = dataclass_field(default_factory=list)
|
||||
|
||||
|
||||
def _parse_candidate(response: Any) -> _Parsed:
|
||||
"""Pull answer text, thought summaries, function calls (ids synthesized by the caller),
|
||||
the finish reason, and thought signatures out of a response or streamed chunk. Parts
|
||||
flagged `thought` are reasoning — their signature is kept, their text never joins the
|
||||
answer."""
|
||||
out = _Parsed()
|
||||
candidates = getattr(response, "candidates", None) or []
|
||||
if not candidates:
|
||||
return texts, calls, finish, (text_sig, call_sigs)
|
||||
return out
|
||||
candidate = candidates[0]
|
||||
content = getattr(candidate, "content", None)
|
||||
for part in getattr(content, "parts", None) or []:
|
||||
sig = _sig_str(part)
|
||||
function_call = getattr(part, "function_call", None)
|
||||
if function_call is not None:
|
||||
calls.append(
|
||||
out.calls.append(
|
||||
ToolCall(
|
||||
id="", # synthesized by the caller (needs the running count)
|
||||
id="",
|
||||
name=getattr(function_call, "name", "") or "",
|
||||
arguments=dict(getattr(function_call, "args", None) or {}),
|
||||
)
|
||||
)
|
||||
call_sigs.append(sig)
|
||||
out.call_sigs.append(sig)
|
||||
continue
|
||||
if sig:
|
||||
text_sig = sig
|
||||
if getattr(part, "thought", False):
|
||||
continue
|
||||
out.text_sig = sig
|
||||
text = getattr(part, "text", None)
|
||||
if getattr(part, "thought", False):
|
||||
if text:
|
||||
out.thoughts.append(text)
|
||||
continue
|
||||
if text:
|
||||
texts.append(text)
|
||||
out.texts.append(text)
|
||||
raw_finish = getattr(candidate, "finish_reason", None)
|
||||
if raw_finish is not None:
|
||||
finish = getattr(raw_finish, "name", None) or str(raw_finish)
|
||||
return texts, calls, finish, (text_sig, call_sigs)
|
||||
out.finish = getattr(raw_finish, "name", None) or str(raw_finish)
|
||||
return out
|
||||
|
||||
|
||||
def _map_finish(finish: Optional[str], has_calls: bool) -> Optional[str]:
|
||||
@@ -409,6 +418,11 @@ class GeminiProvider(ProviderClient):
|
||||
config: dict[str, Any] = {
|
||||
k: v for k, v in settings.items() if k in _SETTINGS_WHITELIST
|
||||
}
|
||||
# Thinking models (2.5+/3.x — all our curated ids) think by default; ask for the
|
||||
# thought SUMMARIES too so the GUI can show them. Parse-side keeps them out of
|
||||
# answer text (`thought` parts → reasoning).
|
||||
if model.startswith("gemini-"):
|
||||
config["thinking_config"] = {"include_thoughts": True}
|
||||
if system:
|
||||
config["system_instruction"] = system
|
||||
if tools:
|
||||
@@ -429,17 +443,18 @@ class GeminiProvider(ProviderClient):
|
||||
model=model, messages=messages, tools=tools, settings=settings
|
||||
)
|
||||
response = self._ensure_client().models.generate_content(**kwargs)
|
||||
texts, calls, finish, (text_sig, call_sigs) = _parse_candidate(response)
|
||||
parsed = _parse_candidate(response)
|
||||
tool_calls = [
|
||||
ToolCall(id=f"call_{i}", name=c.name, arguments=c.arguments)
|
||||
for i, c in enumerate(calls)
|
||||
for i, c in enumerate(parsed.calls)
|
||||
]
|
||||
return AssistantTurn(
|
||||
text="".join(texts) or None,
|
||||
text="".join(parsed.texts) or None,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=_map_finish(finish, bool(tool_calls)),
|
||||
finish_reason=_map_finish(parsed.finish, bool(tool_calls)),
|
||||
raw=response,
|
||||
extras=_signature_extras(text_sig, call_sigs),
|
||||
reasoning="".join(parsed.thoughts) or None,
|
||||
extras=_signature_extras(parsed.text_sig, parsed.call_sigs),
|
||||
)
|
||||
|
||||
def capabilities(self, model: str) -> ModelCapabilities:
|
||||
@@ -459,6 +474,7 @@ class GeminiProvider(ProviderClient):
|
||||
client = self._ensure_client()
|
||||
|
||||
text_parts: list[str] = []
|
||||
thought_parts: list[str] = []
|
||||
calls: list[ToolCall] = []
|
||||
finish = None
|
||||
text_sig: Optional[str] = None
|
||||
@@ -467,18 +483,19 @@ class GeminiProvider(ProviderClient):
|
||||
# Unlike Anthropic, function_call parts arrive whole (args are a complete dict per
|
||||
# part), so there is no JSON accumulation — just collect parts across chunks.
|
||||
for chunk in client.models.generate_content_stream(**kwargs):
|
||||
texts, chunk_calls, chunk_finish, (chunk_sig, chunk_call_sigs) = (
|
||||
_parse_candidate(chunk)
|
||||
)
|
||||
for text in texts:
|
||||
parsed = _parse_candidate(chunk)
|
||||
for thought in parsed.thoughts:
|
||||
thought_parts.append(thought)
|
||||
yield StreamChunk(reasoning_delta=thought)
|
||||
for text in parsed.texts:
|
||||
text_parts.append(text)
|
||||
yield StreamChunk(text_delta=text)
|
||||
calls.extend(chunk_calls)
|
||||
call_sigs.extend(chunk_call_sigs)
|
||||
if chunk_sig:
|
||||
text_sig = chunk_sig
|
||||
if chunk_finish:
|
||||
finish = chunk_finish
|
||||
calls.extend(parsed.calls)
|
||||
call_sigs.extend(parsed.call_sigs)
|
||||
if parsed.text_sig:
|
||||
text_sig = parsed.text_sig
|
||||
if parsed.finish:
|
||||
finish = parsed.finish
|
||||
|
||||
tool_calls = [
|
||||
ToolCall(id=f"call_{i}", name=c.name, arguments=c.arguments)
|
||||
@@ -489,6 +506,7 @@ class GeminiProvider(ProviderClient):
|
||||
text="".join(text_parts) or None,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=_map_finish(finish, bool(tool_calls)),
|
||||
reasoning="".join(thought_parts) or None,
|
||||
extras=_signature_extras(text_sig, call_sigs),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -51,6 +51,14 @@ def _pin_reasoning_effort(kwargs: dict[str, Any]) -> None:
|
||||
kwargs.setdefault("reasoning_effort", "none")
|
||||
|
||||
|
||||
def _delta_reasoning(obj: Any) -> Optional[str]:
|
||||
"""Thinking text off a delta/message: `reasoning_content` (DeepSeek, GLM, Kimi, and
|
||||
most compat vendors) or `reasoning` (xAI, OpenRouter). Extra response fields survive
|
||||
the OpenAI SDK's models (extra="allow"), so plain getattr sees them."""
|
||||
value = getattr(obj, "reasoning_content", None) or getattr(obj, "reasoning", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _strip_foreign_sidecars(messages: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Drop provider-private message sidecars (underscore-prefixed keys, e.g. `_gemini`
|
||||
thought signatures — see providers/base.py): they belong to other providers, and the
|
||||
@@ -165,6 +173,7 @@ class OpenAIProvider(ProviderClient):
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=getattr(choice, "finish_reason", None),
|
||||
raw=response,
|
||||
reasoning=_delta_reasoning(message),
|
||||
)
|
||||
|
||||
def capabilities(self, model: str) -> ModelCapabilities:
|
||||
@@ -190,6 +199,7 @@ class OpenAIProvider(ProviderClient):
|
||||
client = self._ensure_client()
|
||||
|
||||
text_parts: list[str] = []
|
||||
reasoning_parts: list[str] = []
|
||||
tool_accum: dict[int, dict[str, str]] = {}
|
||||
finish_reason = None
|
||||
|
||||
@@ -209,6 +219,10 @@ class OpenAIProvider(ProviderClient):
|
||||
choice = choices[0]
|
||||
delta = getattr(choice, "delta", None)
|
||||
if delta is not None:
|
||||
reasoning = _delta_reasoning(delta)
|
||||
if reasoning:
|
||||
reasoning_parts.append(reasoning)
|
||||
yield StreamChunk(reasoning_delta=reasoning)
|
||||
content = getattr(delta, "content", None)
|
||||
if content:
|
||||
text_parts.append(content)
|
||||
@@ -247,6 +261,7 @@ class OpenAIProvider(ProviderClient):
|
||||
text=text,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
reasoning="".join(reasoning_parts) or None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -648,6 +648,27 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A reasoning model's turn: thinking deltas tick in slowly, then the answer —
|
||||
// the assistant_message carries the full trace like the real engine's payload.
|
||||
if (/think hard/i.test(msg.text)) {
|
||||
const thoughts = ["Weighing options. ", "Comparing tradeoffs. ", "Settling it. "];
|
||||
let tick = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (tick < thoughts.length) {
|
||||
send("reasoning_delta", { text: thoughts[tick] });
|
||||
tick += 1;
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
send("assistant_delta", { text: "Decision made." });
|
||||
send("assistant_message", {
|
||||
text: "Decision made.",
|
||||
reasoning: thoughts.join(""),
|
||||
});
|
||||
send("turn_done");
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
|
||||
if (/fail the turn/i.test(msg.text)) {
|
||||
send("error", { error: "model unreachable" });
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// Model-layer roadmap item 4 (2026-07-22): reasoning traces. Live turn shows a quiet
|
||||
// pulsing "Thinking…" disclosure that streams the trace; once the message finalizes the
|
||||
// trace folds into a collapsed "Thought process" disclosure on the answer bubble.
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "./fixtures";
|
||||
|
||||
test("thinking streams live, then persists as a collapsed disclosure on the answer", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await page.getByText("Draft the launch note").first().click();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("think hard about this");
|
||||
await box.press("Enter");
|
||||
|
||||
// Live phase: the Thinking… block is up while deltas tick in; expanding shows the trace.
|
||||
await expect(page.getByText("Thinking…").first()).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByTestId("thinking-toggle").click();
|
||||
await expect(page.getByTestId("thinking-body")).toContainText("Weighing options.");
|
||||
|
||||
// Finalized: the answer bubble carries a collapsed "Thought process" disclosure.
|
||||
await expect(page.getByText("Decision made.").first()).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Thinking…")).toHaveCount(0);
|
||||
const toggle = page.getByTestId("thinking-toggle");
|
||||
await expect(toggle).toHaveText(/Thought process/);
|
||||
await expect(page.getByTestId("thinking-body")).toHaveCount(0); // collapsed by default
|
||||
await toggle.click();
|
||||
await expect(page.getByTestId("thinking-body")).toContainText(
|
||||
"Weighing options. Comparing tradeoffs. Settling it.",
|
||||
);
|
||||
});
|
||||
@@ -36,7 +36,7 @@ import { InboxItemCard } from "./components/InboxItemCard";
|
||||
import { isTauri, platformOS, startWindowDrag } from "./tauri";
|
||||
import { Icon } from "./components/Icon";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Transcript } from "./components/Transcript";
|
||||
import { ThinkingBlock, Transcript } from "./components/Transcript";
|
||||
import { Composer } from "./components/Composer";
|
||||
import { Markdown } from "./components/Markdown";
|
||||
import { SearchModal } from "./components/SearchModal";
|
||||
@@ -162,6 +162,14 @@ export function App() {
|
||||
streamingRef.current = typeof value === "function" ? value(streamingRef.current) : value;
|
||||
setStreamingState(streamingRef.current);
|
||||
};
|
||||
// The turn's live thinking text (reasoning_delta events) — same ref-mirror pattern.
|
||||
// Folded onto the assistant item when the message finalizes; cleared on turn_start.
|
||||
const [reasoningStream, setReasoningStreamState] = useState("");
|
||||
const reasoningRef = useRef("");
|
||||
const setReasoningStream = (value: string) => {
|
||||
reasoningRef.current = value;
|
||||
setReasoningStreamState(value);
|
||||
};
|
||||
const [todo, setTodo] = useState<TodoItem[]>([]);
|
||||
const [sessions, setSessions] = useState<SessionInfo[]>([]);
|
||||
const [projects, setProjects] = useState<RecentWorkspace[]>([]);
|
||||
@@ -527,9 +535,19 @@ export function App() {
|
||||
// the same text server-side, so the live view and a session reload now agree.
|
||||
const flushPartialStream = () => {
|
||||
const partial = streamingRef.current;
|
||||
if (!partial) return;
|
||||
const thinking = reasoningRef.current;
|
||||
if (!partial && !thinking) return;
|
||||
setStreaming("");
|
||||
setItems((p) => [...p, { kind: "assistant", text: partial, ts: Date.now() / 1000 }]);
|
||||
setReasoningStream("");
|
||||
setItems((p) => [
|
||||
...p,
|
||||
{
|
||||
kind: "assistant",
|
||||
text: partial,
|
||||
ts: Date.now() / 1000,
|
||||
...(thinking ? { reasoning: thinking } : {}),
|
||||
},
|
||||
]);
|
||||
};
|
||||
switch (ev.type) {
|
||||
case "ready":
|
||||
@@ -542,6 +560,7 @@ export function App() {
|
||||
case "turn_start":
|
||||
setRunning(true);
|
||||
setStreaming("");
|
||||
setReasoningStream("");
|
||||
// Background-delivered turns (channel message, self-wake, durable resume) have no local
|
||||
// send(), so the triggering message isn't in `items` yet — surface it. A connector message
|
||||
// carries a structured `source` (§3.1) → render the rich card; otherwise a plain user item.
|
||||
@@ -566,10 +585,27 @@ export function App() {
|
||||
case "assistant_delta":
|
||||
setStreaming((s) => s + (d.text || ""));
|
||||
break;
|
||||
case "assistant_message":
|
||||
if (d.text) setItems((p) => [...p, { kind: "assistant", text: d.text, ts: Date.now() / 1000 }]);
|
||||
setStreaming(""); // finalized into items (or empty tool-only turn)
|
||||
case "reasoning_delta":
|
||||
setReasoningStream(reasoningRef.current + (d.text || ""));
|
||||
break;
|
||||
case "assistant_message": {
|
||||
// The event's reasoning is authoritative (covers background-delivered turns);
|
||||
// the local buffer is the fallback for older servers.
|
||||
const reasoning = d.reasoning || reasoningRef.current;
|
||||
if (d.text || reasoning)
|
||||
setItems((p) => [
|
||||
...p,
|
||||
{
|
||||
kind: "assistant",
|
||||
text: d.text || "",
|
||||
ts: Date.now() / 1000,
|
||||
...(reasoning ? { reasoning } : {}),
|
||||
},
|
||||
]);
|
||||
setStreaming(""); // finalized into items (or empty tool-only turn)
|
||||
setReasoningStream("");
|
||||
break;
|
||||
}
|
||||
case "tool_proposed":
|
||||
if (d.name === "todo_write" && (d.arguments?.todos || d.arguments?.items))
|
||||
setTodo(normalizeTodos(d.arguments.todos ?? d.arguments.items));
|
||||
@@ -1421,7 +1457,16 @@ export function App() {
|
||||
// floating paragraph.
|
||||
streamingText={streamMode(streaming, items, running) === "quiet" ? streaming : undefined}
|
||||
/>
|
||||
{/* Live thinking (reasoning models): a quiet collapsed block that streams the
|
||||
trace for anyone who expands it; folds into the answer's disclosure when
|
||||
the message finalizes. */}
|
||||
{running && reasoningStream && !streaming && (
|
||||
<div className="transcript">
|
||||
<ThinkingBlock text={reasoningStream} live />
|
||||
</div>
|
||||
)}
|
||||
{running &&
|
||||
!reasoningStream &&
|
||||
(!streaming || streamMode(streaming, items, running) === "hold") &&
|
||||
!lastItemIsAssistant(items) && <WaitingForAgent />}
|
||||
{streaming && streamMode(streaming, items, running) === "answer" && (
|
||||
|
||||
@@ -50,6 +50,32 @@ function BubbleMeta({ text, ts, align }: { text: string; ts?: number; align: "le
|
||||
);
|
||||
}
|
||||
|
||||
// Reasoning-model thinking text (model-layer roadmap item 4): a quiet disclosure —
|
||||
// collapsed by default, the trace one click away. `live` = still streaming (pulsing label);
|
||||
// App renders that variant above the transcript, this one rides a finalized assistant item.
|
||||
export function ThinkingBlock({ text, live }: { text: string; live?: boolean }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<div className="thinking">
|
||||
<button
|
||||
className="thinking-head"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
data-testid="thinking-toggle"
|
||||
>
|
||||
<Icon name="chevronDown" size={12} className={"thinking-caret" + (open ? " open" : "")} />
|
||||
<span className={live ? "thinking-live" : undefined}>
|
||||
{live ? "Thinking…" : "Thought process"}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="thinking-body" data-testid="thinking-body">
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||||
type ApprovalItem = Extract<Item, { kind: "approval" }>;
|
||||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||||
@@ -72,6 +98,8 @@ function buildRows(items: TurnItem[]): TurnRow[] {
|
||||
// same-name tool that doesn't have one yet (approvals may stream before or after their call).
|
||||
const rows: TurnRow[] = items
|
||||
.filter((it): it is ToolItem | AssistantItem => it.kind !== "approval")
|
||||
// Thinking-only assistant items (no text) carry nothing narratable — skip the row.
|
||||
.filter((it) => it.kind !== "assistant" || it.text)
|
||||
.map((it) =>
|
||||
it.kind === "assistant" ? { type: "narr" as const, text: it.text } : { type: "step" as const, tool: it },
|
||||
);
|
||||
@@ -363,9 +391,17 @@ export function Transcript({ items, running, streamingText, onRetry }: Props) {
|
||||
</div>
|
||||
);
|
||||
case "assistant":
|
||||
// Thinking-only item (stopped mid-reasoning): just the disclosure, no bubble.
|
||||
if (!item.text && item.reasoning)
|
||||
return (
|
||||
<div key={bi}>
|
||||
<ThinkingBlock text={item.reasoning} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="group bubble-assistant" key={bi}>
|
||||
<div className="who">assistant</div>
|
||||
{item.reasoning && <ThinkingBlock text={item.reasoning} />}
|
||||
<Markdown text={item.text} />
|
||||
<BubbleMeta text={item.text} ts={item.ts} align="left" />
|
||||
</div>
|
||||
|
||||
@@ -82,3 +82,15 @@ describe("itemsFromMessages model switch", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("itemsFromMessages reasoning", () => {
|
||||
it("attaches the reasoning sidecar to assistant items; thinking-only messages still render", () => {
|
||||
const items = itemsFromMessages([
|
||||
{ role: "user", content: "hi" },
|
||||
{ role: "assistant", content: "answer", reasoning: "let me think" },
|
||||
{ role: "assistant", content: "", reasoning: "stopped mid-thought" },
|
||||
] as any);
|
||||
expect(items[1]).toEqual({ kind: "assistant", text: "answer", reasoning: "let me think" });
|
||||
expect(items[2]).toEqual({ kind: "assistant", text: "", reasoning: "stopped mid-thought" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -37,8 +37,13 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
|
||||
if (typeof m.ts === "number") user.ts = m.ts;
|
||||
if (user.text || user.attachments?.length) items.push(user);
|
||||
} else if (m.role === "assistant") {
|
||||
if (m.content)
|
||||
items.push({ kind: "assistant", text: m.content, ...(typeof m.ts === "number" ? { ts: m.ts } : {}) });
|
||||
if (m.content || m.reasoning)
|
||||
items.push({
|
||||
kind: "assistant",
|
||||
text: m.content || "",
|
||||
...(typeof m.ts === "number" ? { ts: m.ts } : {}),
|
||||
...(m.reasoning ? { reasoning: m.reasoning } : {}),
|
||||
});
|
||||
for (const tc of m.tool_calls || []) {
|
||||
let args: any = {};
|
||||
try {
|
||||
|
||||
@@ -1561,3 +1561,21 @@ html[data-platform="linux"] ::-webkit-scrollbar-thumb {
|
||||
}
|
||||
html[data-platform="windows"] ::-webkit-scrollbar-thumb:hover,
|
||||
html[data-platform="linux"] ::-webkit-scrollbar-thumb:hover { background-color: var(--faint); }
|
||||
|
||||
/* -- thinking (reasoning trace) disclosure — model-layer roadmap item 4 ----------- */
|
||||
.thinking { margin: 2px 0 4px; }
|
||||
.thinking-head {
|
||||
display: flex; align-items: center; gap: 5px;
|
||||
font-size: 12px; color: var(--faint);
|
||||
background: none; border: none; padding: 2px 0; cursor: pointer;
|
||||
}
|
||||
.thinking-head:hover { color: var(--muted); }
|
||||
.thinking-caret { transition: transform 0.15s; transform: rotate(-90deg); }
|
||||
.thinking-caret.open { transform: rotate(0); }
|
||||
.thinking-live { animation: boot-pulse 1.4s ease-in-out infinite; }
|
||||
.thinking-body {
|
||||
font-size: 12px; line-height: 1.55; color: var(--muted);
|
||||
white-space: pre-wrap; overflow-wrap: anywhere;
|
||||
border-left: 2px solid var(--line); padding: 4px 0 4px 10px; margin: 4px 0 6px 4px;
|
||||
max-height: 280px; overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export type EventType =
|
||||
| "inbound"
|
||||
| "turn_start"
|
||||
| "assistant_delta"
|
||||
| "reasoning_delta"
|
||||
| "assistant_message"
|
||||
| "tool_proposed"
|
||||
| "permission_required"
|
||||
@@ -78,7 +79,7 @@ export type Item =
|
||||
// (ConnectorMessageCard) instead of a plain user bubble. Generalizes to any connector via the
|
||||
// registry — no per-connector special-casing.
|
||||
| { kind: "connector"; source: MessageSource }
|
||||
| { kind: "assistant"; text: string; ts?: number }
|
||||
| { kind: "assistant"; text: string; ts?: number; reasoning?: string }
|
||||
// `hidden` = results the user's privacy filters removed before the agent saw them
|
||||
// (from the tool message's `_display` sidecar; the agent-visible content has no trace).
|
||||
// `standingRule` = the task-scoped rule that auto-allowed this call ("tool → target").
|
||||
|
||||
@@ -276,3 +276,80 @@ def test_interrupt_hook_fires(tmp_path):
|
||||
)
|
||||
engine.request_interrupt()
|
||||
assert fired == [True]
|
||||
|
||||
|
||||
class ReasoningStreamProvider(ProviderClient):
|
||||
"""Streams thinking deltas, then answer text — a DeepSeek-style reasoning model."""
|
||||
|
||||
def complete(self, **kwargs): # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
def stream(self, *, model, messages, tools=None, **settings):
|
||||
yield StreamChunk(reasoning_delta="hmm, ")
|
||||
yield StreamChunk(reasoning_delta="let me think")
|
||||
yield StreamChunk(text_delta="the answer")
|
||||
yield StreamChunk(
|
||||
turn=AssistantTurn(
|
||||
text="the answer", finish_reason="stop", reasoning="hmm, let me think"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_reasoning_streams_persists_and_never_reaches_providers(tmp_path):
|
||||
engine = TurnEngine(
|
||||
provider=ReasoningStreamProvider(),
|
||||
registry=ToolRegistry(),
|
||||
permissions=PermissionEngine(workspace_root=tmp_path),
|
||||
model="deepseek:deepseek-v4-pro",
|
||||
)
|
||||
|
||||
async def run():
|
||||
return [ev async for ev in engine.run("go")]
|
||||
|
||||
events = asyncio.run(run())
|
||||
deltas = [ev.data["text"] for ev in events if ev.type == EventType.REASONING_DELTA]
|
||||
assert deltas == ["hmm, ", "let me think"]
|
||||
final = next(ev for ev in events if ev.type == EventType.ASSISTANT_MESSAGE)
|
||||
assert final.data["reasoning"] == "hmm, let me think"
|
||||
persisted = engine.messages[-1]
|
||||
assert persisted["reasoning"] == "hmm, let me think"
|
||||
# Display-only: stripped from every provider feed.
|
||||
assert all("reasoning" not in m for m in engine._outbound_messages())
|
||||
|
||||
|
||||
def test_stop_during_thinking_keeps_partial_reasoning(tmp_path):
|
||||
class EndlessThinkingProvider(ProviderClient):
|
||||
def complete(self, **kwargs): # pragma: no cover
|
||||
raise NotImplementedError
|
||||
|
||||
def capabilities(self, model):
|
||||
return ModelCapabilities()
|
||||
|
||||
def stream(self, *, model, messages, tools=None, **settings):
|
||||
for i in range(200):
|
||||
yield StreamChunk(reasoning_delta=f"t{i} ")
|
||||
time.sleep(0.01)
|
||||
yield StreamChunk(turn=AssistantTurn(text="done", finish_reason="stop"))
|
||||
|
||||
engine = TurnEngine(
|
||||
provider=EndlessThinkingProvider(),
|
||||
registry=ToolRegistry(),
|
||||
permissions=PermissionEngine(workspace_root=tmp_path),
|
||||
model="gpt-5.5",
|
||||
)
|
||||
|
||||
async def run():
|
||||
events = []
|
||||
async for ev in engine.run("go"):
|
||||
events.append(ev)
|
||||
if ev.type == EventType.REASONING_DELTA and len(events) > 3:
|
||||
engine.request_interrupt()
|
||||
return events
|
||||
|
||||
events = asyncio.run(run())
|
||||
assert events[-1].type == EventType.INTERRUPTED
|
||||
partial = engine.messages[-2] # [-1] is the interrupted notice
|
||||
assert partial["role"] == "assistant" and partial["reasoning"].startswith("t0 ")
|
||||
|
||||
@@ -604,3 +604,33 @@ def test_convert_signature_parts_validate_as_sdk_types():
|
||||
)
|
||||
part = types_mod.Part.model_validate(contents[-1]["parts"][0])
|
||||
assert part.thought_signature == b"sig"
|
||||
|
||||
|
||||
def test_thought_summaries_requested_and_surfaced_as_reasoning():
|
||||
response = _response(
|
||||
[
|
||||
_thought_part("plotting a plan", sig=None),
|
||||
SimpleNamespace(text="the answer", function_call=None, thought_signature=None),
|
||||
]
|
||||
)
|
||||
client = _FakeClient(response=response)
|
||||
provider = GeminiProvider(client=client)
|
||||
turn = provider.complete(model="gemini-3.6-flash", messages=[{"role": "user", "content": "x"}])
|
||||
|
||||
# We ask for summaries on every gemini-* model…
|
||||
assert client.kwargs["config"]["thinking_config"] == {"include_thoughts": True}
|
||||
# …and thought parts land as reasoning, never as answer text.
|
||||
assert turn.text == "the answer" and turn.reasoning == "plotting a plan"
|
||||
|
||||
|
||||
def test_stream_yields_reasoning_deltas_for_thought_parts():
|
||||
chunks = [
|
||||
_response([_thought_part("mull ")], finish_reason=None),
|
||||
_response([_thought_part("it over")], finish_reason=None),
|
||||
_response([_text_part("done")], finish_reason="STOP"),
|
||||
]
|
||||
provider = GeminiProvider(client=_FakeClient(chunks=chunks))
|
||||
out = list(provider.stream(model="gemini-3.6-flash", messages=[{"role": "user", "content": "x"}]))
|
||||
assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["mull ", "it over"]
|
||||
final = out[-1].turn
|
||||
assert final.text == "done" and final.reasoning == "mull it over"
|
||||
|
||||
@@ -431,3 +431,26 @@ def test_foreign_sidecars_stripped_from_outbound_messages():
|
||||
)
|
||||
sent = client.chat.completions.calls[0]["messages"]
|
||||
assert sent[1] == {"role": "assistant", "content": "prev"}
|
||||
|
||||
|
||||
def test_stream_reasoning_content_deltas():
|
||||
"""DeepSeek-style thinking: reasoning_content deltas surface as reasoning chunks and
|
||||
land on the final turn — never mixed into the answer text."""
|
||||
def rchunk(text):
|
||||
delta = SimpleNamespace(content=None, tool_calls=None, reasoning_content=text)
|
||||
return SimpleNamespace(choices=[SimpleNamespace(delta=delta, finish_reason=None)])
|
||||
|
||||
chunks = [rchunk("hmm "), rchunk("okay."), _chunk(content="Answer"), _chunk(finish="stop")]
|
||||
provider = OpenAIProvider(client=_StreamClient(chunks))
|
||||
out = list(provider.stream(model="deepseek-v4-pro", messages=[]))
|
||||
assert [c.reasoning_delta for c in out if c.reasoning_delta] == ["hmm ", "okay."]
|
||||
final = out[-1].turn
|
||||
assert final.text == "Answer" and final.reasoning == "hmm okay."
|
||||
|
||||
|
||||
def test_complete_picks_up_reasoning_content():
|
||||
message = SimpleNamespace(content="Answer", tool_calls=None, reasoning_content="deep thought")
|
||||
choice = SimpleNamespace(message=message, finish_reason="stop")
|
||||
provider = OpenAIProvider(client=_FakeClient(SimpleNamespace(choices=[choice])))
|
||||
turn = provider.complete(model="deepseek-v4-pro", messages=[{"role": "user", "content": "x"}])
|
||||
assert turn.text == "Answer" and turn.reasoning == "deep thought"
|
||||
|
||||
Reference in New Issue
Block a user