Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions apps/desktop/e2e/desktop-preview.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
37 changes: 37 additions & 0 deletions apps/desktop/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions apps/desktop/src/lib/protocol-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class FakeTransport implements ProtocolTransport {
structuredToolEvents: true,
interactiveRequests: true,
reviewActions: true,
reasoningDeltas: true,
configDiagnostics: true,
diagnosticExport: true,
workspaceDiff: true,
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/lib/protocol-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
35 changes: 35 additions & 0 deletions apps/desktop/src/lib/repl-stream.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
appendReasoningDelta,
appendTextDelta,
appendToolUse,
attachToolResult,
Expand Down Expand Up @@ -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');
});
});
29 changes: 29 additions & 0 deletions apps/desktop/src/lib/repl-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
8 changes: 8 additions & 0 deletions apps/desktop/src/preview-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise<void> {
structuredToolEvents: true,
interactiveRequests: true,
reviewActions: true,
reasoningDeltas: true,
workspaceDiff: true,
configDiagnostics: true,
},
Expand Down Expand Up @@ -396,6 +397,13 @@ async function handleProtocolRequest(request: ProtocolRequest): Promise<void> {
// 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,
Expand Down
29 changes: 29 additions & 0 deletions apps/desktop/src/screens/Repl.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ?? {};
Expand Down Expand Up @@ -1123,6 +1127,7 @@ function renderMessage(
<div className="body">
<div className="author">DeepCode</div>
<div className="content">
{m.turn.reasoning ? <ReasoningBlock text={m.turn.reasoning} /> : null}
{m.turn.text}
{m.turn.streaming && isActive && <span className="streaming-cursor" />}
{m.turn.tools.map((t) => (
Expand Down Expand Up @@ -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 (
<details className="reasoning" open={open} onToggle={(e) => setOpen(e.currentTarget.open)}>
<summary>
thinking
<span className="reasoning-meta">
{' · '}
{lines} line{lines === 1 ? '' : 's'}
</span>
</summary>
<div className="reasoning-body">{text}</div>
</details>
);
}
1 change: 1 addition & 0 deletions apps/lsp/src/handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const capabilities: InitializeResult = {
structuredToolEvents: true,
interactiveRequests: true,
reviewActions: true,
reasoningDeltas: true,
configDiagnostics: true,
diagnosticExport: true,
workspaceDiff: true,
Expand Down
6 changes: 6 additions & 0 deletions apps/server/src/runtime-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 11 additions & 0 deletions apps/server/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export interface TurnExecutionArgs {
input: Record<string, unknown>;
signal: AbortSignal;
publishDelta: (itemId: string, delta: string) => void;
publishReasoning: (itemId: string, delta: string) => void;
publishToolStarted: (itemId: string, name: string, input: Record<string, unknown>) => void;
publishToolCompleted: (itemId: string, result: { content: string; isError?: boolean }) => void;
publishUsage: (usage: {
Expand Down Expand Up @@ -127,6 +128,7 @@ export class AppServer {
diagnosticExport: options.diagnosticExport !== undefined,
workspaceDiff: options.workspaceDiff !== undefined,
reviewActions: true,
reasoningDeltas: true,
});
}

Expand Down Expand Up @@ -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',
Expand Down
1 change: 1 addition & 0 deletions apps/vscode/src/protocol-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ class FakeClient {
structuredToolEvents: true,
interactiveRequests: true,
reviewActions: true,
reasoningDeltas: true,
configDiagnostics: true,
diagnosticExport: true,
workspaceDiff: true,
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ describe('ProtocolRuntime', () => {
structuredToolEvents: true,
interactiveRequests: true,
reviewActions: false,
reasoningDeltas: false,
configDiagnostics: false,
diagnosticExport: false,
workspaceDiff: false,
Expand Down
8 changes: 8 additions & 0 deletions packages/protocol/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
type DurableProtocolEvent,
type InitializeResult,
type ProtocolEvent,
type ReasoningDeltaEvent,
type ThreadSnapshot,
type TransientDeltaEvent,
type TurnSnapshot,
Expand Down Expand Up @@ -45,6 +46,7 @@ export interface ProtocolRuntimeOptions {
diagnosticExport?: boolean;
workspaceDiff?: boolean;
reviewActions?: boolean;
reasoningDeltas?: boolean;
}

export class ProtocolInvariantError extends Error {
Expand Down Expand Up @@ -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,
},
};
}
Expand Down Expand Up @@ -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<ReasoningDeltaEvent, 'type'>): void {
this.emit({ type: 'reasoning.delta', ...event });
}

completeTurn(threadId: string, turnId: string): Promise<TurnSnapshot> {
return this.finishTurn(threadId, turnId, 'completed');
}
Expand Down
20 changes: 20 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -137,6 +155,8 @@ export interface InitializeResult {
diagnosticExport: boolean;
workspaceDiff: boolean;
reviewActions: boolean;
/** Server streams `reasoning.delta` for models that emit reasoning. */
reasoningDeltas: boolean;
};
}

Expand Down
Loading