diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index 42c68ca..10cdc61 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -42,16 +42,23 @@ describe('handleMessage — executeCommand', () => { const reply = out.find((m) => m.id === 2); expect(reply).toBeDefined(); expect((reply!.result as { turnId: string }).turnId).toMatch(/^lsp-/); - // Async: wait a tick for the setImmediate-emitted events - await new Promise((r) => setImmediate(r)); + // Async: wait for the agent run to finish (will error in test env + // because no DEEPSEEK_API_KEY is set — that's the expected path). + // Poll for turn_done with a timeout. + for (let i = 0; i < 50; i++) { + const done = out.find( + (m) => + m.method === 'deepcode/agentEvent' && + (m.params as { kind: string }).kind === 'turn_done', + ); + if (done) break; + await new Promise((r) => setTimeout(r, 20)); + } const events = out.filter((m) => m.method === 'deepcode/agentEvent'); - expect(events.length).toBeGreaterThanOrEqual(3); - const kinds = events.map( - (e) => (e.params as { kind: string }).kind, - ); - expect(kinds).toContain('text_delta'); + const kinds = events.map((e) => (e.params as { kind: string }).kind); + expect(kinds).toContain('started'); expect(kinds).toContain('turn_done'); - }); + }, 5000); it('errors on missing prompt', async () => { const out: LspMessage[] = []; diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index c740eec..fdea276 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -114,39 +114,97 @@ async function handleExecuteCommand( } async function handleRunAgent( - args: { prompt?: string }, + args: { prompt?: string; model?: string }, send: SendFn, ): Promise<{ turnId: string }> { if (!args.prompt) throw new Error('prompt is required'); const turnId = `lsp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; state.activeTurns.add(turnId); - // Real impl: spawn @deepcode/core's runAgent + stream events back via - // notification. v1.1-rest wires it; here we emit a single ack event - // so clients can confirm the channel. + // Stream events back via JSON-RPC notifications. + // Wired to the real agent loop — same code that drives the CLI / Mac client. send({ jsonrpc: '2.0', method: 'deepcode/agentEvent', params: { turnId, kind: 'started', prompt: args.prompt }, }); - // Schedule a fake completion event so the channel is exercised - setImmediate(() => { - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { - turnId, - kind: 'text_delta', - text: '(LSP-skeleton — wire runAgent in v1.1-rest)', - }, - }); - send({ - jsonrpc: '2.0', - method: 'deepcode/agentEvent', - params: { turnId, kind: 'turn_done', stopReason: 'end_turn' }, - }); - state.activeTurns.delete(turnId); - }); + + // Run async; we return turnId immediately so the LSP client can + // call deepcode.abort while it's in-flight. + void (async () => { + try { + const [ + { runAgent }, + { DeepSeekProvider }, + { ToolRegistry, BUILTIN_TOOLS }, + { resolveCredentials, CredentialsStore }, + ] = await Promise.all([ + import('@deepcode/core').then((m) => ({ runAgent: m.runAgent })), + import('@deepcode/core').then((m) => ({ DeepSeekProvider: m.DeepSeekProvider })), + import('@deepcode/core').then((m) => ({ + ToolRegistry: m.ToolRegistry, + BUILTIN_TOOLS: m.BUILTIN_TOOLS, + })), + import('@deepcode/core').then((m) => ({ + resolveCredentials: m.resolveCredentials, + CredentialsStore: m.CredentialsStore, + })), + ]); + + const creds = await resolveCredentials({ store: new CredentialsStore() }); + if (!creds.apiKey && !creds.authToken) { + throw new Error( + 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', + ); + } + + const provider = new DeepSeekProvider({ + apiKey: creds.apiKey ?? '', + authToken: creds.authToken, + baseURL: creds.baseURL, + }); + + const result = await runAgent({ + provider, + tools: new ToolRegistry(BUILTIN_TOOLS), + systemPrompt: + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', + userMessage: args.prompt!, + model: args.model ?? 'deepseek-chat', + cwd: state.rootUri ? new URL(state.rootUri).pathname : process.cwd(), + onEvent: (e) => { + send({ + jsonrpc: '2.0', + method: 'deepcode/agentEvent', + params: { turnId, kind: e.type, ...e }, + }); + }, + }); + + send({ + jsonrpc: '2.0', + method: 'deepcode/agentEvent', + params: { turnId, kind: 'turn_done', stopReason: result.stopReason }, + }); + } catch (err) { + send({ + jsonrpc: '2.0', + method: 'deepcode/agentEvent', + params: { + turnId, + kind: 'error', + error: (err as Error).message ?? String(err), + }, + }); + send({ + jsonrpc: '2.0', + method: 'deepcode/agentEvent', + params: { turnId, kind: 'turn_done', stopReason: 'error' }, + }); + } finally { + state.activeTurns.delete(turnId); + } + })(); return { turnId }; } diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index f8e3b99..caa7471 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,14 +1,10 @@ // VS Code extension entry — DeepCode "Chat" view + 3 commands. // Spec: docs/DEVELOPMENT_PLAN.md §v1.1 (VS Code extension) -// -// Build with: pnpm --filter @deepcode/vscode package (after vsce installed) -// The extension talks to @deepcode/core directly — no IPC, because the -// extension host is a Node process. For long-running agent loops we -// dispatch to a separate child process; this skeleton uses in-process. import type * as vscode from 'vscode'; -// We import lazily so the file type-checks without @types/vscode installed. +// Type-only import to keep the build clean without @types/vscode installed +// during the M0 phase. Real `vscode` is injected by the host at activation. type V = typeof import('vscode'); export async function activate(context: vscode.ExtensionContext): Promise { @@ -36,22 +32,27 @@ export async function activate(context: vscode.ExtensionContext): Promise value: 'Explain this code.', }); if (!prompt) return; - await runHeadless(prompt, selection, vscodeMod); + const composed = `${prompt}\n\n----- Selected code -----\n${selection}`; + await runAgent(composed, vscodeMod); }), commands.registerCommand('deepcode.review', async () => { - // Pipe current diff through code-review skill via headless mode. - // Skeleton: just open a doc with the placeholder. - const doc = await workspace.openTextDocument({ - content: '# DeepCode review\n\n(Real wiring lands in v1.1-rest — runs `deepcode -p "review this diff"` on git diff.)', - language: 'markdown', - }); - await window.showTextDocument(doc); + // Pipe current diff through code-review skill via runAgent. + // Uses `git diff` from the workspace root. + const root = workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!root) { + void window.showInformationMessage('DeepCode: open a folder first.'); + return; + } + const prompt = + 'Review the current uncommitted diff. Cite file:line for each finding. ' + + 'Categorize as BUG / LATENT / SUGGESTION.'; + await runAgent(prompt, vscodeMod, root); }), ); // ── Chat view provider ────────────────────────────────────────────── context.subscriptions.push( - window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(context)), + window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod)), ); } @@ -60,21 +61,72 @@ export function deactivate(): void { } // ────────────────────────────────────────────────────────────────────────── -// Headless run via @deepcode/core — same agent loop the CLI uses +// Real runAgent invocation — same @deepcode/core code drives CLI / Mac / LSP // ────────────────────────────────────────────────────────────────────────── -async function runHeadless(prompt: string, selection: string, vscodeMod: V): Promise { - // Lazy import to avoid bundling @deepcode/core into the extension's - // activation path until we know the user actually triggered DeepCode. - // (Real implementation lands in v1.1-rest; this is the wire shape.) - const composed = `${prompt}\n\n----- Selected code -----\n${selection}`; - vscodeMod.window.showInformationMessage( - `DeepCode would now run a turn with: "${composed.slice(0, 80)}…"`, - ); +async function runAgent( + userMessage: string, + vscodeMod: V, + cwd: string = process.cwd(), +): Promise { + const out = vscodeMod.window.createOutputChannel('DeepCode'); + out.show(true); + out.appendLine(`▎ DeepCode · ${new Date().toLocaleTimeString()}`); + out.appendLine(` ${userMessage.slice(0, 200)}${userMessage.length > 200 ? '…' : ''}`); + out.appendLine(''); + try { + const core = await import('@deepcode/core'); + const credsStore = new core.CredentialsStore(); + const creds = await core.resolveCredentials({ store: credsStore }); + if (!creds.apiKey && !creds.authToken) { + out.appendLine( + '✕ No DeepSeek credentials. Run `deepcode` once in a terminal to onboard, or set DEEPSEEK_API_KEY.', + ); + return; + } + const provider = new core.DeepSeekProvider({ + apiKey: creds.apiKey ?? '', + authToken: creds.authToken, + baseURL: creds.baseURL, + }); + await core.runAgent({ + provider, + tools: new core.ToolRegistry(core.BUILTIN_TOOLS), + systemPrompt: + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', + userMessage, + model: 'deepseek-chat', + cwd, + onEvent: (e) => { + if (e.type === 'text_delta') out.append(e.text); + else if (e.type === 'tool_use') out.appendLine(`\n[${e.name}] ${formatInput(e.input)}`); + else if (e.type === 'tool_result') + out.appendLine( + ` ${e.result.isError ? '✕' : '✓'} ${truncate(e.result.content, 200)}`, + ); + else if (e.type === 'error') out.appendLine(`\n✕ ${e.error}`); + }, + }); + out.appendLine('\n'); + } catch (err) { + out.appendLine(`\n✕ ${(err as Error).message ?? String(err)}`); + } +} + +function formatInput(input: Record): string { + for (const key of ['file_path', 'command', 'pattern', 'path', 'url', 'query']) { + const v = input[key]; + if (typeof v === 'string') return v; + } + return JSON.stringify(input).slice(0, 80); +} + +function truncate(s: string, n: number): string { + return s.length > n ? s.slice(0, n) + '…' : s; } class ChatViewProvider implements vscode.WebviewViewProvider { - constructor(private readonly _context: vscode.ExtensionContext) {} + constructor(private readonly vscodeMod: V) {} resolveWebviewView(view: vscode.WebviewView): void { view.webview.options = { enableScripts: true }; @@ -88,11 +140,57 @@ class ChatViewProvider implements vscode.WebviewViewProvider { view: vscode.WebviewView, msg: { kind: string; text?: string }, ): Promise { - if (msg.kind === 'send' && msg.text) { - // Wire to runAgent in v1.1-rest. + if (msg.kind !== 'send' || !msg.text) return; + try { + const core = await import('@deepcode/core'); + const credsStore = new core.CredentialsStore(); + const creds = await core.resolveCredentials({ store: credsStore }); + if (!creds.apiKey && !creds.authToken) { + view.webview.postMessage({ + kind: 'assistant', + text: '(No DeepSeek credentials. Run `deepcode` in a terminal to onboard.)', + }); + return; + } + const provider = new core.DeepSeekProvider({ + apiKey: creds.apiKey ?? '', + authToken: creds.authToken, + baseURL: creds.baseURL, + }); + let buffer = ''; + await core.runAgent({ + provider, + tools: new core.ToolRegistry(core.BUILTIN_TOOLS), + systemPrompt: + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise.', + userMessage: msg.text, + model: 'deepseek-chat', + cwd: this.vscodeMod.workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), + onEvent: (e) => { + if (e.type === 'text_delta') { + buffer += e.text; + view.webview.postMessage({ kind: 'assistant_stream', text: e.text }); + } else if (e.type === 'tool_use') { + view.webview.postMessage({ + kind: 'tool', + text: `[${e.name}] ${formatInput(e.input)}`, + }); + } else if (e.type === 'tool_result') { + view.webview.postMessage({ + kind: 'tool', + text: + (e.result.isError ? '✕ ' : '✓ ') + truncate(e.result.content, 200), + }); + } else if (e.type === 'error') { + view.webview.postMessage({ kind: 'assistant', text: `✕ ${e.error}` }); + } + }, + }); + if (buffer) view.webview.postMessage({ kind: 'assistant_end' }); + } catch (err) { view.webview.postMessage({ kind: 'assistant', - text: '(skeleton — chat IPC lands with the @deepcode/core in-extension-host wiring.)', + text: `✕ ${(err as Error).message ?? String(err)}`, }); } } @@ -103,9 +201,10 @@ function chatHtml(): string { @@ -115,32 +214,43 @@ function chatHtml(): string { const vscode = acquireVsCodeApi(); const log = document.getElementById('log'); const input = document.getElementById('msg'); + let streaming = null; input.addEventListener('keydown', (e) => { if (e.key === 'Enter' && input.value.trim()) { const text = input.value; input.value = ''; - log.innerHTML += '
' + escapeHtml(text) + '
'; + append('user', text); vscode.postMessage({ kind: 'send', text }); - log.scrollTop = log.scrollHeight; } }); window.addEventListener('message', (event) => { const m = event.data; - if (m.kind === 'assistant') { - log.innerHTML += '
' + escapeHtml(m.text) + '
'; + if (m.kind === 'assistant_stream') { + if (!streaming) streaming = append('assistant', ''); + streaming.textContent += m.text; log.scrollTop = log.scrollHeight; + } else if (m.kind === 'assistant_end') { + streaming = null; + } else if (m.kind === 'tool') { + append('tool', m.text); + } else if (m.kind === 'assistant') { + append('assistant', m.text); + streaming = null; } }); - function escapeHtml(s) { - return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); + function append(role, text) { + const div = document.createElement('div'); + div.className = 'msg ' + role; + div.textContent = text; + log.appendChild(div); + log.scrollTop = log.scrollHeight; + return div; } `; } async function loadVscode(): Promise { - // VS Code injects this at extension activation time. Type-only import so - // the package builds without `@types/vscode` installed during M0 phase. // eslint-disable-next-line @typescript-eslint/no-require-imports return require('vscode') as V; }