diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts index 491c671..87389d0 100644 --- a/apps/desktop/e2e/desktop-preview.spec.ts +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -48,6 +48,13 @@ test('resumes a thread and completes an approval-gated protocol turn', async ({ const approve = page.getByRole('button', { name: /^Approve \(↵\)$/ }); await expect(approve).toBeVisible(); await expect(main.getByText(/I’ll update the game safely\./)).toBeVisible(); + + // Reasoning arrives as its own channel: collapsed, not mixed into the answer. + const reasoning = main.locator('details.reasoning').first(); + await expect(reasoning).toBeVisible(); + await expect(reasoning.getByText(/The boss needs a phase field\./)).toBeHidden(); + await reasoning.getByText('thinking').click(); + await expect(reasoning.getByText(/The boss needs a phase field\./)).toBeVisible(); await expect(main.locator('.tool-card').filter({ hasText: 'Edit' }).last()).toBeVisible(); await approve.click(); diff --git a/apps/desktop/src/index.css b/apps/desktop/src/index.css index a314ad5..6f97def 100644 --- a/apps/desktop/src/index.css +++ b/apps/desktop/src/index.css @@ -1973,3 +1973,40 @@ select { .ch-text { overflow-x: auto; } + +/* ── Reasoning ─────────────────────────────────────────────────────────── + A dim, collapsed side channel above the answer: reasoner output is long and + is not the response, but dropping it entirely hid the most useful thing + DeepSeek's reasoner emits. */ +.reasoning { + margin: 0 0 8px; + border-left: 2px solid var(--line); + padding-left: 10px; +} +.reasoning > summary { + cursor: pointer; + color: var(--text-3); + font-size: 11.5px; + list-style: none; + user-select: none; +} +.reasoning > summary::-webkit-details-marker { + display: none; +} +.reasoning > summary::before { + content: '▸ '; +} +.reasoning[open] > summary::before { + content: '▾ '; +} +.reasoning-meta { + opacity: 0.7; +} +.reasoning-body { + margin-top: 6px; + color: var(--text-3); + font-size: 12px; + white-space: pre-wrap; + max-height: 320px; + overflow-y: auto; +} diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts index 3d394cf..dcfc1ad 100644 --- a/apps/desktop/src/lib/protocol-agent.test.ts +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -27,6 +27,7 @@ class FakeTransport implements ProtocolTransport { structuredToolEvents: true, interactiveRequests: true, reviewActions: true, + reasoningDeltas: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts index acc6b37..a561268 100644 --- a/apps/desktop/src/lib/protocol-agent.ts +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -210,6 +210,14 @@ export class DesktopProtocolAgent { case 'item.delta': this.emit({ kind: 'event', turnId: event.turnId, type: 'text_delta', text: event.delta }); break; + case 'reasoning.delta': + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'thinking_delta', + text: event.delta, + }); + break; case 'tool.started': this.emit({ kind: 'event', diff --git a/apps/desktop/src/lib/repl-stream.test.ts b/apps/desktop/src/lib/repl-stream.test.ts index 6ca8d01..ceb5d7d 100644 --- a/apps/desktop/src/lib/repl-stream.test.ts +++ b/apps/desktop/src/lib/repl-stream.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + appendReasoningDelta, appendTextDelta, appendToolUse, attachToolResult, @@ -281,3 +282,37 @@ describe('threadReviewItems', () => { expect(actions).toEqual([{ actionId: 'a1', kind: 'apply' }]); }); }); + +describe('appendReasoningDelta', () => { + it('opens a turn when reasoning arrives before any answer text', () => { + const msgs = appendReasoningDelta([], 'first thought'); + expect(msgs).toHaveLength(1); + const a = msgs[0] as AssistantMsg; + expect(a.turn.reasoning).toBe('first thought'); + expect(a.turn.text).toBe(''); + expect(a.turn.streaming).toBe(true); + }); + + it('accumulates into the open turn without touching the answer', () => { + let msgs = appendReasoningDelta([], 'a'); + msgs = appendReasoningDelta(msgs, 'b'); + msgs = appendTextDelta(msgs, 'answer'); + const a = msgs[0] as AssistantMsg; + expect(a.turn.reasoning).toBe('ab'); + expect(a.turn.text).toBe('answer'); + expect(msgs).toHaveLength(1); + }); + + it('starts a new turn when the previous one has finished', () => { + let msgs = appendTextDelta([], 'done'); + msgs = finalizeStreaming(msgs); + msgs = appendReasoningDelta(msgs, 'next turn thinking'); + expect(msgs).toHaveLength(2); + expect((msgs[1] as AssistantMsg).turn.reasoning).toBe('next turn thinking'); + }); + + it('does not leak reasoning into the answer text', () => { + const msgs = appendTextDelta(appendReasoningDelta([], 'secret plan'), 'visible'); + expect((msgs[0] as AssistantMsg).turn.text).toBe('visible'); + }); +}); diff --git a/apps/desktop/src/lib/repl-stream.ts b/apps/desktop/src/lib/repl-stream.ts index f8cfbb5..f082f04 100644 --- a/apps/desktop/src/lib/repl-stream.ts +++ b/apps/desktop/src/lib/repl-stream.ts @@ -20,6 +20,13 @@ export interface ToolInvocation { export interface AssistantTurn { text: string; + /** + * The model's reasoning for this turn, when it produces any. Kept separate + * from `text` so it can be rendered as a distinct, collapsible channel — it + * is not the answer, and concatenating it into the answer is how it used to + * get dropped instead. + */ + reasoning?: string; /** Tool calls interleaved during this turn — rendered as cards after the text. */ tools: ToolInvocation[]; streaming: boolean; @@ -64,6 +71,28 @@ export function appendTextDelta(msgs: Msg[], delta: string): Msg[] { return [...msgs, { role: 'assistant', turn: { text: delta, tools: [], streaming: true } }]; } +/** + * Append a reasoning delta to the open assistant turn, opening one if needed. + * Reasoning usually arrives *before* any answer text, so this has to be able to + * start the turn on its own. + */ +export function appendReasoningDelta(msgs: Msg[], delta: string): Msg[] { + const idx = lastAssistantIndex(msgs); + const target = idx === -1 ? null : (msgs[idx] as AssistantMsg); + if (target && target.turn.streaming) { + const copy = [...msgs]; + copy[idx] = { + role: 'assistant', + turn: { ...target.turn, reasoning: (target.turn.reasoning ?? '') + delta }, + }; + return copy; + } + return [ + ...msgs, + { role: 'assistant', turn: { text: '', reasoning: delta, tools: [], streaming: true } }, + ]; +} + /** Append a tool invocation to the open assistant turn (same anti-split rule). */ export function appendToolUse(msgs: Msg[], tool: ToolInvocation): Msg[] { const idx = lastAssistantIndex(msgs); diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 785f5c1..528f0ea 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -271,6 +271,7 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise { structuredToolEvents: true, interactiveRequests: true, reviewActions: true, + reasoningDeltas: true, workspaceDiff: true, configDiagnostics: true, }, @@ -396,6 +397,13 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise { // Emit before the response to exercise the renderer's fast-turn buffer. await sendEvent({ type: 'turn.started', threadId: activeThreadId, turn: activeTurn }); await respond(activeTurn); + await sendEvent({ + type: 'reasoning.delta', + threadId: activeThreadId, + turnId, + itemId: 'assistant-reasoning', + delta: 'The boss needs a phase field.\nChecking how spawnBoss reads it.', + }); await sendEvent({ type: 'item.delta', threadId: activeThreadId, diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index 5280f96..1b7aa41 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -46,6 +46,7 @@ import { projectName } from '../lib/project.js'; import { useVoice } from '../lib/use-voice.js'; import { insertTranscript } from '../lib/voice.js'; import { + appendReasoningDelta, appendTextDelta, appendToolUse, attachToolResult, @@ -366,6 +367,9 @@ export function ReplScreen({ case 'text_delta': setMessages((m) => appendTextDelta(m, e.text ?? '')); break; + case 'thinking_delta': + setMessages((m) => appendReasoningDelta(m, e.text ?? '')); + break; case 'tool_use': { const name = e.name ?? '?'; const input = e.input ?? {}; @@ -1123,6 +1127,7 @@ function renderMessage(
DeepCode
+ {m.turn.reasoning ? : null} {m.turn.text} {m.turn.streaming && isActive && } {m.turn.tools.map((t) => ( @@ -1191,3 +1196,27 @@ function abbreviatePath(p: string): string { function truncate(s: string, n: number): string { return s.length > n ? s.slice(0, n) + '…\n[truncated]' : s; } + +/** + * The model's reasoning, as a collapsed side channel. + * + * Collapsed by default: reasoner output is long and is not the answer. Open on + * click, and while a turn is still streaming it is often the only thing to look + * at, so the summary line reports its length rather than staying silent. + */ +function ReasoningBlock({ text }: { text: string }): JSX.Element { + const [open, setOpen] = useState(false); + const lines = text.split('\n').length; + return ( +
setOpen(e.currentTarget.open)}> + + thinking + + {' · '} + {lines} line{lines === 1 ? '' : 's'} + + +
{text}
+
+ ); +} diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index c8364bd..6e27035 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -22,6 +22,7 @@ const capabilities: InitializeResult = { structuredToolEvents: true, interactiveRequests: true, reviewActions: true, + reasoningDeltas: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts index 17720e8..9d47b69 100644 --- a/apps/server/src/runtime-executor.ts +++ b/apps/server/src/runtime-executor.ts @@ -134,6 +134,12 @@ export class RuntimeHostExecutor implements TurnExecutor { case 'text_delta': args.publishDelta(streamingItemId, event.text); break; + case 'thinking_delta': + // Reasoning went nowhere: the protocol carried only + // reasoningTokens, so DeepSeek's reasoner produced its most + // useful output and every client dropped it. + args.publishReasoning(`${streamingItemId}-reasoning`, event.text); + break; case 'tool_use': args.publishToolStarted(event.id, event.name, event.input); break; diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 5dfe261..5113d9d 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -36,6 +36,7 @@ export interface TurnExecutionArgs { input: Record; signal: AbortSignal; publishDelta: (itemId: string, delta: string) => void; + publishReasoning: (itemId: string, delta: string) => void; publishToolStarted: (itemId: string, name: string, input: Record) => void; publishToolCompleted: (itemId: string, result: { content: string; isError?: boolean }) => void; publishUsage: (usage: { @@ -127,6 +128,7 @@ export class AppServer { diagnosticExport: options.diagnosticExport !== undefined, workspaceDiff: options.workspaceDiff !== undefined, reviewActions: true, + reasoningDeltas: true, }); } @@ -382,6 +384,15 @@ export class AppServer { delta, }); }, + publishReasoning: (itemId, delta) => { + this.lifecycle.publishReasoning({ + traceId, + threadId: thread.id, + turnId: turn.id, + itemId, + delta, + }); + }, publishToolStarted: (itemId, name, input) => { this.options.onEvent?.({ type: 'tool.started', diff --git a/apps/vscode/src/protocol-runtime.test.ts b/apps/vscode/src/protocol-runtime.test.ts index 21d384f..3450fe7 100644 --- a/apps/vscode/src/protocol-runtime.test.ts +++ b/apps/vscode/src/protocol-runtime.test.ts @@ -42,6 +42,7 @@ class FakeClient { structuredToolEvents: true, interactiveRequests: true, reviewActions: true, + reasoningDeltas: true, configDiagnostics: true, diagnosticExport: true, workspaceDiff: true, diff --git a/packages/protocol/src/runtime.test.ts b/packages/protocol/src/runtime.test.ts index faad833..a5834c7 100644 --- a/packages/protocol/src/runtime.test.ts +++ b/packages/protocol/src/runtime.test.ts @@ -36,6 +36,7 @@ describe('ProtocolRuntime', () => { structuredToolEvents: true, interactiveRequests: true, reviewActions: false, + reasoningDeltas: false, configDiagnostics: false, diagnosticExport: false, workspaceDiff: false, diff --git a/packages/protocol/src/runtime.ts b/packages/protocol/src/runtime.ts index e264d88..a735906 100644 --- a/packages/protocol/src/runtime.ts +++ b/packages/protocol/src/runtime.ts @@ -5,6 +5,7 @@ import { type DurableProtocolEvent, type InitializeResult, type ProtocolEvent, + type ReasoningDeltaEvent, type ThreadSnapshot, type TransientDeltaEvent, type TurnSnapshot, @@ -45,6 +46,7 @@ export interface ProtocolRuntimeOptions { diagnosticExport?: boolean; workspaceDiff?: boolean; reviewActions?: boolean; + reasoningDeltas?: boolean; } export class ProtocolInvariantError extends Error { @@ -84,6 +86,7 @@ export class ProtocolRuntime { diagnosticExport: this.options.diagnosticExport ?? false, workspaceDiff: this.options.workspaceDiff ?? false, reviewActions: this.options.reviewActions ?? false, + reasoningDeltas: this.options.reasoningDeltas ?? false, }, }; } @@ -172,6 +175,11 @@ export class ProtocolRuntime { this.emit({ type: 'item.delta', ...event }); } + /** Reasoning stream — transient like item.delta, never persisted as an item. */ + publishReasoning(event: Omit): void { + this.emit({ type: 'reasoning.delta', ...event }); + } + completeTurn(threadId: string, turnId: string): Promise { return this.finishTurn(threadId, turnId, 'completed'); } diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index b4c72bd..65a7aba 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -114,8 +114,26 @@ export interface TransientDeltaEvent { delta: string; } +/** + * A reasoning delta from a model that produces one (DeepSeek's reasoner). + * + * Separate from `item.delta` rather than a flag on it: reasoning is not the + * answer, it is never persisted as a completed item, and a client that doesn't + * understand it must be able to drop it without accidentally rendering it as + * assistant text. Gated by the `reasoningDeltas` capability. + */ +export interface ReasoningDeltaEvent { + type: 'reasoning.delta'; + traceId?: string; + threadId: string; + turnId: string; + itemId: string; + delta: string; +} + export type TransientProtocolEvent = | TransientDeltaEvent + | ReasoningDeltaEvent | ToolStartedEvent | ToolCompletedEvent | UsageUpdatedEvent @@ -137,6 +155,8 @@ export interface InitializeResult { diagnosticExport: boolean; workspaceDiff: boolean; reviewActions: boolean; + /** Server streams `reasoning.delta` for models that emit reasoning. */ + reasoningDeltas: boolean; }; }