Skip to content

Commit bd0a7ee

Browse files
tclaude
andcommitted
feat(protocol): carry reasoning, and show it in the desktop
The protocol carried reasoningTokens and nothing else, so DeepSeek's reasoner produced its most distinctive output and every client dropped it. The CLI got this in #218; the desktop could not, because there was nothing on the wire to render. Adds a `reasoning.delta` transient event behind a `reasoningDeltas` capability. 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 the type has to be able to drop it rather than accidentally render it as assistant text. The app-server forwards the agent loop's thinking_delta events; the desktop projects them onto the assistant turn as a distinct `reasoning` field — kept out of `text` precisely so it can be rendered as its own channel — and shows a collapsed `▸ thinking · N lines` block above the answer. Collapsed because reasoner output is long and is not the response; the line count is there because while a turn streams it is often the only thing to look at. VS Code and the LSP bridge forward protocol events unchanged, so they receive the new event without changes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 3d27582 commit bd0a7ee

15 files changed

Lines changed: 199 additions & 0 deletions

File tree

apps/desktop/e2e/desktop-preview.spec.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@ test('resumes a thread and completes an approval-gated protocol turn', async ({
4848
const approve = page.getByRole('button', { name: /^Approve \(\)$/ });
4949
await expect(approve).toBeVisible();
5050
await expect(main.getByText(/Ill update the game safely\./)).toBeVisible();
51+
52+
// Reasoning arrives as its own channel: collapsed, not mixed into the answer.
53+
const reasoning = main.locator('details.reasoning').first();
54+
await expect(reasoning).toBeVisible();
55+
await expect(reasoning.getByText(/The boss needs a phase field\./)).toBeHidden();
56+
await reasoning.getByText('thinking').click();
57+
await expect(reasoning.getByText(/The boss needs a phase field\./)).toBeVisible();
5158
await expect(main.locator('.tool-card').filter({ hasText: 'Edit' }).last()).toBeVisible();
5259

5360
await approve.click();

apps/desktop/src/index.css

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1973,3 +1973,40 @@ select {
19731973
.ch-text {
19741974
overflow-x: auto;
19751975
}
1976+
1977+
/* ── Reasoning ───────────────────────────────────────────────────────────
1978+
A dim, collapsed side channel above the answer: reasoner output is long and
1979+
is not the response, but dropping it entirely hid the most useful thing
1980+
DeepSeek's reasoner emits. */
1981+
.reasoning {
1982+
margin: 0 0 8px;
1983+
border-left: 2px solid var(--line);
1984+
padding-left: 10px;
1985+
}
1986+
.reasoning > summary {
1987+
cursor: pointer;
1988+
color: var(--text-3);
1989+
font-size: 11.5px;
1990+
list-style: none;
1991+
user-select: none;
1992+
}
1993+
.reasoning > summary::-webkit-details-marker {
1994+
display: none;
1995+
}
1996+
.reasoning > summary::before {
1997+
content: '▸ ';
1998+
}
1999+
.reasoning[open] > summary::before {
2000+
content: '▾ ';
2001+
}
2002+
.reasoning-meta {
2003+
opacity: 0.7;
2004+
}
2005+
.reasoning-body {
2006+
margin-top: 6px;
2007+
color: var(--text-3);
2008+
font-size: 12px;
2009+
white-space: pre-wrap;
2010+
max-height: 320px;
2011+
overflow-y: auto;
2012+
}

apps/desktop/src/lib/protocol-agent.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class FakeTransport implements ProtocolTransport {
2727
structuredToolEvents: true,
2828
interactiveRequests: true,
2929
reviewActions: true,
30+
reasoningDeltas: true,
3031
configDiagnostics: true,
3132
diagnosticExport: true,
3233
workspaceDiff: true,

apps/desktop/src/lib/protocol-agent.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ export class DesktopProtocolAgent {
210210
case 'item.delta':
211211
this.emit({ kind: 'event', turnId: event.turnId, type: 'text_delta', text: event.delta });
212212
break;
213+
case 'reasoning.delta':
214+
this.emit({
215+
kind: 'event',
216+
turnId: event.turnId,
217+
type: 'thinking_delta',
218+
text: event.delta,
219+
});
220+
break;
213221
case 'tool.started':
214222
this.emit({
215223
kind: 'event',

apps/desktop/src/lib/repl-stream.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from 'vitest';
22
import {
3+
appendReasoningDelta,
34
appendTextDelta,
45
appendToolUse,
56
attachToolResult,
@@ -279,5 +280,36 @@ describe('threadReviewItems', () => {
279280
});
280281
expect(findings).toEqual([{ findingId: 'f1' }]);
281282
expect(actions).toEqual([{ actionId: 'a1', kind: 'apply' }]);
283+
describe('appendReasoningDelta', () => {
284+
it('opens a turn when reasoning arrives before any answer text', () => {
285+
const msgs = appendReasoningDelta([], 'first thought');
286+
expect(msgs).toHaveLength(1);
287+
const a = msgs[0] as AssistantMsg;
288+
expect(a.turn.reasoning).toBe('first thought');
289+
expect(a.turn.text).toBe('');
290+
expect(a.turn.streaming).toBe(true);
291+
});
292+
293+
it('accumulates into the open turn without touching the answer', () => {
294+
let msgs = appendReasoningDelta([], 'a');
295+
msgs = appendReasoningDelta(msgs, 'b');
296+
msgs = appendTextDelta(msgs, 'answer');
297+
const a = msgs[0] as AssistantMsg;
298+
expect(a.turn.reasoning).toBe('ab');
299+
expect(a.turn.text).toBe('answer');
300+
expect(msgs).toHaveLength(1);
301+
});
302+
303+
it('starts a new turn when the previous one has finished', () => {
304+
let msgs = appendTextDelta([], 'done');
305+
msgs = finalizeStreaming(msgs);
306+
msgs = appendReasoningDelta(msgs, 'next turn thinking');
307+
expect(msgs).toHaveLength(2);
308+
expect((msgs[1] as AssistantMsg).turn.reasoning).toBe('next turn thinking');
309+
});
310+
311+
it('does not leak reasoning into the answer text', () => {
312+
const msgs = appendTextDelta(appendReasoningDelta([], 'secret plan'), 'visible');
313+
expect((msgs[0] as AssistantMsg).turn.text).toBe('visible');
282314
});
283315
});

apps/desktop/src/lib/repl-stream.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ export interface ToolInvocation {
2020

2121
export interface AssistantTurn {
2222
text: string;
23+
/**
24+
* The model's reasoning for this turn, when it produces any. Kept separate
25+
* from `text` so it can be rendered as a distinct, collapsible channel — it
26+
* is not the answer, and concatenating it into the answer is how it used to
27+
* get dropped instead.
28+
*/
29+
reasoning?: string;
2330
/** Tool calls interleaved during this turn — rendered as cards after the text. */
2431
tools: ToolInvocation[];
2532
streaming: boolean;
@@ -64,6 +71,28 @@ export function appendTextDelta(msgs: Msg[], delta: string): Msg[] {
6471
return [...msgs, { role: 'assistant', turn: { text: delta, tools: [], streaming: true } }];
6572
}
6673

74+
/**
75+
* Append a reasoning delta to the open assistant turn, opening one if needed.
76+
* Reasoning usually arrives *before* any answer text, so this has to be able to
77+
* start the turn on its own.
78+
*/
79+
export function appendReasoningDelta(msgs: Msg[], delta: string): Msg[] {
80+
const idx = lastAssistantIndex(msgs);
81+
const target = idx === -1 ? null : (msgs[idx] as AssistantMsg);
82+
if (target && target.turn.streaming) {
83+
const copy = [...msgs];
84+
copy[idx] = {
85+
role: 'assistant',
86+
turn: { ...target.turn, reasoning: (target.turn.reasoning ?? '') + delta },
87+
};
88+
return copy;
89+
}
90+
return [
91+
...msgs,
92+
{ role: 'assistant', turn: { text: '', reasoning: delta, tools: [], streaming: true } },
93+
];
94+
}
95+
6796
/** Append a tool invocation to the open assistant turn (same anti-split rule). */
6897
export function appendToolUse(msgs: Msg[], tool: ToolInvocation): Msg[] {
6998
const idx = lastAssistantIndex(msgs);

apps/desktop/src/preview-app.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,7 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise<void> {
271271
structuredToolEvents: true,
272272
interactiveRequests: true,
273273
reviewActions: true,
274+
reasoningDeltas: true,
274275
workspaceDiff: true,
275276
configDiagnostics: true,
276277
},
@@ -396,6 +397,13 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise<void> {
396397
// Emit before the response to exercise the renderer's fast-turn buffer.
397398
await sendEvent({ type: 'turn.started', threadId: activeThreadId, turn: activeTurn });
398399
await respond(activeTurn);
400+
await sendEvent({
401+
type: 'reasoning.delta',
402+
threadId: activeThreadId,
403+
turnId,
404+
itemId: 'assistant-reasoning',
405+
delta: 'The boss needs a phase field.\nChecking how spawnBoss reads it.',
406+
});
399407
await sendEvent({
400408
type: 'item.delta',
401409
threadId: activeThreadId,

apps/desktop/src/screens/Repl.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import { projectName } from '../lib/project.js';
4646
import { useVoice } from '../lib/use-voice.js';
4747
import { insertTranscript } from '../lib/voice.js';
4848
import {
49+
appendReasoningDelta,
4950
appendTextDelta,
5051
appendToolUse,
5152
attachToolResult,
@@ -366,6 +367,9 @@ export function ReplScreen({
366367
case 'text_delta':
367368
setMessages((m) => appendTextDelta(m, e.text ?? ''));
368369
break;
370+
case 'thinking_delta':
371+
setMessages((m) => appendReasoningDelta(m, e.text ?? ''));
372+
break;
369373
case 'tool_use': {
370374
const name = e.name ?? '?';
371375
const input = e.input ?? {};
@@ -1123,6 +1127,7 @@ function renderMessage(
11231127
<div className="body">
11241128
<div className="author">DeepCode</div>
11251129
<div className="content">
1130+
{m.turn.reasoning ? <ReasoningBlock text={m.turn.reasoning} /> : null}
11261131
{m.turn.text}
11271132
{m.turn.streaming && isActive && <span className="streaming-cursor" />}
11281133
{m.turn.tools.map((t) => (
@@ -1191,3 +1196,27 @@ function abbreviatePath(p: string): string {
11911196
function truncate(s: string, n: number): string {
11921197
return s.length > n ? s.slice(0, n) + '…\n[truncated]' : s;
11931198
}
1199+
1200+
/**
1201+
* The model's reasoning, as a collapsed side channel.
1202+
*
1203+
* Collapsed by default: reasoner output is long and is not the answer. Open on
1204+
* click, and while a turn is still streaming it is often the only thing to look
1205+
* at, so the summary line reports its length rather than staying silent.
1206+
*/
1207+
function ReasoningBlock({ text }: { text: string }): JSX.Element {
1208+
const [open, setOpen] = useState(false);
1209+
const lines = text.split('\n').length;
1210+
return (
1211+
<details className="reasoning" open={open} onToggle={(e) => setOpen(e.currentTarget.open)}>
1212+
<summary>
1213+
thinking
1214+
<span className="reasoning-meta">
1215+
{' · '}
1216+
{lines} line{lines === 1 ? '' : 's'}
1217+
</span>
1218+
</summary>
1219+
<div className="reasoning-body">{text}</div>
1220+
</details>
1221+
);
1222+
}

apps/lsp/src/handler.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ const capabilities: InitializeResult = {
2222
structuredToolEvents: true,
2323
interactiveRequests: true,
2424
reviewActions: true,
25+
reasoningDeltas: true,
2526
configDiagnostics: true,
2627
diagnosticExport: true,
2728
workspaceDiff: true,

apps/server/src/runtime-executor.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ export class RuntimeHostExecutor implements TurnExecutor {
134134
case 'text_delta':
135135
args.publishDelta(streamingItemId, event.text);
136136
break;
137+
case 'thinking_delta':
138+
// Reasoning went nowhere: the protocol carried only
139+
// reasoningTokens, so DeepSeek's reasoner produced its most
140+
// useful output and every client dropped it.
141+
args.publishReasoning(`${streamingItemId}-reasoning`, event.text);
142+
break;
137143
case 'tool_use':
138144
args.publishToolStarted(event.id, event.name, event.input);
139145
break;

0 commit comments

Comments
 (0)