Skip to content

Commit 580f9c8

Browse files
author
t
committed
feat: add interactive app server protocol
1 parent 1367119 commit 580f9c8

15 files changed

Lines changed: 573 additions & 35 deletions

apps/server/README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
Experimental line-delimited JSON runtime server for DeepCode clients.
44

55
The server owns lifecycle state and delegates model work to `RuntimeHost`. Completed items and
6-
terminal turn state are persisted; streaming deltas are notifications only. The initial transport
6+
terminal turn state are persisted; streaming and interactive requests are notifications only.
7+
Approval and user-input responses are bound to their active thread and turn. The initial transport
78
is single-client stdio, matching the desktop packaging decision in
89
`docs/adr/0001-desktop-runtime-sidecar.md`.
910

apps/server/src/default-runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { RuntimeHostExecutor } from './runtime-executor.js';
77

88
export function createDefaultTurnExecutor(): RuntimeHostExecutor {
99
return new RuntimeHostExecutor({
10-
createHost: async (cwd) => {
10+
createHost: async (cwd, mode) => {
1111
const credentials = await resolveCredentials({ store: new CredentialsStore() });
1212
if (!credentials.apiKey && !credentials.authToken) {
1313
throw new Error(
@@ -22,7 +22,7 @@ export function createDefaultTurnExecutor(): RuntimeHostExecutor {
2222
}),
2323
tools: new ToolRegistry(BUILTIN_TOOLS),
2424
cwd,
25-
mode: 'default',
25+
mode,
2626
permissions: { allow: [...SAFE_READONLY_TOOLS] },
2727
});
2828
},

apps/server/src/runtime-executor.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ import { describe, expect, it } from 'vitest';
1010

1111
import { RuntimeHostExecutor, historyFromThread } from './runtime-executor.js';
1212

13+
function protocolCallbacks() {
14+
return {
15+
publishToolStarted: () => undefined,
16+
publishToolCompleted: () => undefined,
17+
publishUsage: () => undefined,
18+
requestApproval: async () => 'deny' as const,
19+
requestUserInput: async () => '',
20+
};
21+
}
22+
1323
const priorAssistant = {
1424
role: 'assistant' as const,
1525
content: [{ type: 'text' as const, text: 'prior answer' }],
@@ -61,6 +71,28 @@ class StreamingProvider implements Provider {
6171
}
6272
}
6373

74+
class ToolProvider implements Provider {
75+
readonly name = 'tool-test';
76+
calls = 0;
77+
78+
async runTurn(options: ProviderRunOpts): Promise<ProviderResult> {
79+
this.calls++;
80+
if (this.calls === 1) {
81+
return {
82+
content: [{ type: 'tool_use', id: 'tool-1', name: 'WriteTest', input: { value: 'ok' } }],
83+
stopReason: 'tool_use',
84+
usage: { inputTokens: 3, outputTokens: 4, reasoningTokens: 1, cacheReadTokens: 2 },
85+
};
86+
}
87+
options.handlers?.onTextDelta?.('done');
88+
return {
89+
content: [{ type: 'text', text: 'done' }],
90+
stopReason: 'end_turn',
91+
usage: { inputTokens: 5, outputTokens: 6, reasoningTokens: 0, cacheReadTokens: 0 },
92+
};
93+
}
94+
}
95+
6496
describe('RuntimeHostExecutor', () => {
6597
it('reconstructs history and returns only messages created by the new turn', async () => {
6698
const provider = new StreamingProvider();
@@ -85,6 +117,7 @@ describe('RuntimeHostExecutor', () => {
85117
input: { text: 'current question' },
86118
signal: new AbortController().signal,
87119
publishDelta: (_itemId, delta) => deltas.push(delta),
120+
...protocolCallbacks(),
88121
});
89122

90123
expect(provider.seenMessages).toEqual([
@@ -133,4 +166,55 @@ describe('RuntimeHostExecutor', () => {
133166

134167
expect(historyFromThread(withError)).toHaveLength(2);
135168
});
169+
170+
it('projects tool, usage, and approval activity onto protocol callbacks', async () => {
171+
const provider = new ToolProvider();
172+
const tools = new ToolRegistry();
173+
tools.register({
174+
name: 'WriteTest',
175+
definition: { name: 'WriteTest', description: 'test', inputSchema: { type: 'object' } },
176+
execute: async () => ({ content: 'wrote test value' }),
177+
});
178+
const host = new RuntimeHost({ provider, tools, cwd: '/workspace', mode: 'default' });
179+
const executor = new RuntimeHostExecutor({ createHost: () => host });
180+
const turn: TurnSnapshot = {
181+
id: 'turn-tool',
182+
threadId: thread.id,
183+
status: 'in_progress',
184+
startedAt: '2026-08-01T00:00:02.000Z',
185+
items: [],
186+
};
187+
const started: string[] = [];
188+
const completed: string[] = [];
189+
const usage: number[] = [];
190+
const approvals: string[] = [];
191+
192+
const result = await executor.execute({
193+
thread,
194+
turn,
195+
input: { text: 'write it', effort: 'low' },
196+
signal: new AbortController().signal,
197+
publishDelta: () => undefined,
198+
publishToolStarted: (itemId) => started.push(itemId),
199+
publishToolCompleted: (itemId) => completed.push(itemId),
200+
publishUsage: (value) => usage.push(value.inputTokens),
201+
requestApproval: async (toolName) => {
202+
approvals.push(toolName);
203+
return 'allow';
204+
},
205+
requestUserInput: async () => '',
206+
});
207+
208+
expect(started).toEqual(['tool-1']);
209+
expect(completed).toEqual(['tool-1']);
210+
expect(usage).toEqual([3, 5]);
211+
expect(approvals).toEqual(['WriteTest']);
212+
expect(result.items).toEqual(
213+
expect.arrayContaining([
214+
expect.objectContaining({ type: 'approval' }),
215+
expect.objectContaining({ type: 'assistant_message' }),
216+
expect.objectContaining({ type: 'tool_result' }),
217+
]),
218+
);
219+
});
136220
});

apps/server/src/runtime-executor.ts

Lines changed: 74 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
1-
import { type AgentEvent, type RuntimeHost, type StoredMessage } from '@deepcode/core';
1+
import {
2+
type AgentEvent,
3+
type Effort,
4+
type Mode,
5+
type RuntimeHost,
6+
type StoredMessage,
7+
} from '@deepcode/core';
8+
import { EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js';
29
import type { CompletedItem, ThreadSnapshot } from '@deepcode/protocol';
310

411
import type { TurnExecutionArgs, TurnExecutionItem, TurnExecutor } from './server.js';
512

613
export interface RuntimeHostExecutorOptions {
7-
createHost: (cwd: string) => Promise<RuntimeHost> | RuntimeHost;
14+
createHost: (cwd: string, mode: Mode) => Promise<RuntimeHost> | RuntimeHost;
815
systemPrompt?: string;
916
model?: string;
1017
}
@@ -16,29 +23,71 @@ export class RuntimeHostExecutor implements TurnExecutor {
1623
constructor(private readonly options: RuntimeHostExecutorOptions) {}
1724

1825
async execute(args: TurnExecutionArgs) {
19-
const host = await this.options.createHost(args.thread.cwd);
26+
const mode = parseMode(args.input.mode);
27+
const host = await this.options.createHost(args.thread.cwd, mode);
2028
const history = historyFromThread(args.thread);
2129
const baselineLength = history.length;
2230
const text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input);
2331
const streamingItemId = `${args.turn.id}-assistant`;
2432
const events: AgentEvent[] = [];
33+
const interactionItems: TurnExecutionItem[] = [];
34+
const effort = parseEffort(args.input.effort);
35+
const effortParams = EFFORT_PARAMS[effort];
2536
const result = await host.run({
2637
cwd: args.thread.cwd,
2738
systemPrompt: this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT,
2839
userMessage: text,
2940
history,
30-
model: this.options.model ?? 'deepseek-chat',
41+
model:
42+
typeof args.input.model === 'string'
43+
? args.input.model
44+
: (this.options.model ?? 'deepseek-chat'),
45+
maxTokens: effortParams.maxTokens,
46+
temperature: effortParams.temperature,
3147
signal: args.signal,
3248
systemReminders: false,
33-
approval: async () => false,
49+
approval: async (toolName, _input, verdict) => {
50+
const decision = await args.requestApproval(
51+
toolName,
52+
verdict.reason ?? `Approve ${toolName}?`,
53+
);
54+
interactionItems.push({
55+
type: 'approval',
56+
payload: { toolName, decision, reason: verdict.reason },
57+
});
58+
return decision === 'always' ? 'always' : decision === 'allow';
59+
},
60+
askUser: async (request) => {
61+
const answer = await args.requestUserInput(request);
62+
interactionItems.push({ type: 'ask_user', payload: { ...request, answer } });
63+
return answer;
64+
},
3465
onEvent: (event) => {
3566
events.push(event);
36-
if (event.type === 'text_delta') args.publishDelta(streamingItemId, event.text);
67+
switch (event.type) {
68+
case 'text_delta':
69+
args.publishDelta(streamingItemId, event.text);
70+
break;
71+
case 'tool_use':
72+
args.publishToolStarted(event.id, event.name, event.input);
73+
break;
74+
case 'tool_result':
75+
args.publishToolCompleted(event.id, event.result);
76+
break;
77+
case 'usage':
78+
args.publishUsage({
79+
inputTokens: event.inputTokens,
80+
outputTokens: event.outputTokens,
81+
reasoningTokens: event.reasoningTokens,
82+
cacheReadTokens: event.cacheReadTokens,
83+
});
84+
break;
85+
}
3786
},
3887
});
3988

4089
const newMessages = result.history.slice(baselineLength);
41-
const items = completedItemsFromMessages(newMessages, text);
90+
const items = [...interactionItems, ...completedItemsFromMessages(newMessages, text)];
4291
if (result.stopReason === 'error') {
4392
const error = [...events].reverse().find((event) => event.type === 'error');
4493
if (error?.type === 'error') items.push({ type: 'error', payload: { message: error.error } });
@@ -50,6 +99,24 @@ export class RuntimeHostExecutor implements TurnExecutor {
5099
}
51100
}
52101

102+
const MODES = new Set<Mode>([
103+
'default',
104+
'acceptEdits',
105+
'plan',
106+
'auto',
107+
'dontAsk',
108+
'bypassPermissions',
109+
]);
110+
const EFFORTS = new Set<Effort>(['low', 'medium', 'high', 'xhigh', 'max']);
111+
112+
function parseMode(value: unknown): Mode {
113+
return typeof value === 'string' && MODES.has(value as Mode) ? (value as Mode) : 'default';
114+
}
115+
116+
function parseEffort(value: unknown): Effort {
117+
return typeof value === 'string' && EFFORTS.has(value as Effort) ? (value as Effort) : 'high';
118+
}
119+
53120
export function historyFromThread(thread: ThreadSnapshot): StoredMessage[] {
54121
const history: StoredMessage[] = [];
55122
for (const turn of thread.turns) {

apps/server/src/server.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,11 @@ describe('AppServer', () => {
5656
it('persists completed items and terminal state while publishing deltas transiently', async () => {
5757
const events: ProtocolEvent[] = [];
5858
const executor: TurnExecutor = {
59-
execute: async ({ publishDelta }) => {
59+
execute: async ({ publishDelta, publishToolStarted, publishToolCompleted, publishUsage }) => {
6060
publishDelta('assistant-stream', 'hel');
61+
publishToolStarted('tool-1', 'Read', { file_path: 'README.md' });
62+
publishToolCompleted('tool-1', { content: 'contents' });
63+
publishUsage({ inputTokens: 1, outputTokens: 2 });
6164
return {
6265
items: [{ type: 'assistant_message', payload: { text: 'hello' } }],
6366
};
@@ -94,6 +97,9 @@ describe('AppServer', () => {
9497
}),
9598
});
9699
expect(events.map((event) => event.type)).toContain('item.delta');
100+
expect(events.map((event) => event.type)).toEqual(
101+
expect.arrayContaining(['tool.started', 'tool.completed', 'usage.updated']),
102+
);
97103
expect((read.result as { turns: Array<{ items: unknown[] }> }).turns[0]?.items).toHaveLength(2);
98104
});
99105

@@ -136,6 +142,102 @@ describe('AppServer', () => {
136142
expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(0);
137143
});
138144

145+
it('round-trips approval and user-input requests through the active turn', async () => {
146+
const events: ProtocolEvent[] = [];
147+
const responses: string[] = [];
148+
const executor: TurnExecutor = {
149+
execute: async ({ requestApproval, requestUserInput }) => {
150+
responses.push(await requestApproval('Bash', 'Run tests?'));
151+
responses.push(
152+
await requestUserInput({
153+
question: 'Choose scope',
154+
options: [{ label: 'All', description: 'Run every test' }],
155+
}),
156+
);
157+
return {};
158+
},
159+
};
160+
const server = new AppServer({ executor, onEvent: (event) => events.push(event) });
161+
const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' }));
162+
const threadId = (thread.result as { id: string }).id;
163+
const started = await server.handle(
164+
request(2, 'turn/start', { threadId, input: { text: 'test' } }),
165+
);
166+
const turnId = (started.result as { id: string }).id;
167+
const approval = events.find((event) => event.type === 'approval.requested');
168+
expect(approval).toEqual(
169+
expect.objectContaining({ type: 'approval.requested', threadId, turnId, toolName: 'Bash' }),
170+
);
171+
172+
await expect(
173+
server.handle(
174+
request(3, 'approval/respond', {
175+
threadId,
176+
turnId,
177+
requestId: approval?.type === 'approval.requested' ? approval.requestId : '',
178+
decision: 'allow',
179+
}),
180+
),
181+
).resolves.toEqual({ id: 3, result: { accepted: true } });
182+
await Promise.resolve();
183+
184+
const question = events.find((event) => event.type === 'user-input.requested');
185+
expect(question).toEqual(
186+
expect.objectContaining({ type: 'user-input.requested', threadId, turnId }),
187+
);
188+
await server.handle(
189+
request(4, 'user-input/respond', {
190+
threadId,
191+
turnId,
192+
requestId: question?.type === 'user-input.requested' ? question.requestId : '',
193+
answer: 'All',
194+
}),
195+
);
196+
await server.waitForIdle();
197+
198+
expect(responses).toEqual(['allow', 'All']);
199+
expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(1);
200+
await expect(
201+
server.handle(
202+
request(5, 'approval/respond', {
203+
threadId,
204+
turnId,
205+
requestId: approval?.type === 'approval.requested' ? approval.requestId : '',
206+
decision: 'allow',
207+
}),
208+
),
209+
).resolves.toEqual({
210+
id: 5,
211+
error: expect.objectContaining({ code: 'invalid_request' }),
212+
});
213+
});
214+
215+
it('releases a pending interaction when its turn is interrupted', async () => {
216+
let decision: string | undefined;
217+
const executor: TurnExecutor = {
218+
execute: async ({ requestApproval }) => {
219+
decision = await requestApproval('Bash', 'Run forever?');
220+
return {};
221+
},
222+
};
223+
const server = new AppServer({ executor });
224+
const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' }));
225+
const threadId = (thread.result as { id: string }).id;
226+
const started = await server.handle(
227+
request(2, 'turn/start', { threadId, input: { text: 'wait' } }),
228+
);
229+
const turnId = (started.result as { id: string }).id;
230+
231+
await server.handle(request(3, 'turn/interrupt', { threadId, turnId }));
232+
await server.waitForIdle();
233+
234+
expect(decision).toBe('deny');
235+
const read = await server.handle(request(4, 'thread/read', { threadId }));
236+
expect(read.result).toEqual(
237+
expect.objectContaining({ turns: [expect.objectContaining({ status: 'interrupted' })] }),
238+
);
239+
});
240+
139241
it('marks an orphaned active turn interrupted when a new process resumes it', async () => {
140242
const root = await mkdtemp(join(tmpdir(), 'deepcode-app-server-'));
141243
temporaryRoots.push(root);

0 commit comments

Comments
 (0)