diff --git a/coworker/engine.py b/coworker/engine.py index 7e0530f9..3c3e7d8d 100644 --- a/coworker/engine.py +++ b/coworker/engine.py @@ -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). diff --git a/coworker/events.py b/coworker/events.py index 71c659eb..4cdfc192 100644 --- a/coworker/events.py +++ b/coworker/events.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" diff --git a/coworker/providers/base.py b/coworker/providers/base.py index aed82282..a5f263f6 100644 --- a/coworker/providers/base.py +++ b/coworker/providers/base.py @@ -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 diff --git a/coworker/providers/gemini_provider.py b/coworker/providers/gemini_provider.py index 04772a6c..683d21b2 100644 --- a/coworker/providers/gemini_provider.py +++ b/coworker/providers/gemini_provider.py @@ -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), ) ) diff --git a/coworker/providers/openai_provider.py b/coworker/providers/openai_provider.py index 45b0b6fe..42edec85 100644 --- a/coworker/providers/openai_provider.py +++ b/coworker/providers/openai_provider.py @@ -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, ) ) diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 82a52b58..ad9a1690 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -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" }); diff --git a/surfaces/gui/e2e/reasoning.spec.ts b/surfaces/gui/e2e/reasoning.spec.ts new file mode 100644 index 00000000..f8b9fa95 --- /dev/null +++ b/surfaces/gui/e2e/reasoning.spec.ts @@ -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.", + ); +}); diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 2e41e34c..9ae5e0d2 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -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([]); const [sessions, setSessions] = useState([]); const [projects, setProjects] = useState([]); @@ -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 && ( +
+ +
+ )} {running && + !reasoningStream && (!streaming || streamMode(streaming, items, running) === "hold") && !lastItemIsAssistant(items) && } {streaming && streamMode(streaming, items, running) === "answer" && ( diff --git a/surfaces/gui/src/components/Transcript.tsx b/surfaces/gui/src/components/Transcript.tsx index 3551d661..b8927faa 100644 --- a/surfaces/gui/src/components/Transcript.tsx +++ b/surfaces/gui/src/components/Transcript.tsx @@ -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 ( +
+ + {open && ( +
+ {text} +
+ )} +
+ ); +} + type ToolItem = Extract; type ApprovalItem = Extract; type AssistantItem = Extract; @@ -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) { ); case "assistant": + // Thinking-only item (stopped mid-reasoning): just the disclosure, no bubble. + if (!item.text && item.reasoning) + return ( +
+ +
+ ); return (
assistant
+ {item.reasoning && }
diff --git a/surfaces/gui/src/itemsFromMessages.test.ts b/surfaces/gui/src/itemsFromMessages.test.ts index 993c5e8b..ad025475 100644 --- a/surfaces/gui/src/itemsFromMessages.test.ts +++ b/surfaces/gui/src/itemsFromMessages.test.ts @@ -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" }); + }); +}); diff --git a/surfaces/gui/src/itemsFromMessages.ts b/surfaces/gui/src/itemsFromMessages.ts index 1f8b3c98..b13b81e2 100644 --- a/surfaces/gui/src/itemsFromMessages.ts +++ b/surfaces/gui/src/itemsFromMessages.ts @@ -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 { diff --git a/surfaces/gui/src/styles.css b/surfaces/gui/src/styles.css index e01b3f83..a8f091af 100644 --- a/surfaces/gui/src/styles.css +++ b/surfaces/gui/src/styles.css @@ -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; +} diff --git a/surfaces/gui/src/types.ts b/surfaces/gui/src/types.ts index 27f87763..74692a05 100644 --- a/surfaces/gui/src/types.ts +++ b/surfaces/gui/src/types.ts @@ -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"). diff --git a/tests/test_engine_stop.py b/tests/test_engine_stop.py index 38da2d68..cee2c62a 100644 --- a/tests/test_engine_stop.py +++ b/tests/test_engine_stop.py @@ -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 ") diff --git a/tests/test_gemini_provider.py b/tests/test_gemini_provider.py index 4759d628..f356042f 100644 --- a/tests/test_gemini_provider.py +++ b/tests/test_gemini_provider.py @@ -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" diff --git a/tests/test_providers.py b/tests/test_providers.py index 94281f11..66e3bb75 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -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"