diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d8feb..74a1576 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,10 @@ jobs: - name: Build run: pnpm build + - name: Package VS Code extension + if: runner.os == 'Linux' + run: pnpm --filter deepcode package --out "${RUNNER_TEMP}/deepcode.vsix" + link-check: name: Docs link check runs-on: ubuntu-latest @@ -80,6 +84,34 @@ jobs: - name: Verify current documentation run: node scripts/check-docs.mjs + desktop-preview: + name: Desktop protocol journey + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Install Chromium + run: pnpm --filter @deepcode/desktop exec playwright install --with-deps chromium + - name: Exercise the desktop protocol fixture + run: pnpm --filter @deepcode/desktop test:e2e + - name: Upload browser diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: desktop-playwright-report + path: | + apps/desktop/playwright-report + apps/desktop/test-results + if-no-files-found: ignore + retention-days: 7 + desktop-rust: name: Desktop Rust check + test runs-on: macos-latest @@ -88,6 +120,17 @@ jobs: - uses: actions/checkout@v6 - name: Show Rust toolchain run: rustc --version && cargo --version + # Tauri validates every externalBin path in its build script. This job + # only compiles/tests Rust and never executes or packages the sidecar, so + # use a target-correct placeholder instead of copying a 100+ MB runtime. + - name: Prepare Tauri sidecar placeholder + run: | + target="$(rustc -vV | sed -n 's/^host: //p')" + runtime="apps/desktop/src-tauri/binaries/deepcode-runtime-${target}" + mkdir -p "$(dirname "$runtime")" + touch "$runtime" + mkdir -p apps/server/dist-sidecar + touch apps/server/dist-sidecar/app-server.cjs - name: Check and test Tauri backend run: | cargo check --manifest-path apps/desktop/src-tauri/Cargo.toml --locked diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 42eb576..21004f1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -116,6 +116,19 @@ jobs: - name: pnpm install run: pnpm install --frozen-lockfile + - name: Prepare pinned Node sidecar runtime + env: + NODE_SIDECAR_VERSION: 22.23.1 + NODE_SIDECAR_SHA256: ef28d8fab2c0e4314522d4bb1b7173270aa3937e93b92cb7de79c112ac1fa953 + run: | + archive="node-v${NODE_SIDECAR_VERSION}-darwin-arm64.tar.xz" + curl --fail --location --retry 3 \ + "https://nodejs.org/dist/v${NODE_SIDECAR_VERSION}/${archive}" \ + --output "$RUNNER_TEMP/$archive" + echo "${NODE_SIDECAR_SHA256} $RUNNER_TEMP/$archive" | shasum -a 256 --check + tar -xJf "$RUNNER_TEMP/$archive" -C "$RUNNER_TEMP" + echo "DEEPCODE_NODE_RUNTIME=$RUNNER_TEMP/node-v${NODE_SIDECAR_VERSION}-darwin-arm64/bin/node" >> "$GITHUB_ENV" + - name: Set version run: | cd apps/desktop diff --git a/.gitignore b/.gitignore index d124e0b..4b03661 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,8 @@ yarn-error.log # Test outputs coverage/ .nyc_output/ +apps/desktop/playwright-report/ +apps/desktop/test-results/ # Electron build outputs (pre-Tauri pivot — kept for historical artifacts) apps/desktop/release/ @@ -39,6 +41,8 @@ apps/desktop/dist-electron/ # Tauri build outputs (Rust target dir is large) apps/desktop/src-tauri/target/ apps/desktop/src-tauri/gen/ +apps/desktop/src-tauri/binaries/ +apps/server/dist-sidecar/ # Note: Cargo.lock IS committed (best practice for applications) # Release artifacts — too large for git; CI uploads to GitHub Releases instead diff --git a/README.md b/README.md index 7cd4a31..d2a98fa 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ Mac 客户端(v1 即将发布):拖入 Applications → 首启完成 onboar | [docs/DEVELOPMENT_PLAN.md](docs/DEVELOPMENT_PLAN.md) | 整体开发方案 v0.5(1500+ 行 / §3 模块 / §6 里程碑) | | [docs/VISUAL_DESIGN.html](docs/VISUAL_DESIGN.html) | 视觉设计 v0.4(11 屏 mockup) | | [docs/security-model.md](docs/security-model.md) | 威胁模型 + 防御层 + 攻击向量测试 + 已知缺口 | +| [docs/design/session-format-v1.md](docs/design/session-format-v1.md) | 统一 session JSONL、旧格式迁移与 writer ownership | | [docs/design/sandbox-plan-worktree.md](docs/design/sandbox-plan-worktree.md) | sandbox × plan mode × worktree 关系矩阵 | | [docs/design/plugin-security.md](docs/design/plugin-security.md) | plugin 信任 ladder + sandbox 子进程 | | [docs/design/effort-levels.md](docs/design/effort-levels.md) | 5 档 effort 到 DeepSeek API 参数映射 | @@ -77,7 +78,7 @@ packages/ apps/ cli/ # deepcode-cli — Node.js CLI (npm publishable) desktop/ # @deepcode/desktop — Tauri 2 + React Mac client - vscode/ # @deepcode/vscode — VS Code extension (v1.1) + vscode/ # deepcode — VS Code extension (app-server protocol client) lsp/ # @deepcode/lsp — LSP bridge for Neovim/Emacs/Sublime (v1.1) docs/ design/ # internal design docs diff --git a/apps/cli/package.json b/apps/cli/package.json index 1b65597..5c66d68 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -21,6 +21,7 @@ "start": "node ./dist/cli.js" }, "dependencies": { + "@deepcode/app-server": "workspace:*", "@deepcode/core": "workspace:*", "@deepcode/shared-ui": "workspace:*" }, diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 22e9d38..9a33f55 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -4,6 +4,7 @@ // M2: onboarding + REPL + slash commands + settings + permissions matcher. import { CredentialsStore, VERSION, redact } from '@deepcode/core'; +import { runAppServer } from '@deepcode/app-server'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { runHeadless } from './headless.js'; @@ -80,6 +81,14 @@ async function main(): Promise { errOutput: process.stderr, }); } + if (args.positional[0] === 'app-server') { + await runAppServer({ + input: process.stdin, + output: process.stdout, + home: process.env.DEEPCODE_HOME ?? resolve(homedir(), '.deepcode'), + }); + return 0; + } if (args.positional[0] === 'trust') { return runTrustCommand(args.positional.slice(1), { cwd: process.cwd(), diff --git a/apps/cli/src/completion.ts b/apps/cli/src/completion.ts index f938260..795b592 100644 --- a/apps/cli/src/completion.ts +++ b/apps/cli/src/completion.ts @@ -53,6 +53,7 @@ const SUBCOMMANDS = [ 'doctor', 'upgrade', 'mcp', + 'app-server', 'trust', 'plugins', 'skills', diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index c24213b..f76f997 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -21,6 +21,7 @@ import { EFFORT_PARAMS, HookDispatcher, ReadTool, + RuntimeHost, SessionManager, ToolRegistry, WebFetchTool, @@ -39,7 +40,6 @@ import { loadSkills, makeSkillTool, resolveCredentials, - runAgent, wirePlugins, collectPluginContributions, type AgentEvent, @@ -271,9 +271,18 @@ export async function runHeadless(opts: HeadlessOpts): Promise { } let exitCode = 0; try { - const result = await runAgent({ + const runtime = new RuntimeHost({ provider, tools, + cwd, + mode, + permissions: settings.permissions, + hooks, + pluginDirs: pluginContrib.dirs, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + }); + const result = await runtime.run({ systemPrompt, userMessage, history: [], @@ -281,15 +290,9 @@ export async function runHeadless(opts: HeadlessOpts): Promise { maxTokens, temperature, maxTurns, - cwd, + signal: ctrl.signal, session: { manager: sessions, id: session.id }, - mode, - permissions: settings.permissions, - hooks, - pluginDirs: pluginContrib.dirs, autoCompact: { contextWindow: contextWindowFor(model), threshold: 0.8 }, - autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, // In headless mode there's no human to ask: auto-deny anything that // would normally need approval. Users wanting auto-yes should pass // --mode dontAsk or --mode bypassPermissions (gated by trust). @@ -441,6 +444,7 @@ function formatEventText(out: Writable, e: AgentEvent): void { return; case 'usage': case 'thinking_delta': + case 'model_step_complete': case 'turn_complete': return; } diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index f2fc27f..6149fe6 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -285,6 +285,7 @@ USAGE deepcode cron Scheduled tasks: install/uninstall/list/status deepcode scheduler run Run due scheduled jobs (invoked by launchd) deepcode mcp serve Expose DeepCode tools as an MCP server (stdio) + deepcode app-server Run the experimental lifecycle server (JSONL stdio) deepcode trust [--plan-only] Trust this directory's project config (hooks/MCP/...) deepcode plugins list [--json] List installed plugins deepcode plugins install Install a plugin (gh:owner/repo | name@npm | ./path) diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 150854d..fb88bee 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -8,6 +8,7 @@ import { EFFORT_PARAMS, HookDispatcher, ReadTool, + RuntimeHost, SessionManager, TaskManager, ToolRegistry, @@ -37,7 +38,6 @@ import { contextWindowFor, makeSkillTool, resolveCredentials, - runAgent, settingsPaths, wirePlugins, collectPluginContributions, @@ -429,6 +429,17 @@ export async function startRepl(opts: ReplOpts): Promise { } let history: StoredMessage[] = resolved.seededHistory; + const runtime = new RuntimeHost({ + provider, + tools, + cwd, + mode, + permissions: settings.permissions, + hooks, + pluginDirs: pluginContrib.dirs, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + }); const ctx: SessionContext = { cwd, model, @@ -471,25 +482,20 @@ export async function startRepl(opts: ReplOpts): Promise { // reading ctx.model/ctx.mode live so /model and /mode switches are honored. const tasks = new TaskManager((spec) => { const ac = new AbortController(); - const done = runAgent({ - provider, - tools, - systemPrompt, - userMessage: spec.prompt, - model: ctx.model, - maxTokens, - temperature, - cwd: ctx.cwd, - signal: ac.signal, - mode: ctx.mode as Mode, - permissions: settings.permissions, - hooks, - pluginDirs: pluginContrib.dirs, - sandboxConfig: settings.sandbox, - autoMode: settings.autoMode, - subAgentDepth: 1, - systemReminders: false, - }).then((r) => assistantText(r.history)); + const done = runtime + .run({ + systemPrompt, + userMessage: spec.prompt, + model: ctx.model, + maxTokens, + temperature, + cwd: ctx.cwd, + signal: ac.signal, + modeOverride: ctx.mode as Mode, + subAgentDepth: 1, + systemReminders: false, + }) + .then((r) => assistantText(r.history)); return { done, abort: () => ac.abort() }; }); ctx.tasks = tasks; @@ -649,9 +655,7 @@ export async function startRepl(opts: ReplOpts): Promise { } // Otherwise: send to agent (with mode/permission/hooks gating from M3b) - const result = await runAgent({ - provider, - tools, + const result = await runtime.run({ systemPrompt, userMessage: userInput, history, @@ -663,13 +667,8 @@ export async function startRepl(opts: ReplOpts): Promise { // ctx.sessionId (not the launch `session.id`) so a live `/resume ` // switch redirects new messages to the resumed session. session: { manager: sessions, id: ctx.sessionId }, - mode: ctx.mode as Mode, - permissions: settings.permissions, - hooks, - pluginDirs: pluginContrib.dirs, + modeOverride: ctx.mode as Mode, autoCompact: { contextWindow: contextWindowFor(ctx.model), threshold: 0.8 }, - autoMode: settings.autoMode, - sandboxConfig: settings.sandbox, // Session-scoped manager: the agent's TaskCreate calls land here too, so // background tasks persist across turns and show up in /tasks. taskManager: tasks, @@ -762,6 +761,7 @@ function formatEvent(out: Writable, e: AgentEvent): void { else out.write(` ✓ ${truncate(e.result.content, 200)}\n`); return; case 'usage': + case 'model_step_complete': return; case 'error': out.write(`\n ✕ ${e.error}\n`); diff --git a/apps/cli/src/trust.ts b/apps/cli/src/trust.ts index 89ab5d8..ba37821 100644 --- a/apps/cli/src/trust.ts +++ b/apps/cli/src/trust.ts @@ -1,72 +1,6 @@ -// Trust dialog — track which directories the user has approved for full feature access. -// Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 -// M2: tracks state to ~/.deepcode/trusted-dirs.json; CLI prompt for new dirs. -// Hooks/MCP/apiKeyHelper gating is consulted by their owners (deferred to M3). - -import { promises as fs } from 'node:fs'; -import { homedir } from 'node:os'; -import { dirname, join, resolve } from 'node:path'; - -export interface TrustState { - dirs: Record; -} - -/** A fresh empty state. Must be a factory — returning a shared object literal - * would let `trust()`/`untrust()` mutate `dirs` on the shared instance, leaking - * entries into later `load()`s of a not-yet-created store file. */ -function emptyState(): TrustState { - return { dirs: {} }; -} - -export interface TrustStoreOpts { - home?: string; -} - -export class TrustStore { - private readonly home: string; - constructor(opts: TrustStoreOpts = {}) { - this.home = opts.home ?? homedir(); - } - - filePath(): string { - return join(this.home, '.deepcode', 'trusted-dirs.json'); - } - - async load(): Promise { - try { - const raw = await fs.readFile(this.filePath(), 'utf8'); - return JSON.parse(raw) as TrustState; - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return emptyState(); - throw err; - } - } - - async save(state: TrustState): Promise { - const path = this.filePath(); - await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, JSON.stringify(state, null, 2) + '\n', 'utf8'); - } - - async statusFor(cwd: string): Promise<'trusted' | 'plan-only' | 'untrusted'> { - const abs = resolve(cwd); - const state = await this.load(); - const entry = state.dirs[abs]; - if (!entry) return 'untrusted'; - return entry.mode === 'plan-only' ? 'plan-only' : 'trusted'; - } - - async trust(cwd: string, mode: 'full' | 'plan-only'): Promise { - const abs = resolve(cwd); - const state = await this.load(); - state.dirs[abs] = { trustedAt: new Date().toISOString(), mode }; - await this.save(state); - } - - async untrust(cwd: string): Promise { - const abs = resolve(cwd); - const state = await this.load(); - delete state.dirs[abs]; - await this.save(state); - } -} +// Compatibility name for the shared core trust store. +export { DirectoryTrustStore as TrustStore } from '@deepcode/core'; +export type { + DirectoryTrustState as TrustState, + DirectoryTrustStoreOptions as TrustStoreOpts, +} from '@deepcode/core'; diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 1b5060f..372efed 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -9,5 +9,9 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "references": [{ "path": "../../packages/core" }, { "path": "../../packages/shared-ui" }] + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/shared-ui" }, + { "path": "../server" } + ] } diff --git a/apps/desktop/README.md b/apps/desktop/README.md index 0818901..acef184 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -15,13 +15,14 @@ src/ renderer(React + Vite,无 Tailwind,手写设计系统 screens/ About / MCPManager / Onboarding / Permissions / Plugins / Repl / Sessions / Settings / Skills components/ Sidebar / InspectorRail / ToolCard / UpdateBanner … - lib/ tauri-api(renderer↔Rust IPC 封装)· mac-agent · - mac-tools · repl-stream · updater … + lib/ tauri-api(renderer↔Rust IPC 封装)· protocol-client · + protocol-agent · repl-stream · updater … src-tauri/ Rust 主进程 + src/app_server.rs bundled runtime 启停、stdio 与 crash event src/commands.rs #[tauri::command] —— renderer 通过 invoke() 调用 - src/credentials.rs 凭据读写(原子写入) + src/credentials.rs 凭据保存与无密钥状态查询 src/settings.rs 设置持久化 - src/tools.rs 工具实现 + src/tools.rs legacy native helpers(renderer 仅暴露只读 file read) src/lib.rs Tauri builder / 插件注册 tauri.conf.json 窗口 + 构建 + 打包配置 capabilities/ 权限能力声明 @@ -31,6 +32,11 @@ src-tauri/ Rust 主进程 renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试见 `src/lib/tauri-api.test.ts`(#84)。 +app-server 由 Tauri 作为 target-specific sidecar 监督。`apps/server` 会被打成单个 +`app-server.cjs` resource,Node runtime 通过 `bundle.externalBin` 进入 `.app`;renderer 只能通过 +Rust commands 与版本化协议通信,不能直接使用 shell plugin。provider、agent loop、tools、权限、 +session materialization 和凭证明文都只存在于 sidecar;renderer 不再带有第二套运行时。 + ## 开发 依赖在 monorepo 根 `pnpm install` 一次装好;Rust 工具链 + Tauri CLI 见下。 @@ -40,19 +46,20 @@ renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试 | `pnpm dev` | 仅 Vite dev server(5173)—— 一般由 Tauri 自动拉起 | | `pnpm tauri:dev` | 完整 app:Tauri 启 dev server + 原生窗口,热重载 | | `pnpm build` | `tsc -b` + `vite build` → `dist/`(renderer 产物) | -| `pnpm tauri:build` | 当前架构的 .app / .dmg | +| `pnpm tauri:build` | 构建包含 runtime + app-server 的 `.app` | | `pnpm tauri:build:universal` | universal-apple-darwin 通用二进制 | | `pnpm typecheck` | `tsc -b` | | `pnpm test` | `vitest run`(lib 单测 + IPC 契约测试) | -`tauri.conf.json` 里 `beforeDevCommand` / `beforeBuildCommand` 分别接 -`pnpm dev` / `pnpm build`,所以平时只跑 `pnpm tauri:dev` 即可。 +`tauri.conf.json` 的 dev/build hooks 会先生成 app-server bundle 和目标 runtime,再启动 Vite 或 +Tauri release build,所以平时只跑 `pnpm tauri:dev` 即可。 ### 前置工具 - Node ≥ 22、pnpm - Rust 工具链(`rustup`)—— Tauri 主进程是 Rust - 通用构建需 `rustup target add aarch64-apple-darwin x86_64-apple-darwin` +- 通用构建还要求 `DEEPCODE_NODE_RUNTIME` 指向同时含 arm64/x86_64 的通用 Node binary ## 打包 / 签名 @@ -60,5 +67,7 @@ renderer ↔ Rust 的 IPC 边界由 `src/lib/tauri-api.ts` 封装,契约测试 `src-tauri/Entitlements.plist`。 - 签名 + 公证需要 Apple Developer ID 证书,以及 `APPLE_ID` / `APPLE_APP_SPECIFIC_PASSWORD` 等环境变量(CI 走 secrets)。 +- release CI 固定 Node 22.23.1,校验官方 SHA256 后才进入 Tauri 打包;nested runtime 先签,outer + `.app` 后签,再做 strict deep verification 与 notarization。 详见 `docs/DEVELOPMENT_PLAN.md` §4 / §4a / §4b。 diff --git a/apps/desktop/e2e/desktop-preview.spec.ts b/apps/desktop/e2e/desktop-preview.spec.ts new file mode 100644 index 0000000..7efc2a7 --- /dev/null +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -0,0 +1,81 @@ +import { expect, test } from '@playwright/test'; + +const composerPlaceholder = '问点什么… @ 引用文件 · / 命令 · # 写入 DEEPCODE.md'; + +test.beforeEach(async ({ page }) => { + await page.goto('/preview-app.html'); + await expect(page.locator('.app-shell')).toBeVisible(); +}); + +test('keeps the Codex-style three-column shell inside the viewport', async ({ page }, testInfo) => { + const sidebar = await page.locator('.sidebar').boundingBox(); + const main = await page.locator('.chat-main').boundingBox(); + const rail = await page.locator('.inspector-rail').boundingBox(); + + expect(sidebar).not.toBeNull(); + expect(main).not.toBeNull(); + expect(rail).not.toBeNull(); + expect(Math.round(sidebar!.width)).toBe(240); + expect(Math.round(rail!.width)).toBe(64); + expect(Math.round(main!.x)).toBe(Math.round(sidebar!.x + sidebar!.width)); + expect(Math.round(rail!.x + rail!.width)).toBe(1280); + + const overflow = await page.evaluate(() => ({ + horizontal: document.documentElement.scrollWidth - window.innerWidth, + vertical: document.documentElement.scrollHeight - window.innerHeight, + })); + expect(overflow.horizontal).toBeLessThanOrEqual(0); + expect(overflow.vertical).toBeLessThanOrEqual(0); + + await testInfo.attach('desktop-shell.png', { + body: await page.screenshot(), + contentType: 'image/png', + }); +}); + +test('resumes a thread and completes an approval-gated protocol turn', async ({ page }) => { + await page.locator('[title*="2026-06-02-aaa111"]').click(); + const main = page.getByRole('main'); + await expect( + main.getByText('Resumed session — earlier conversation loaded below.'), + ).toBeVisible(); + await expect(main.getByText('制作一个打飞机的小游戏', { exact: true })).toBeVisible(); + + const composer = page.getByPlaceholder(composerPlaceholder, { exact: true }); + await composer.fill('Add a boss phase'); + await composer.press('Enter'); + + const approve = page.getByRole('button', { name: /^Approve \(↵\)$/ }); + await expect(approve).toBeVisible(); + await expect(main.getByText(/I’ll update the game safely\./)).toBeVisible(); + await expect(main.locator('.tool-card').filter({ hasText: 'Edit' }).last()).toBeVisible(); + + await approve.click(); + + await expect(main.getByText(/The boss encounter is ready\./)).toBeVisible(); + await expect(main.getByText('Updated the boss encounter.', { exact: true }).last()).toBeVisible(); + await expect(main.getByText('2,304 / 128,000', { exact: true })).toBeVisible(); + await expect(approve).toBeHidden(); + await expect(composer).toBeEnabled(); + await expect(main.getByText('Add a boss phase', { exact: true })).toBeVisible(); + const toolCards = main.locator('.tool-card'); + await expect(toolCards).toHaveCount(2); + await expect(toolCards.first()).toContainText('running'); + await expect(toolCards.last()).toContainText('done'); +}); + +test('opens source, diff, and history from the file activity rail', async ({ page }) => { + await page.locator('[title*="2026-06-02-aaa111"]').click(); + await page.getByRole('button', { name: 'Files', exact: true }).click(); + + const panel = page.getByTestId('file-panel'); + await expect(panel).toBeVisible(); + await expect(panel.getByText('打飞机.html', { exact: true })).toBeVisible(); + await expect(panel.getByText('', { exact: true })).toBeVisible(); + + await panel.getByRole('button', { name: 'Diff', exact: true }).click(); + await expect(panel.locator('.fp-diff')).toBeVisible(); + + await panel.getByRole('button', { name: 'History', exact: true }).click(); + await expect(panel.locator('.fp-hist-row')).toHaveCount(3); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b26b64f..fd9355b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -7,7 +7,9 @@ "type": "module", "scripts": { "dev": "vite", + "dev:tauri": "pnpm --filter @deepcode/app-server build:sidecar && node scripts/prepare-runtime.mjs && pnpm dev", "build": "tsc -b && vite build", + "build:tauri-assets": "pnpm build && pnpm --filter @deepcode/app-server build:sidecar && node scripts/prepare-runtime.mjs", "preview": "vite preview", "tauri": "tauri", "tauri:dev": "tauri dev", @@ -15,11 +17,13 @@ "tauri:build:universal": "tauri build --target universal-apple-darwin", "typecheck": "tsc -b", "test": "vitest run --passWithNoTests", + "test:e2e": "pnpm --workspace-root build && playwright test", "lint": "echo 'lint: configured at repo root' && exit 0", "clean": "rm -rf dist src-tauri/target *.tsbuildinfo" }, "dependencies": { "@deepcode/core": "workspace:*", + "@deepcode/protocol": "workspace:*", "@deepcode/shared-ui": "workspace:*", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-dialog": "^2.0.0", @@ -32,6 +36,7 @@ "react-dom": "^18.3.0" }, "devDependencies": { + "@playwright/test": "^1.62.1", "@tauri-apps/cli": "^2.0.0", "@types/node": "^22.10.0", "@types/react": "^18.3.0", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 0000000..0cefcea --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from '@playwright/test'; + +const port = 4173; +const origin = `http://127.0.0.1:${port}`; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: Boolean(process.env.CI), + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: origin, + viewport: { width: 1280, height: 800 }, + screenshot: 'only-on-failure', + trace: 'retain-on-failure', + }, + webServer: { + command: `pnpm dev --host 127.0.0.1 --port ${port}`, + url: `${origin}/preview-app.html`, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, +}); diff --git a/apps/desktop/scripts/prepare-runtime.mjs b/apps/desktop/scripts/prepare-runtime.mjs new file mode 100644 index 0000000..e99db2c --- /dev/null +++ b/apps/desktop/scripts/prepare-runtime.mjs @@ -0,0 +1,77 @@ +import { copyFile, mkdir, rename, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const desktopRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const target = + process.env.DEEPCODE_TARGET ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? hostTargetTriple(); +const source = process.env.DEEPCODE_NODE_RUNTIME ?? process.execPath; + +if (process.env.CI && !process.env.DEEPCODE_NODE_RUNTIME) { + throw new Error('CI desktop packaging requires a pinned DEEPCODE_NODE_RUNTIME'); +} + +const destination = resolve( + desktopRoot, + 'src-tauri', + 'binaries', + `deepcode-runtime-${target}${target.includes('windows') ? '.exe' : ''}`, +); +await mkdir(dirname(destination), { recursive: true }); +await copyFile(source, destination); + +let thinned = false; +if (process.platform === 'darwin' && target.endsWith('apple-darwin')) { + const architectures = spawnSync('/usr/bin/lipo', ['-archs', destination]); + if (architectures.status !== 0) { + throw new Error(`unable to inspect Node runtime architecture: ${architectures.stderr.toString()}`); + } + const availableArchitectures = architectures.stdout.toString().trim().split(/\s+/); + if (target.startsWith('universal')) { + if (!availableArchitectures.includes('arm64') || !availableArchitectures.includes('x86_64')) { + throw new Error('universal desktop target requires a universal Node runtime'); + } + } else { + const architecture = target.startsWith('aarch64') ? 'arm64' : 'x86_64'; + if (!availableArchitectures.includes(architecture)) { + throw new Error( + `desktop target ${target} requires ${architecture}, but Node runtime contains ${availableArchitectures.join(', ')}`, + ); + } + const thinPath = `${destination}.thin`; + const thin = spawnSync('/usr/bin/lipo', [ + destination, + '-thin', + architecture, + '-output', + thinPath, + ]); + if (thin.status === 0) { + await rename(thinPath, destination); + thinned = true; + } + } + const strip = spawnSync('/usr/bin/strip', ['-S', destination]); + if (strip.status !== 0) throw new Error(`strip failed: ${strip.stderr.toString()}`); + const sign = spawnSync('/usr/bin/codesign', ['--force', '--sign', '-', destination]); + if (sign.status !== 0) throw new Error(`ad-hoc signing failed: ${sign.stderr.toString()}`); +} + +process.stdout.write( + `${JSON.stringify({ target, source, destination, bytes: (await stat(destination)).size, thinned })}\n`, +); + +function hostTargetTriple() { + if (process.platform === 'darwin') { + return `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-apple-darwin`; + } + if (process.platform === 'linux') { + return `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-unknown-linux-gnu`; + } + if (process.platform === 'win32') { + return `${process.arch === 'arm64' ? 'aarch64' : 'x86_64'}-pc-windows-msvc`; + } + throw new Error(`Unsupported desktop sidecar host: ${process.platform}-${process.arch}`); +} diff --git a/apps/desktop/src-tauri/Cargo.lock b/apps/desktop/src-tauri/Cargo.lock index d25f9f5..ce392f9 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -675,6 +675,7 @@ name = "deepcode_desktop" version = "0.1.6" dependencies = [ "dirs 5.0.1", + "libc", "serde", "serde_json", "sha2", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 703e41d..7a0e4f0 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -27,6 +27,7 @@ sha2 = "0.10" thiserror = "1" tokio = { version = "1", features = ["fs", "rt-multi-thread", "macros", "sync", "time", "process"] } dirs = "5" +libc = "0.2" [profile.release] panic = "abort" diff --git a/apps/desktop/src-tauri/capabilities/default.json b/apps/desktop/src-tauri/capabilities/default.json index b23265a..2180f48 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -13,7 +13,6 @@ "dialog:default", "fs:default", "opener:default", - "shell:default", "updater:default", "process:default", "process:allow-restart" diff --git a/apps/desktop/src-tauri/src/app_server.rs b/apps/desktop/src-tauri/src/app_server.rs new file mode 100644 index 0000000..c5012cf --- /dev/null +++ b/apps/desktop/src-tauri/src/app_server.rs @@ -0,0 +1,184 @@ +use std::sync::Mutex; + +use serde::Serialize; +use tauri::{path::BaseDirectory, AppHandle, Emitter, Manager, State}; +use tauri_plugin_shell::{ + process::{CommandChild, CommandEvent}, + ShellExt, +}; + +struct ManagedChild { + pid: u32, + child: CommandChild, +} + +#[derive(Default)] +pub struct AppServerState { + child: Mutex>, +} + +impl Drop for AppServerState { + fn drop(&mut self) { + if let Ok(slot) = self.child.get_mut() { + if let Some(managed) = slot.take() { + let _ = managed.child.kill(); + } + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppServerStatus { + running: bool, + pid: Option, +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct AppServerOutput { + stream: &'static str, + line: String, + code: Option, + signal: Option, +} + +#[tauri::command] +pub fn app_server_start( + app: AppHandle, + state: State<'_, AppServerState>, +) -> Result { + let mut slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + if let Some(managed) = slot.as_ref() { + return Ok(AppServerStatus { + running: true, + pid: Some(managed.pid), + }); + } + + let script = app + .path() + .resolve("app-server.cjs", BaseDirectory::Resource) + .map_err(|error| format!("resolve app-server resource: {error}"))?; + let (mut receiver, child) = app + .shell() + .sidecar("deepcode-runtime") + .map_err(|error| format!("resolve bundled runtime: {error}"))? + .arg(script) + .spawn() + .map_err(|error| format!("start app-server: {error}"))?; + let pid = child.pid(); + *slot = Some(ManagedChild { pid, child }); + drop(slot); + + let handle = app.clone(); + tauri::async_runtime::spawn(async move { + while let Some(event) = receiver.recv().await { + let (stream, line, code, signal, terminated) = match event { + CommandEvent::Stdout(bytes) => ( + "stdout", + String::from_utf8_lossy(&bytes).into_owned(), + None, + None, + false, + ), + CommandEvent::Stderr(bytes) => ( + "stderr", + String::from_utf8_lossy(&bytes).into_owned(), + None, + None, + false, + ), + CommandEvent::Error(error) => ("error", error, None, None, false), + CommandEvent::Terminated(payload) => ( + "terminated", + String::new(), + payload.code, + payload.signal, + true, + ), + _ => continue, + }; + let _ = handle.emit( + "app-server-output", + AppServerOutput { + stream, + line, + code, + signal, + }, + ); + if terminated { + if let Ok(mut current) = handle.state::().child.lock() { + if current.as_ref().is_some_and(|managed| managed.pid == pid) { + current.take(); + } + } + } + } + }); + + Ok(AppServerStatus { + running: true, + pid: Some(pid), + }) +} + +#[tauri::command] +pub fn app_server_send(state: State<'_, AppServerState>, message: String) -> Result<(), String> { + validate_request_line(&message)?; + let mut slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + let managed = slot + .as_mut() + .ok_or_else(|| "app-server is not running".to_string())?; + managed + .child + .write(format!("{message}\n").as_bytes()) + .map_err(|error| format!("write app-server request: {error}")) +} + +#[tauri::command] +pub fn app_server_stop(state: State<'_, AppServerState>) -> Result<(), String> { + let mut slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + if let Some(managed) = slot.take() { + managed + .child + .kill() + .map_err(|error| format!("stop app-server: {error}"))?; + } + Ok(()) +} + +#[tauri::command] +pub fn app_server_status(state: State<'_, AppServerState>) -> Result { + let slot = state.child.lock().map_err(|_| "app-server lock poisoned")?; + Ok(AppServerStatus { + running: slot.is_some(), + pid: slot.as_ref().map(|managed| managed.pid), + }) +} + +fn validate_request_line(message: &str) -> Result<(), String> { + if message.contains(['\n', '\r']) { + return Err("app-server request must be one line".to_string()); + } + let value: serde_json::Value = serde_json::from_str(message) + .map_err(|error| format!("invalid app-server JSON: {error}"))?; + if !value.is_object() { + return Err("app-server request must be a JSON object".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::validate_request_line; + + #[test] + fn request_line_must_be_one_json_object() { + assert!(validate_request_line(r#"{"id":1,"method":"initialize","params":{}}"#).is_ok()); + assert!(validate_request_line("{}\n{}").is_err()); + assert!(validate_request_line("not-json").is_err()); + assert!(validate_request_line("[]").is_err()); + } +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index a839406..728f82b 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -4,6 +4,8 @@ use crate::credentials::{self, Credentials}; use crate::settings; use serde::Serialize; +use std::collections::HashMap; +use std::io::Write; use std::path::PathBuf; #[derive(Serialize)] @@ -23,8 +25,8 @@ pub fn get_app_info() -> AppInfo { } #[tauri::command] -pub fn read_credentials() -> Result { - credentials::read() +pub fn credential_status() -> Result { + credentials::status() } #[tauri::command] @@ -114,89 +116,126 @@ pub fn append_allow_matcher(matcher: String) -> Result<(), String> { settings::write_user(&value) } -/// Create a new session JSONL with a metadata header line. Returns the -/// generated session id. The id format matches what @deepcode/core's -/// SessionManager produces: `YYYY-MM-DD-`. -#[tauri::command] -pub fn session_create(cwd: String) -> Result { - let Some(home) = dirs::home_dir() else { - return Err("no home directory".into()); - }; - let now = std::time::SystemTime::now(); - let secs = now - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .as_secs(); - let date = format_date(secs); - // Lightweight unique suffix from time-nanos — no extra crate dep - let nanos = now - .duration_since(std::time::UNIX_EPOCH) - .map_err(|e| e.to_string())? - .subsec_nanos(); - let rand_id = format!("{:08x}", nanos); - let id = format!("{}-{}", date, rand_id); - let dir = home.join(".deepcode").join("sessions"); - std::fs::create_dir_all(&dir).map_err(|e| format!("mkdir {}: {}", dir.display(), e))?; - let path = dir.join(format!("{}.jsonl", id)); - let header = serde_json::json!({ - "type": "session_meta", - "id": id, - "cwd": cwd, - "created_at": secs, - "client": "desktop" - }); - let line = format!("{}\n", header); - std::fs::write(&path, line).map_err(|e| format!("write {}: {}", path.display(), e))?; - Ok(id) -} - -/// Append a single JSON line to a session's JSONL file. -#[tauri::command] -pub fn session_append(id: String, message: serde_json::Value) -> Result<(), String> { - let Some(home) = dirs::home_dir() else { - return Err("no home directory".into()); - }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{}.jsonl", id)); - let line = format!("{}\n", message); - use std::io::Write; - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&path) - .map_err(|e| format!("open {}: {}", path.display(), e))?; - f.write_all(line.as_bytes()) - .map_err(|e| format!("write {}: {}", path.display(), e)) -} - /// Read a session's JSONL and return its message lines (skipping the /// `session_meta` header and any unparseable lines). Each returned value is the -/// stored message object as written by session_append: `{ type, role, content, -/// timestamp }`. Returns an empty vec if the file doesn't exist. +/// canonical `{ type, role, content, timestamp }` object. Returns an empty vec +/// if the file doesn't exist. #[tauri::command] pub fn session_read(id: String) -> Result, String> { + safe_session_id(&id)?; let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{}.jsonl", id)); + let dir = home.join(".deepcode").join("sessions"); + let path = readable_session_path(&dir, &id); let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(format!("read {}: {}", path.display(), e)), }; + parse_session_messages(&text) +} + +struct SessionWriterLock { + path: PathBuf, +} + +impl SessionWriterLock { + fn acquire(dir: &std::path::Path, id: &str) -> Result { + let path = dir.join(format!("{id}.writer.lock")); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| { + if e.kind() == std::io::ErrorKind::AlreadyExists { + format!("session {id} already has an active writer") + } else { + format!("open {}: {}", path.display(), e) + } + })?; + writeln!(file, "pid={}", std::process::id()).map_err(|e| e.to_string())?; + Ok(Self { path }) + } +} + +impl Drop for SessionWriterLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn readable_session_path(dir: &std::path::Path, id: &str) -> PathBuf { + let canonical = dir.join(format!("{id}.v1.jsonl")); + if canonical.exists() { + canonical + } else { + dir.join(format!("{id}.jsonl")) + } +} + +fn ensure_canonical_session(dir: &std::path::Path, id: &str) -> Result { + let canonical = dir.join(format!("{id}.v1.jsonl")); + if canonical.exists() { + return Ok(canonical); + } + let legacy = dir.join(format!("{id}.jsonl")); + let text = match std::fs::read_to_string(&legacy) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(), + Err(error) => return Err(format!("read {}: {}", legacy.display(), error)), + }; + let messages = parse_session_messages(&text)?; + let sidecar = dir.join(format!("{id}.meta.json")); + let legacy_meta = std::fs::read_to_string(sidecar) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()); + let mut header = text + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|value| value.get("type").and_then(|v| v.as_str()) == Some("session_meta")) + .or(legacy_meta) + .unwrap_or_else(|| serde_json::json!({ "type": "session_meta", "id": id, "cwd": "" })); + header["type"] = serde_json::Value::String("session_meta".to_string()); + header["schema_version"] = serde_json::Value::Number(1.into()); + header["id"] = serde_json::Value::String(id.to_string()); + if let Some(created_at) = header.get("createdAt").cloned() { + header["created_at"] = created_at; + } + if let Some(updated_at) = header.get("updatedAt").cloned() { + header["updated_at"] = updated_at; + } + let mut lines = vec![header.to_string()]; + for mut message in messages { + message["type"] = serde_json::Value::String("message".to_string()); + message["schema_version"] = serde_json::Value::Number(1.into()); + lines.push(message.to_string()); + } + let temp = dir.join(format!("{id}.v1.{}.tmp", std::process::id())); + std::fs::write(&temp, lines.join("\n") + "\n") + .map_err(|e| format!("write {}: {}", temp.display(), e))?; + std::fs::rename(&temp, &canonical) + .map_err(|e| format!("rename {}: {}", temp.display(), e))?; + Ok(canonical) +} + +fn parse_session_messages(text: &str) -> Result, String> { + let lines: Vec<&str> = text.split('\n').collect(); + let last_content = lines.iter().rposition(|line| !line.trim().is_empty()); let mut out = Vec::new(); - for line in text.lines() { + for (index, line) in lines.iter().enumerate() { let line = line.trim(); if line.is_empty() { continue; } - let Ok(v) = serde_json::from_str::(line) else { - continue; // tolerate a partial trailing line + let v = match serde_json::from_str::(line) { + Ok(value) => value, + Err(_) if Some(index) == last_content && !text.ends_with('\n') => { + continue; // recover an interrupted final append only + } + Err(error) => { + return Err(format!("corrupt session at line {}: {}", index + 1, error)); + } }; // Desktop sessions tag messages with type:"message"; CLI/headless sessions // write bare {role, content} lines with no type. Accept both, skip meta. @@ -206,30 +245,18 @@ pub fn session_read(id: String) -> Result, String> { Some("user") | Some("assistant") ); if t == Some("message") || (t.is_none() && is_role_msg) { + if !v.get("content").is_some_and(|content| content.is_array()) { + return Err(format!( + "corrupt session at line {}: message content must be an array", + index + 1 + )); + } out.push(v); } } Ok(out) } -fn format_date(secs: u64) -> String { - // Simple YYYY-MM-DD; days since epoch math is enough for filename use. - let days = secs / 86_400; - // Reference: 1970-01-01 was a Thursday; we compute YMD via the - // standard "civil_from_days" algorithm by Howard Hinnant. - let z = days as i64 + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); - let mp = (5 * doy + 2) / 153; - let d = doy - (153 * mp + 2) / 5 + 1; - let m = if mp < 10 { mp + 3 } else { mp - 9 }; - let y = if m <= 2 { y + 1 } else { y }; - format!("{:04}-{:02}-{:02}", y, m, d) -} - /// List session files under ~/.deepcode/sessions/. Returns just metadata. #[derive(Serialize)] pub struct SessionMeta { @@ -296,13 +323,13 @@ fn derive_session_title(path: &std::path::Path) -> Option { /// Set (or clear, with "") a session's manual title on its session_meta header. #[tauri::command] pub fn session_set_title(id: String, title: String) -> Result<(), String> { + safe_session_id(&id)?; let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{id}.jsonl")); + let dir = home.join(".deepcode").join("sessions"); + let _lock = SessionWriterLock::acquire(&dir, &id)?; + let path = ensure_canonical_session(&dir, &id)?; let text = std::fs::read_to_string(&path).map_err(|e| format!("read {}: {}", path.display(), e))?; let trimmed = title.trim(); let mut lines: Vec = text.lines().map(|l| l.to_string()).collect(); @@ -320,7 +347,9 @@ pub fn session_set_title(id: String, title: String) -> Result<(), String> { } if !updated { // No meta header (older session) — prepend one carrying the title. - let meta = serde_json::json!({ "type": "session_meta", "id": id, "title": trimmed }); + let meta = serde_json::json!({ + "type": "session_meta", "schema_version": 1, "id": id, "title": trimmed + }); lines.insert(0, meta.to_string()); } std::fs::write(&path, lines.join("\n") + "\n") @@ -359,7 +388,7 @@ pub fn list_sessions() -> Result, String> { Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), Err(e) => return Err(format!("read_dir {}: {}", dir.display(), e)), }; - let mut out = Vec::new(); + let mut selected: HashMap = HashMap::new(); for entry in read.flatten() { let path = entry.path(); if !path.is_file() { @@ -368,11 +397,20 @@ pub fn list_sessions() -> Result, String> { let Some(name) = path.file_name().and_then(|s| s.to_str()) else { continue; }; - if !name.ends_with(".jsonl") { + let (id, canonical) = if let Some(id) = name.strip_suffix(".v1.jsonl") { + (id.to_string(), true) + } else if let Some(id) = name.strip_suffix(".jsonl") { + (id.to_string(), false) + } else { continue; + }; + if canonical || !selected.contains_key(&id) { + selected.insert(id, path); } - let id = name.trim_end_matches(".jsonl").to_string(); - let meta = entry.metadata().map_err(|e| e.to_string())?; + } + let mut out = Vec::new(); + for (id, path) in selected { + let meta = std::fs::metadata(&path).map_err(|e| e.to_string())?; let updated_at_secs = meta .modified() .ok() @@ -407,11 +445,17 @@ pub fn session_delete(id: String) -> Result<(), String> { let Some(home) = dirs::home_dir() else { return Err("no home directory".into()); }; - let path = home - .join(".deepcode") - .join("sessions") - .join(format!("{id}.jsonl")); - std::fs::remove_file(&path).map_err(|e| format!("delete {}: {}", path.display(), e)) + let dir = home.join(".deepcode").join("sessions"); + let mut removed = false; + for name in [format!("{id}.v1.jsonl"), format!("{id}.jsonl"), format!("{id}.meta.json")] { + let path = dir.join(name); + match std::fs::remove_file(&path) { + Ok(()) => removed = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("delete {}: {}", path.display(), error)), + } + } + if removed { Ok(()) } else { Err(format!("session not found: {id}")) } } /// Archive a session by moving its JSONL into sessions/archived/ — excluded from @@ -426,9 +470,17 @@ pub fn session_archive(id: String) -> Result<(), String> { let archived = dir.join("archived"); std::fs::create_dir_all(&archived) .map_err(|e| format!("mkdir {}: {}", archived.display(), e))?; - let from = dir.join(format!("{id}.jsonl")); - let to = archived.join(format!("{id}.jsonl")); - std::fs::rename(&from, &to).map_err(|e| format!("archive {}: {}", from.display(), e)) + let mut moved = false; + for name in [format!("{id}.v1.jsonl"), format!("{id}.jsonl"), format!("{id}.meta.json")] { + let from = dir.join(&name); + let to = archived.join(&name); + match std::fs::rename(&from, &to) { + Ok(()) => moved = true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("archive {}: {}", from.display(), error)), + } + } + if moved { Ok(()) } else { Err(format!("session not found: {id}")) } } /// Path to the `deepcode` CLI so the GUI can drop users into it for advanced @@ -773,6 +825,68 @@ mod contract_tests { assert!(name.is_none() && desc.is_none()); } + #[test] + fn session_parser_accepts_both_legacy_formats_and_truncated_tail() { + let text = concat!( + "{\"type\":\"session_meta\",\"id\":\"x\"}\n", + "{\"type\":\"message\",\"role\":\"user\",\"content\":[]}\n", + "{\"role\":\"assistant\",\"content\":[]}\n", + "{\"role\":\"assistant\"" + ); + let messages = parse_session_messages(text).unwrap(); + assert_eq!(messages.len(), 2); + } + + #[test] + fn session_parser_rejects_middle_corruption() { + let text = concat!( + "{\"role\":\"user\",\"content\":[]}\n", + "{not-json}\n", + "{\"role\":\"assistant\",\"content\":[]}\n" + ); + let error = parse_session_messages(text).unwrap_err(); + assert!(error.contains("line 2"), "got {error}"); + } + + #[test] + fn canonical_session_normalizes_without_touching_legacy() { + let root = std::env::temp_dir().join(format!( + "dc-session-v1-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let legacy = root.join("legacy.jsonl"); + let original = "{\"role\":\"user\",\"content\":[]}\n"; + std::fs::write(&legacy, original).unwrap(); + std::fs::write( + root.join("legacy.meta.json"), + "{\"id\":\"legacy\",\"cwd\":\"/core\",\"createdAt\":\"2025-01-01T00:00:00Z\",\"updatedAt\":\"2025-01-02T00:00:00Z\"}", + ) + .unwrap(); + + let _lock = SessionWriterLock::acquire(&root, "legacy").unwrap(); + let canonical = ensure_canonical_session(&root, "legacy").unwrap(); + assert_eq!(std::fs::read_to_string(&legacy).unwrap(), original); + let normalized = std::fs::read_to_string(canonical).unwrap(); + let records: Vec = normalized + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(records.len(), 2); + assert_eq!(records[0]["schema_version"], 1); + assert_eq!(records[0]["cwd"], "/core"); + assert_eq!(records[0]["created_at"], "2025-01-01T00:00:00Z"); + assert_eq!(records[1]["type"], "message"); + assert_eq!(records[1]["schema_version"], 1); + assert!(SessionWriterLock::acquire(&root, "legacy").is_err()); + drop(_lock); + let _ = std::fs::remove_dir_all(root); + } + #[test] fn skill_info_serializes_camel_case() { let v = serde_json::to_value(SkillInfo { diff --git a/apps/desktop/src-tauri/src/credentials.rs b/apps/desktop/src-tauri/src/credentials.rs index a2df4a0..a76d05b 100644 --- a/apps/desktop/src-tauri/src/credentials.rs +++ b/apps/desktop/src-tauri/src/credentials.rs @@ -14,6 +14,14 @@ pub struct Credentials { pub base_url: Option, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CredentialStatus { + pub has_key: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub base_url: Option, +} + pub fn credentials_path() -> Option { let home = dirs::home_dir()?; Some(home.join(".deepcode").join("credentials.json")) @@ -30,6 +38,21 @@ pub fn read() -> Result { } } +pub fn status() -> Result { + let credentials = read()?; + Ok(CredentialStatus { + has_key: credentials + .api_key + .as_ref() + .is_some_and(|value| !value.is_empty()) + || credentials + .auth_token + .as_ref() + .is_some_and(|value| !value.is_empty()), + base_url: credentials.base_url, + }) +} + pub fn write(creds: &Credentials) -> Result<(), String> { let Some(path) = credentials_path() else { return Err("no home directory".into()); @@ -49,9 +72,8 @@ pub fn write(creds: &Credentials) -> Result<(), String> { } // ── Serde contract ───────────────────────────────────────────────────── -// tauri-api.ts#readCredentials reads `api_key`/`auth_token`/`base_url` (snake) -// and maps them to camelCase itself. Lock that shape + the skip-if-None omission -// the TS side relies on (missing field → undefined). See HANDOFF §8a. +// Credentials remain backend-only. The renderer receives CredentialStatus, +// while this shape stays compatible with the CLI's credentials.json. #[cfg(test)] mod contract_tests { use super::*; @@ -76,4 +98,17 @@ mod contract_tests { let v = serde_json::to_value(Credentials::default()).unwrap(); assert_eq!(v.as_object().unwrap().len(), 0, "None fields must be skipped: {v}"); } + + #[test] + fn status_never_serializes_credentials() { + let value = serde_json::to_value(CredentialStatus { + has_key: true, + base_url: Some("https://host/v1".into()), + }) + .unwrap(); + assert_eq!(value["hasKey"], true); + assert_eq!(value["baseUrl"], "https://host/v1"); + assert!(value.get("api_key").is_none()); + assert!(value.get("auth_token").is_none()); + } } diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 866d119..25bc2a4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,28 +3,32 @@ // // Architecture: most of DeepCode's logic lives in @deepcode/core (TypeScript). // The Tauri backend's job is to host the webview and expose a few native -// commands that the frontend can't do (file dialogs, credentials read/write, -// settings file IO, child-process spawn for CLI integration). -// -// The agent loop itself runs in the renderer via @deepcode/core — no Node -// runtime in main process means smaller binary + faster startup. +// commands that the frontend can't do (file dialogs, credential save/status, +// settings/session index IO, and read-only file previews). Rust supervises the +// bundled app-server sidecar; runtime/tool execution never runs in the webview. +mod app_server; mod commands; mod credentials; mod settings; +#[allow(dead_code)] // mutation-only snapshot helpers remain for compatibility tests mod snapshots; +#[allow(dead_code)] // legacy native mutation helpers are no longer renderer commands mod tools; mod voice; +use app_server::{ + app_server_send, app_server_start, app_server_status, app_server_stop, AppServerState, +}; use commands::{ append_allow_matcher, cli_path, get_app_info, get_settings_path, list_plugins, list_sessions, - list_skills, load_keybindings, load_settings_file, open_url, read_credentials, - save_credentials, save_keybindings, save_settings_file, session_append, session_archive, - session_create, session_delete, session_read, session_set_title, + credential_status, list_skills, load_keybindings, load_settings_file, open_url, + save_credentials, save_keybindings, save_settings_file, session_archive, session_delete, + session_read, session_set_title, }; use snapshots::session_snapshots; use tauri::Manager; -use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; +use tools::tool_read; use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -37,9 +41,14 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .manage(VoiceState::default()) + .manage(AppServerState::default()) .invoke_handler(tauri::generate_handler![ get_app_info, - read_credentials, + app_server_start, + app_server_send, + app_server_stop, + app_server_status, + credential_status, save_credentials, load_settings_file, save_settings_file, @@ -47,8 +56,6 @@ pub fn run() { append_allow_matcher, load_keybindings, save_keybindings, - session_create, - session_append, session_read, session_set_title, session_delete, @@ -59,11 +66,6 @@ pub fn run() { cli_path, open_url, tool_read, - tool_write, - tool_edit, - tool_bash, - tool_glob, - tool_grep, session_snapshots, voice_status, voice_start, diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs index 3f43c07..016ff35 100644 --- a/apps/desktop/src-tauri/src/tools.rs +++ b/apps/desktop/src-tauri/src/tools.rs @@ -5,10 +5,12 @@ use crate::snapshots; use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::Path; use std::process::Stdio; use tokio::io::AsyncReadExt; use tokio::process::Command; +use tokio::sync::{oneshot, Mutex}; // ────────────────────────────────────────────────────────────────────────── // Snapshot capture @@ -85,7 +87,16 @@ pub async fn tool_read( offset: Option, limit: Option, ) -> Result { - let raw = tokio::fs::read_to_string(&file_path) + let resolved = tokio::fs::canonicalize(&file_path) + .await + .map_err(|e| format!("read {}: {}", file_path, e))?; + let credentials_path = if let Some(path) = crate::credentials::credentials_path() { + tokio::fs::canonicalize(path).await.ok() + } else { + None + }; + reject_credentials_path(&resolved, credentials_path.as_deref())?; + let raw = tokio::fs::read_to_string(&resolved) .await .map_err(|e| format!("read {}: {}", file_path, e))?; let lines: Vec<&str> = raw.split('\n').collect(); @@ -127,6 +138,14 @@ pub async fn tool_read( }) } +fn reject_credentials_path(resolved: &Path, credentials_path: Option<&Path>) -> Result<(), String> { + if credentials_path.is_some_and(|path| resolved == path) { + Err("credential files are backend-only".to_string()) + } else { + Ok(()) + } +} + // ────────────────────────────────────────────────────────────────────────── // Write // ────────────────────────────────────────────────────────────────────────── @@ -240,10 +259,42 @@ pub struct BashOk { pub stderr: String, pub exit_code: i32, pub timed_out: bool, + pub cancelled: bool, } +#[derive(Default)] +pub struct BashState { + // `Some(sender)` is running; `None` records an abort that raced ahead of + // command registration so the process never escapes cancellation. + active: Mutex>>>, +} + +#[cfg(unix)] +fn kill_process_group(pid: u32) { + // The shell is placed in its own process group below, so a negative PID + // terminates the shell and every descendant it spawned. + unsafe { + libc::kill(-(pid as i32), libc::SIGKILL); + } +} + +#[cfg(not(unix))] +fn kill_process_group(_pid: u32) {} + #[tauri::command] -pub async fn tool_bash(input: BashInput) -> Result { +pub async fn tool_bash( + input: BashInput, + command_id: String, + state: tauri::State<'_, BashState>, +) -> Result { + run_bash(input, command_id, &state).await +} + +async fn run_bash( + input: BashInput, + command_id: String, + state: &BashState, +) -> Result { let timeout = std::time::Duration::from_millis(input.timeout_ms.unwrap_or(120_000)); let mut cmd = Command::new("/bin/sh"); cmd.arg("-c").arg(&input.command); @@ -251,8 +302,24 @@ pub async fn tool_bash(input: BashInput) -> Result { cmd.current_dir(cwd); } cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + cmd.as_std_mut().process_group(0); + } let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?; + let pid = child.id().ok_or("spawned process has no pid")?; + let (cancel_tx, mut cancel_rx) = oneshot::channel(); + { + let mut active = state.active.lock().await; + if matches!(active.get(&command_id), Some(None)) { + active.remove(&command_id); + drop(cancel_tx); + } else { + active.insert(command_id.clone(), Some(cancel_tx)); + } + } let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; @@ -268,31 +335,77 @@ pub async fn tool_bash(input: BashInput) -> Result { s }); - let mut timed_out = false; - let exit_status = match tokio::time::timeout(timeout, child.wait()).await { - Ok(s) => s.map_err(|e| format!("wait: {e}"))?, - Err(_) => { - timed_out = true; + enum Finish { + Exited(std::io::Result), + TimedOut, + Cancelled, + } + let finish = tokio::select! { + status = child.wait() => Finish::Exited(status), + _ = tokio::time::sleep(timeout) => Finish::TimedOut, + _ = &mut cancel_rx => Finish::Cancelled, + }; + state.active.lock().await.remove(&command_id); + + let (exit_code, timed_out, cancelled) = match finish { + Finish::Exited(status) => ( + status + .map_err(|e| format!("wait: {e}"))? + .code() + .unwrap_or(-1), + false, + false, + ), + Finish::TimedOut => { + kill_process_group(pid); let _ = child.start_kill(); let _ = child.wait().await; - return Ok(BashOk { - stdout: String::new(), - stderr: format!("timeout after {}ms", timeout.as_millis()), - exit_code: 124, - timed_out, - }); + (124, true, false) + } + Finish::Cancelled => { + kill_process_group(pid); + let _ = child.start_kill(); + let _ = child.wait().await; + (130, false, true) } }; let stdout = stdout_task.await.unwrap_or_default(); - let stderr = stderr_task.await.unwrap_or_default(); + let mut stderr = stderr_task.await.unwrap_or_default(); + if timed_out { + stderr.push_str(&format!("\ntimeout after {}ms", timeout.as_millis())); + } + if cancelled { + stderr.push_str("\naborted by user"); + } Ok(BashOk { stdout, stderr, - exit_code: exit_status.code().unwrap_or(-1), + exit_code, timed_out, + cancelled, }) } +#[tauri::command] +pub async fn tool_bash_cancel( + command_id: String, + state: tauri::State<'_, BashState>, +) -> Result { + Ok(cancel_bash(command_id, &state).await) +} + +async fn cancel_bash(command_id: String, state: &BashState) -> bool { + let mut active = state.active.lock().await; + match active.remove(&command_id) { + Some(Some(cancel)) => cancel.send(()).is_ok(), + Some(None) => true, + None => { + active.insert(command_id, None); + true + } + } +} + // ────────────────────────────────────────────────────────────────────────── // Glob (filesystem pattern match) // ────────────────────────────────────────────────────────────────────────── @@ -432,6 +545,13 @@ mod casing_tests { ); } + #[test] + fn renderer_read_rejects_backend_credentials() { + let credential = Path::new("/home/user/.deepcode/credentials.json"); + assert!(reject_credentials_path(credential, Some(credential)).is_err()); + assert!(reject_credentials_path(Path::new("/workspace/src.ts"), Some(credential)).is_ok()); + } + #[test] fn edit_ok_serializes_camel_case() { let v = serde_json::to_value(EditOk { @@ -454,17 +574,59 @@ mod casing_tests { stderr: String::new(), exit_code: 0, timed_out: false, + cancelled: false, }) .unwrap(); let k = keys(&v); // The exit-code badge bug: renderer compares r.exitCode !== 0. assert!(k.contains(&"exitCode".to_string()), "got {k:?}"); assert!(k.contains(&"timedOut".to_string()), "got {k:?}"); + assert!(k.contains(&"cancelled".to_string()), "got {k:?}"); assert!( !k.contains(&"exit_code".to_string()), "snake_case leaked: {k:?}" ); } + + #[cfg(unix)] + #[tokio::test] + async fn bash_cancel_kills_descendants() { + use std::sync::Arc; + + let root = std::env::temp_dir().join(format!( + "dc-rust-bash-cancel-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + let marker = root.join("orphan-marker.txt"); + let command = format!("(sleep 0.4; echo orphan > '{}') & wait", marker.display()); + let state = Arc::new(BashState::default()); + let run_state = state.clone(); + let task = tokio::spawn(async move { + run_bash( + BashInput { + command, + cwd: Some(root.to_string_lossy().to_string()), + timeout_ms: Some(5_000), + }, + "cancel-test".to_string(), + &run_state, + ) + .await + }); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert!(cancel_bash("cancel-test".to_string(), &state).await); + let result = task.await.unwrap().unwrap(); + assert!(result.cancelled); + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + assert!(!marker.exists(), "descendant survived cancellation"); + let _ = std::fs::remove_dir_all(marker.parent().unwrap()); + } } // ── snapshot capture path ─────────────────────────────────────────────── diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index fc38f3a..bcce2ab 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -6,8 +6,8 @@ "build": { "frontendDist": "../dist", "devUrl": "http://localhost:5173", - "beforeDevCommand": "pnpm dev", - "beforeBuildCommand": "pnpm build" + "beforeDevCommand": "pnpm dev:tauri", + "beforeBuildCommand": "pnpm build:tauri-assets" }, "app": { "windows": [ @@ -32,8 +32,10 @@ "active": true, "targets": ["app"], "resources": { - "../../../packages/core/skills": "skills" + "../../../packages/core/skills": "skills", + "../../server/dist-sidecar/app-server.cjs": "app-server.cjs" }, + "externalBin": ["binaries/deepcode-runtime"], "category": "public.app-category.developer-tools", "shortDescription": "DeepSeek-powered coding agent", "longDescription": "DeepCode is a Claude-Code-parity coding agent powered by DeepSeek — chat, plan mode, tool use, sandboxed bash, MCP, plugins.", diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index 109d763..ff58361 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -3,7 +3,7 @@ // Milestone: 0.1.2 — adds project-folder flow + inspector wiring + session refresh. import { useCallback, useEffect, useState } from 'react'; -import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js'; +import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js'; import { FilePanel } from './components/FilePanel.js'; import { InspectorPanel } from './components/InspectorPanel.js'; import { InspectorRail } from './components/InspectorRail.js'; @@ -12,7 +12,7 @@ import { SETTINGS_FAMILY, SettingsLayout } from './components/SettingsLayout.js' import { Sidebar } from './components/Sidebar.js'; import { UpdateBanner } from './components/UpdateBanner.js'; import { registerShortcut } from './lib/keyboard.js'; -import { clearHistory as clearAgentHistory } from './lib/mac-agent.js'; +import { clearProtocolThread as clearAgentHistory } from './lib/protocol-agent.js'; import { loadProjectPath, saveProjectPath } from './lib/project.js'; import { storedToMsgs, type Msg } from './lib/repl-stream.js'; import { onUpdateDownloaded, startUpdaterPolling } from './lib/updater.js'; @@ -36,7 +36,12 @@ export function App(): JSX.Element { const [update, setUpdate] = useState(null); const [screen, setScreen] = useState('repl'); const [activeSessionId, setActiveSessionId] = useState(null); - const [sessionEpoch, setSessionEpoch] = useState(0); + // Sidebar refreshes must not remount the active REPL: a completed turn is + // persisted asynchronously and refreshing the session list used to erase + // the just-streamed transcript. Only explicit session/project transitions + // advance the REPL epoch. + const [sidebarEpoch, setSidebarEpoch] = useState(0); + const [replEpoch, setReplEpoch] = useState(0); // Reconstructed messages for a resumed session; seeded into ReplScreen on its // next remount. Cleared when starting a fresh session. const [resumedMessages, setResumedMessages] = useState(undefined); @@ -112,7 +117,8 @@ export function App(): JSX.Element { setResumedMessages(undefined); setActiveSessionId(null); setScreen('repl'); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }); const offComma = registerShortcut('meta+,', () => setScreen('settings')); const offSlash = registerShortcut('meta+/', () => setScreen('about')); @@ -194,7 +200,7 @@ export function App(): JSX.Element { )} {update && } { @@ -208,7 +214,8 @@ export function App(): JSX.Element { } setActiveSessionId(id); setScreen('repl'); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} onNewSession={() => { clearAgentHistory(); @@ -216,7 +223,8 @@ export function App(): JSX.Element { setActiveSessionId(null); setScreen('repl'); // Force ReplScreen to remount with a clean message history - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} onSwitchProject={async () => { // Force-show the picker again by clearing state. Also clear @@ -226,7 +234,8 @@ export function App(): JSX.Element { setResumedMessages(undefined); setProjectPath(null); setActiveSessionId(null); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} onSessionRemoved={() => { // The active session was archived/deleted — reset to a fresh chat. @@ -234,15 +243,17 @@ export function App(): JSX.Element { setResumedMessages(undefined); setActiveSessionId(null); setScreen('repl'); - setSessionEpoch((k) => k + 1); + setSidebarEpoch((k) => k + 1); + setReplEpoch((k) => k + 1); }} /> -
+
{renderScreen( screen, setScreen, projectPath, - () => setSessionEpoch((k) => k + 1), + () => setSidebarEpoch((k) => k + 1), + setActiveSessionId, handleInspector, resumedMessages, openFile, @@ -290,6 +301,7 @@ function renderScreen( setScreen: (s: ScreenName) => void, projectPath: string, onTurnComplete: () => void, + onSessionStarted: (sessionId: string) => void, onInspector: (patch: Partial) => void, initialMessages?: Msg[], onOpenFile?: (path: string) => void, @@ -301,6 +313,7 @@ function renderScreen( l.trim()) - .find((l) => l.length > 0) ?? userMessage.trim(); - return firstLine.slice(0, 60); -} - -// Local minimal ToolRegistry — same shape as @deepcode/core's, without -// the BUILTIN_TOOLS top-level import that drags in fs. -class LocalToolRegistry { - private readonly tools = new Map(); - constructor(initial: ToolHandler[]) { - for (const t of initial) this.tools.set(t.name, t); - } - register(t: ToolHandler): void { - this.tools.set(t.name, t); - } - get(name: string): ToolHandler | undefined { - return this.tools.get(name); - } - list(): ToolHandler[] { - return [...this.tools.values()]; - } - definitions() { - return this.list().map((t) => t.definition); - } -} - -function buildSystemPrompt(cwd?: string): string { - return `You are DeepCode, an AI coding assistant powered by DeepSeek. -Help the user with their codebase using the available tools (Read, Write, Edit, Bash, Grep, Glob). -Be concise and accurate. When you modify files, briefly explain what you changed and why. - -${cwd ? `Working directory: ${cwd}\nAll relative paths resolve against this directory.` : 'NO project folder has been picked yet. Tell the user to pick one before asking for file edits.'} - -Tool input schemas use snake_case field names (e.g. file_path, old_string). -ALWAYS pass absolute paths or paths relative to the working directory above.`; -} - -/** A single in-flight turn. */ -interface ActiveTurn { - turnId: string; - abortController: AbortController; -} - -const turns = new Map(); -let history: import('@deepcode/core/dist/types.js').StoredMessage[] = []; -let provider: DeepSeekProvider | null = null; -// One active session id per app run — created lazily on first turn. -let currentSessionId: string | null = null; - -export function clearSession(): void { - currentSessionId = null; - setActiveSessionId(null); - history = []; -} - -/** - * Resume an existing session: adopt its id + loaded history so the next turn - * continues that conversation (with full context) and appends to its JSONL - * rather than starting a new file. - */ -export function resumeSession( - sessionId: string, - loadedHistory: import('@deepcode/core/dist/types.js').StoredMessage[], -): void { - currentSessionId = sessionId; - setActiveSessionId(sessionId); - history = loadedHistory; -} - -async function ensureProvider(): Promise { - if (provider) return provider; - const creds = await readCredentials(); - if (!creds.apiKey && !creds.authToken) { - throw new Error( - 'No DeepSeek credentials. Set your API key in onboarding or via ~/.deepcode/credentials.json.', - ); - } - provider = new DeepSeekProvider({ - apiKey: creds.apiKey ?? '', - authToken: creds.authToken, - baseURL: creds.baseURL, - }); - return provider; -} - -export interface StartTurnArgs { - userMessage: string; - model?: string; - mode?: Mode; - /** Effort tier — controls maxTokens + temperature. Default 'high'. */ - effort?: Effort; - /** Project folder absolute path. Tools resolve relative paths against this. - * When undefined, tools error because the agent can't safely guess. */ - cwd?: string; - onEvent: (e: AgentEvent) => void; - onDone: (reason: 'end_turn' | 'max_turns' | 'aborted' | 'error') => void; - /** Called when the agent needs user approval for a tool call. Resolves to: - * 'allow' — permit this one call - * 'deny' — reject - * 'always' — permit + persist a permissions.allow matcher - */ - onApproval?: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; - /** Called when the agent's AskUserQuestion tool needs an answer. Resolves to - * the chosen option label (or free text). */ - onAskUser?: (req: { - question: string; - options: Array<{ label: string; description: string }>; - multiSelect?: boolean; - }) => Promise; -} - -export interface StartTurnResult { - turnId: string; -} - -export async function startAgentTurn(args: StartTurnArgs): Promise { - const turnId = `mac-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; - const abort = new AbortController(); - turns.set(turnId, { turnId, abortController: abort }); - - // Lazily create a session JSONL on first turn, so the sidebar can - // surface it. Failures here are non-fatal — we just don't persist. - const isNewSession = !currentSessionId; - if (!currentSessionId) { - try { - currentSessionId = await sessionCreate(args.cwd ?? '/'); - // Publish so the tools snapshot under this id and the file panel can read them. - setActiveSessionId(currentSessionId); - } catch (err) { - console.warn('session_create failed (continuing without persistence):', err); - } - } - // Append the user message right away so the file shows non-zero activity. - if (currentSessionId) { - try { - await sessionAppend(currentSessionId, { - type: 'message', - role: 'user', - content: [{ type: 'text', text: args.userMessage }], - timestamp: new Date().toISOString(), - }); - } catch (err) { - console.warn('session_append (user) failed:', err); - } - // Title a brand-new session from its first user message (Claude-Code style), - // so the sidebar shows a human label immediately rather than the raw id. - if (isNewSession) { - try { - await sessionSetTitle(currentSessionId, sessionTitleFrom(args.userMessage)); - } catch (err) { - console.warn('session_set_title failed:', err); - } - } - } - - const prov = await ensureProvider(); - // Cast: nominal-typing on the private `tools` field makes TS reject the - // structural match. Runtime shape is identical. - const tools = new LocalToolRegistry(MAC_TOOLS) as unknown as Parameters< - typeof runAgent - >[0]['tools']; - - // Run the agent loop in the background. Errors are surfaced via onEvent. - (async () => { - try { - // Default to 'high' (6k output budget): the desktop's primary use is - // writing/editing files, and 'medium' (3k) routinely truncates a single - // multi-file write mid-tool-call. Users can still dial it down per-turn. - const effortParams = EFFORT_PARAMS[args.effort ?? 'high']; - const result = await runAgent({ - provider: prov, - tools, - systemPrompt: buildSystemPrompt(args.cwd), - userMessage: args.userMessage, - history, - model: args.model ?? 'deepseek-chat', - maxTokens: effortParams.maxTokens, - temperature: effortParams.temperature, - cwd: args.cwd ?? '/', - signal: abort.signal, - mode: args.mode, - // Disable system reminders in the renderer — they require node:fs - // (reads todos.json + stats files). The Mac UI surfaces those - // contextually elsewhere. - systemReminders: false, - approval: args.onApproval - ? async (toolName, _input, verdict) => { - const reason = verdict.reason ?? `Approve ${toolName}?`; - const decision = await args.onApproval!(toolName, reason); - if (decision === 'always') return 'always'; - return decision === 'allow'; - } - : undefined, - askUser: args.onAskUser ? async (req) => args.onAskUser!(req) : undefined, - onEvent: args.onEvent, - // No hook dispatcher, no sessions persistence, no autoCompact in v1 Mac MVP. - }); - history = result.history; - // Append the new assistant message(s) for persistence. - if (currentSessionId && history.length > 0) { - const newestAssistant = [...history].reverse().find((m) => m.role === 'assistant'); - if (newestAssistant) { - try { - await sessionAppend(currentSessionId, { - type: 'message', - ...newestAssistant, - }); - } catch (err) { - console.warn('session_append (assistant) failed:', err); - } - } - } - args.onDone(result.stopReason); - } catch (err) { - args.onEvent({ type: 'error', error: (err as Error).message ?? String(err) }); - args.onDone('error'); - } finally { - turns.delete(turnId); - } - })(); - - return { turnId }; -} - -export function abortAgentTurn(turnId: string): boolean { - const t = turns.get(turnId); - if (!t) return false; - t.abortController.abort(); - return true; -} - -export function clearHistory(): void { - history = []; - currentSessionId = null; - setActiveSessionId(null); -} - -export function getHistoryLength(): number { - return history.length; -} diff --git a/apps/desktop/src/lib/mac-session.ts b/apps/desktop/src/lib/mac-session.ts index 3930df8..1801380 100644 --- a/apps/desktop/src/lib/mac-session.ts +++ b/apps/desktop/src/lib/mac-session.ts @@ -1,12 +1,10 @@ -// The id of the session the agent is currently writing to. mac-agent owns the -// session lifecycle (lazy create on first turn, resume, clear) and publishes -// the active id here; mac-tools reads it to stamp file snapshots, and the file -// panel reads it to fetch those snapshots. Kept in its own tiny module so both -// sides depend on it without a mac-agent ↔ mac-tools import cycle. +// Compatibility bridge for panels that still address canonical sessions. +// The protocol agent publishes the active thread id here; canonical thread and +// session ids are identical during the rollout. let activeSessionId: string | null = null; -/** Set (or clear, with null) the session the tools should snapshot under. */ +/** Set (or clear, with null) the canonical session selected by the UI. */ export function setActiveSessionId(id: string | null): void { activeSessionId = id; } diff --git a/apps/desktop/src/lib/mac-tools.test.ts b/apps/desktop/src/lib/mac-tools.test.ts deleted file mode 100644 index 9175e26..0000000 --- a/apps/desktop/src/lib/mac-tools.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @vitest-environment node -// Sanity tests for the mac-tools key-pick helpers + tool schema entries. -// These cover the conversation-blocking bug from 0.1.1 where DeepSeek -// emitted camelCase keys against a snake_case schema and the wrappers -// passed undefined to Tauri, getting "missing required key …". -// -// We can't easily mock `invoke()` without an env shim, so this only -// exercises the helpers + tool definitions. The actual Tauri command -// round-trip is exercised manually + by the integration DMG smoke test. - -import { describe, expect, it } from 'vitest'; - -// Re-implement the helpers under test by extracting them. We can't -// import them directly because mac-tools imports @tauri-apps/api/core -// which can't load outside a Tauri webview. The helpers are pure so -// duplicating them in the test is fine; if either ever changes, both -// places must be updated. -function pickStr(input: Record, ...keys: string[]): string | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'string') return v; - } - return undefined; -} -function pickNum(input: Record, ...keys: string[]): number | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'number') return v; - } - return undefined; -} -function pickBool(input: Record, ...keys: string[]): boolean | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'boolean') return v; - } - return undefined; -} - -describe('mac-tools key pickers', () => { - it('pickStr returns the first matching string', () => { - expect(pickStr({ file_path: '/a' }, 'file_path', 'filePath')).toBe('/a'); - expect(pickStr({ filePath: '/b' }, 'file_path', 'filePath')).toBe('/b'); - expect(pickStr({ path: '/c' }, 'file_path', 'filePath', 'path')).toBe('/c'); - }); - - it('pickStr prefers earlier-listed keys (snake_case wins over camelCase)', () => { - expect(pickStr({ file_path: '/snake', filePath: '/camel' }, 'file_path', 'filePath')).toBe( - '/snake', - ); - }); - - it('pickStr returns undefined when no key matches', () => { - expect(pickStr({ foo: 'bar' }, 'file_path', 'filePath')).toBeUndefined(); - }); - - it('pickStr skips non-string values', () => { - expect(pickStr({ file_path: 42, filePath: '/ok' }, 'file_path', 'filePath')).toBe('/ok'); - expect(pickStr({ file_path: null, filePath: '/ok' }, 'file_path', 'filePath')).toBe('/ok'); - }); - - it('pickNum handles primitives correctly', () => { - expect(pickNum({ offset: 10 }, 'offset')).toBe(10); - expect(pickNum({ offset: '10' as unknown as number }, 'offset')).toBeUndefined(); - expect(pickNum({ offset: 0 }, 'offset')).toBe(0); // zero is valid - }); - - it('pickBool handles primitives correctly', () => { - expect(pickBool({ replace_all: true }, 'replace_all', 'replaceAll')).toBe(true); - expect(pickBool({ replaceAll: false }, 'replace_all', 'replaceAll')).toBe(false); - expect(pickBool({ replace_all: 'true' as unknown as boolean }, 'replace_all')).toBeUndefined(); - }); - - it('empty input returns undefined for all pickers', () => { - expect(pickStr({}, 'a', 'b')).toBeUndefined(); - expect(pickNum({}, 'a', 'b')).toBeUndefined(); - expect(pickBool({}, 'a', 'b')).toBeUndefined(); - }); - - it('rejects keys that contain matching value but with wrong type', () => { - // This is the original 0.1.1 bug: LLM sent the value under the - // "wrong" key, so we tolerate either alias. - const llmInput = { filePath: '/Users/foo/bar.txt', content: 'hello' }; - const filePath = pickStr(llmInput, 'file_path', 'filePath', 'path'); - const content = pickStr(llmInput, 'content', 'text', 'body'); - expect(filePath).toBe('/Users/foo/bar.txt'); - expect(content).toBe('hello'); - }); -}); diff --git a/apps/desktop/src/lib/mac-tools.ts b/apps/desktop/src/lib/mac-tools.ts deleted file mode 100644 index 033b1bb..0000000 --- a/apps/desktop/src/lib/mac-tools.ts +++ /dev/null @@ -1,340 +0,0 @@ -// Mac-flavored ToolHandler implementations. -// -// @deepcode/core's BUILTIN_TOOLS use node:fs / node:child_process which -// don't work in a Tauri webview. These wrappers expose the same -// ToolHandler interface but route through Tauri commands that execute -// fs / bash in the Rust main process. -// -// The agent loop (also from @deepcode/core) is provider-agnostic AND -// IO-agnostic — it just calls `tool.execute(input, ctx)` and the tool -// handles the rest. So substituting these tools is enough. - -import { invoke } from '@tauri-apps/api/core'; -import type { ToolHandler, ToolResult } from '@deepcode/core/dist/types.js'; -import { getActiveSessionId } from './mac-session.js'; - -/** - * Tolerant key pick — accepts either snake_case or camelCase. DeepSeek - * occasionally normalizes JSON Schema keys to camelCase regardless of - * what we asked for; if the agent loop doesn't see the field by the - * exact name in the schema, the value is undefined and the Tauri call - * fails with "missing required key …". This helper lets us accept both. - */ -function pickStr(input: Record, ...keys: string[]): string | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'string') return v; - } - return undefined; -} -function pickNum(input: Record, ...keys: string[]): number | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'number') return v; - } - return undefined; -} -function pickBool(input: Record, ...keys: string[]): boolean | undefined { - for (const k of keys) { - const v = input[k]; - if (typeof v === 'boolean') return v; - } - return undefined; -} - -/** - * Diagnostic suffix for "missing required arg" errors. An empty input almost - * always means the model's tool call was cut off at the output-token limit - * before it emitted any arguments (DeepSeek caps output at ~8k) — surface that - * clearly so the user (and the model, which sees this error) can react. - */ -function describeInput(input: Record): string { - const keys = Object.keys(input); - if (keys.length === 0) { - return ' — the call arrived with NO arguments. The model likely ran out of output tokens before emitting them; raise Effort (try Max) or write a smaller file / split into multiple writes.'; - } - return ` (received keys: ${keys.join(', ')})`; -} - -// ────────────────────────────────────────────────────────────────────────── -// Read -// ────────────────────────────────────────────────────────────────────────── - -export const MacReadTool: ToolHandler = { - name: 'Read', - definition: { - name: 'Read', - description: - 'Read a file from the filesystem. Returns line-numbered content. Use offset/limit for large files.', - inputSchema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path or path relative to cwd.' }, - offset: { type: 'number', description: '1-indexed line to start at.' }, - limit: { type: 'number', description: 'Max lines to return (default 2000).' }, - }, - required: ['file_path'], - }, - }, - async execute(input: Record): Promise { - try { - const filePath = pickStr(input, 'file_path', 'filePath', 'path'); - if (!filePath) { - return { content: `Error: missing file_path${describeInput(input)}`, isError: true }; - } - const r = (await invoke('tool_read', { - filePath, - offset: pickNum(input, 'offset'), - limit: pickNum(input, 'limit'), - })) as { content: string; linesTotal: number; linesShown: number; offset: number }; - return { - content: r.content, - data: { - file: filePath, - lines_total: r.linesTotal, - lines_shown: r.linesShown, - offset: r.offset, - }, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Write -// ────────────────────────────────────────────────────────────────────────── - -export const MacWriteTool: ToolHandler = { - name: 'Write', - definition: { - name: 'Write', - description: - 'Write content to a file. Creates parent directories if needed. Overwrites if file exists.', - inputSchema: { - type: 'object', - properties: { - file_path: { type: 'string', description: 'Absolute path.' }, - content: { type: 'string', description: 'Full file contents to write.' }, - }, - required: ['file_path', 'content'], - }, - }, - async execute(input: Record): Promise { - try { - const filePath = pickStr(input, 'file_path', 'filePath', 'path'); - const content = pickStr(input, 'content', 'text', 'body') ?? ''; - if (!filePath) { - return { content: `Error: missing file_path${describeInput(input)}`, isError: true }; - } - // sessionId lets Rust snapshot the file (file panel Diff/History); omitted - // before the first turn creates a session — capture is best-effort. - await invoke('tool_write', { - filePath, - content, - sessionId: getActiveSessionId() ?? undefined, - }); - const lines = content.split('\n').length; - return { - content: `Wrote ${filePath} (${lines} lines).`, - data: { file: filePath, lines }, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Edit -// ────────────────────────────────────────────────────────────────────────── - -export const MacEditTool: ToolHandler = { - name: 'Edit', - definition: { - name: 'Edit', - description: - 'Replace exact `old_string` with `new_string` in a file. By default, old_string must be unique in the file (use replace_all=true to replace every occurrence).', - inputSchema: { - type: 'object', - properties: { - file_path: { type: 'string' }, - old_string: { type: 'string' }, - new_string: { type: 'string' }, - replace_all: { type: 'boolean', description: 'Default false.' }, - }, - required: ['file_path', 'old_string', 'new_string'], - }, - }, - async execute(input: Record): Promise { - try { - const filePath = pickStr(input, 'file_path', 'filePath', 'path'); - const oldStr = pickStr(input, 'old_string', 'oldString', 'old'); - const newStr = pickStr(input, 'new_string', 'newString', 'new'); - const replaceAll = pickBool(input, 'replace_all', 'replaceAll') ?? false; - if (!filePath || oldStr === undefined || newStr === undefined) { - return { - content: `Error: missing file_path / old_string / new_string${describeInput(input)}`, - isError: true, - }; - } - const r = (await invoke('tool_edit', { - input: { - file_path: filePath, - old_string: oldStr, - new_string: newStr, - replace_all: replaceAll, - }, - sessionId: getActiveSessionId() ?? undefined, - })) as { replaced: number; diffPreview: string }; - return { - content: `Replaced ${r.replaced} occurrence(s) in ${filePath}.\n${r.diffPreview}`, - data: { file: filePath, replaced: r.replaced }, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Bash -// ────────────────────────────────────────────────────────────────────────── - -export const MacBashTool: ToolHandler = { - name: 'Bash', - definition: { - name: 'Bash', - description: - 'Execute a shell command. Returns stdout + stderr + exit code. Default timeout 120s.', - inputSchema: { - type: 'object', - properties: { - command: { type: 'string' }, - cwd: { type: 'string', description: 'Optional working directory.' }, - timeout_ms: { type: 'number', description: 'Optional timeout in milliseconds.' }, - }, - required: ['command'], - }, - }, - async execute(input: Record): Promise { - try { - const command = pickStr(input, 'command', 'cmd'); - if (!command) { - return { content: 'Error: missing command', isError: true }; - } - const r = (await invoke('tool_bash', { - input: { - command, - cwd: pickStr(input, 'cwd', 'working_dir'), - timeout_ms: pickNum(input, 'timeout_ms', 'timeoutMs', 'timeout'), - }, - })) as { stdout: string; stderr: string; exitCode: number; timedOut: boolean }; - const combined = (r.stdout || '') + (r.stderr ? `\n[stderr]\n${r.stderr}` : ''); - return { - content: combined || `(no output, exit ${r.exitCode})`, - data: { exitCode: r.exitCode, timedOut: r.timedOut }, - isError: r.exitCode !== 0, - }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Glob -// ────────────────────────────────────────────────────────────────────────── - -export const MacGlobTool: ToolHandler = { - name: 'Glob', - definition: { - name: 'Glob', - description: 'Find files matching a glob pattern (e.g. `**/*.ts`).', - inputSchema: { - type: 'object', - properties: { - pattern: { type: 'string' }, - cwd: { type: 'string', description: 'Optional working directory; defaults to current.' }, - }, - required: ['pattern'], - }, - }, - async execute(input: Record): Promise { - try { - const pattern = pickStr(input, 'pattern', 'glob'); - if (!pattern) return { content: 'Error: missing pattern', isError: true }; - const r = (await invoke('tool_glob', { - pattern, - cwd: pickStr(input, 'cwd', 'path', 'working_dir'), - })) as { files: string[]; truncated: boolean }; - const body = - r.files.length === 0 - ? '(no matches)' - : r.files.join('\n') + (r.truncated ? `\n[...truncated at 1000]` : ''); - return { content: body, data: { count: r.files.length, truncated: r.truncated } }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -// ────────────────────────────────────────────────────────────────────────── -// Grep -// ────────────────────────────────────────────────────────────────────────── - -export const MacGrepTool: ToolHandler = { - name: 'Grep', - definition: { - name: 'Grep', - description: 'Search for a regex/string pattern recursively. Returns file:line:text.', - inputSchema: { - type: 'object', - properties: { - pattern: { type: 'string' }, - path: { type: 'string', description: 'Optional dir to search; defaults to cwd.' }, - include: { - type: 'string', - description: 'Optional file pattern (e.g. `*.ts`) to restrict matches.', - }, - case_insensitive: { type: 'boolean' }, - }, - required: ['pattern'], - }, - }, - async execute(input: Record): Promise { - try { - const pattern = pickStr(input, 'pattern', 'regex'); - if (!pattern) return { content: 'Error: missing pattern', isError: true }; - const r = (await invoke('tool_grep', { - input: { - pattern, - path: pickStr(input, 'path', 'cwd', 'dir'), - include: pickStr(input, 'include', 'glob'), - case_insensitive: - pickBool(input, 'case_insensitive', 'caseInsensitive', 'ignore_case') ?? false, - }, - })) as { - matches: Array<{ file: string; line: number; text: string }>; - truncated: boolean; - }; - if (r.matches.length === 0) return { content: '(no matches)' }; - const lines = r.matches.map((m) => `${m.file}:${m.line}: ${m.text}`); - if (r.truncated) lines.push('[...truncated at 500 matches]'); - return { content: lines.join('\n'), data: { count: r.matches.length } }; - } catch (err) { - return { content: `Error: ${(err as Error).message ?? String(err)}`, isError: true }; - } - }, -}; - -/** All 6 Mac-flavored tools — pass as `tools` to `new ToolRegistry(MAC_TOOLS)`. */ -export const MAC_TOOLS: ToolHandler[] = [ - MacReadTool, - MacWriteTool, - MacEditTool, - MacBashTool, - MacGlobTool, - MacGrepTool, -]; diff --git a/apps/desktop/src/lib/protocol-agent.test.ts b/apps/desktop/src/lib/protocol-agent.test.ts new file mode 100644 index 0000000..0884d63 --- /dev/null +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -0,0 +1,182 @@ +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { DesktopProtocolAgent, type ProtocolTransport } from './protocol-agent.js'; + +class FakeTransport implements ProtocolTransport { + handler?: (event: ProtocolEvent) => void; + requests: Array<{ method: ProtocolMethod; params: Record }> = []; + + async connect(): Promise { + return { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + configDiagnostics: true, + }, + }; + } + + subscribe(handler: (event: ProtocolEvent) => void): () => void { + this.handler = handler; + return () => { + this.handler = undefined; + }; + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + this.requests.push({ method, params }); + if (method === 'thread/start') return thread as T; + if (method === 'thread/resume') return thread as T; + if (method === 'turn/start') return turn as T; + if (method === 'turn/interrupt') return { interrupted: true } as T; + return { accepted: true } as T; + } +} + +const thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], +}; + +const turn: TurnSnapshot = { + id: 'turn-1', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], +}; + +describe('DesktopProtocolAgent', () => { + it('buffers fast server events until turn/start returns, then projects them in order', async () => { + vi.useFakeTimers(); + const transport = new FakeTransport(); + const events: unknown[] = []; + const agent = new DesktopProtocolAgent(transport, (event) => events.push(event)); + transport.request = async (method: ProtocolMethod, params = {}) => { + transport.requests.push({ method, params }); + if (method === 'thread/start') return thread as T; + if (method === 'turn/start') { + transport.handler?.({ type: 'turn.started', threadId: thread.id, turn }); + transport.handler?.({ + type: 'item.delta', + threadId: thread.id, + turnId: turn.id, + itemId: 'assistant', + delta: 'done', + }); + transport.handler?.({ + type: 'turn.completed', + threadId: thread.id, + turn: { ...turn, status: 'completed' }, + }); + return turn as T; + } + return { accepted: true } as T; + }; + + await expect( + agent.start({ userMessage: 'hello', cwd: '/workspace', effort: 'high' }), + ).resolves.toEqual({ turnId: turn.id, threadId: thread.id }); + expect(events).toEqual([]); + await vi.runAllTimersAsync(); + + expect(events).toEqual([ + expect.objectContaining({ type: 'text_delta', text: 'done' }), + expect.objectContaining({ kind: 'turn_done', stopReason: 'end_turn' }), + ]); + vi.useRealTimers(); + }); + + it('binds approval responses to the request context and maps tool activity', async () => { + const transport = new FakeTransport(); + const events: unknown[] = []; + const agent = new DesktopProtocolAgent(transport, (event) => events.push(event)); + await agent.resume(thread.id); + await agent.start({ userMessage: 'change it' }); + + transport.handler?.({ + type: 'tool.started', + threadId: thread.id, + turnId: turn.id, + itemId: 'tool-1', + name: 'Edit', + input: { file_path: 'a.ts' }, + }); + transport.handler?.({ + type: 'approval.requested', + threadId: thread.id, + turnId: turn.id, + requestId: 'request-1', + toolName: 'Edit', + reason: 'write needs approval', + }); + await agent.approve('request-1', 'always'); + + expect(events).toEqual([ + expect.objectContaining({ type: 'tool_use', id: 'tool-1', name: 'Edit' }), + expect.objectContaining({ type: 'permission_request', requestId: 'request-1' }), + ]); + expect(transport.requests.at(-1)).toEqual({ + method: 'approval/respond', + params: { + threadId: thread.id, + turnId: turn.id, + requestId: 'request-1', + decision: 'always', + }, + }); + await expect(agent.approve('request-1', 'allow')).rejects.toThrow('not found'); + }); + + it('interrupts only known active turns', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await agent.resume(thread.id); + await agent.start({ userMessage: 'wait' }); + + await expect(agent.abort(turn.id)).resolves.toBe(true); + await expect(agent.abort('unknown')).resolves.toBe(false); + expect(transport.requests.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: thread.id, turnId: turn.id }, + }); + }); + + it('drops late events after clearing an active thread', async () => { + const transport = new FakeTransport(); + const events: unknown[] = []; + const agent = new DesktopProtocolAgent(transport, (event) => events.push(event)); + await agent.resume(thread.id); + await agent.start({ userMessage: 'wait' }); + + agent.clear(); + transport.handler?.({ + type: 'item.delta', + threadId: thread.id, + turnId: turn.id, + itemId: 'assistant', + delta: 'too late', + }); + + expect(events).toEqual([]); + expect(transport.requests.at(-1)).toEqual({ + method: 'turn/interrupt', + params: { threadId: thread.id, turnId: turn.id }, + }); + }); +}); diff --git a/apps/desktop/src/lib/protocol-agent.ts b/apps/desktop/src/lib/protocol-agent.ts new file mode 100644 index 0000000..87662ea --- /dev/null +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -0,0 +1,306 @@ +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; + +import { setActiveSessionId } from './mac-session.js'; +import { DesktopProtocolClient } from './protocol-client.js'; + +export interface ProtocolTransport { + connect(): Promise; + request(method: ProtocolMethod, params?: Record): Promise; + subscribe(handler: (event: ProtocolEvent) => void): () => void; +} + +export interface StartProtocolTurnArgs { + userMessage: string; + cwd?: string; + mode?: string; + model?: string; + effort?: string; +} + +export interface DesktopAgentEvent { + kind: 'event' | 'turn_done'; + turnId: string; + [key: string]: unknown; +} + +type PendingInteraction = + | { kind: 'approval'; threadId: string; turnId: string } + | { kind: 'user-input'; threadId: string; turnId: string }; + +export class DesktopProtocolAgent { + private threadId: string | null = null; + private readonly activeTurns = new Map(); + private readonly pendingInteractions = new Map(); + private readonly queuedTurns = new Map(); + + constructor( + private readonly transport: ProtocolTransport, + private readonly emit: (event: DesktopAgentEvent) => void, + ) { + transport.subscribe((event) => this.receive(event)); + } + + async start(args: StartProtocolTurnArgs): Promise<{ turnId: string; threadId: string }> { + await this.transport.connect(); + if (!this.threadId) { + const thread = await this.transport.request('thread/start', { + cwd: args.cwd ?? '/', + }); + this.adoptThread(thread.id); + } + const threadId = this.threadId; + if (!threadId) throw new Error('app-server did not create a thread'); + const turn = await this.transport.request('turn/start', { + threadId, + input: { + text: args.userMessage, + ...(args.mode ? { mode: args.mode } : {}), + ...(args.model ? { model: args.model } : {}), + ...(args.effort ? { effort: args.effort } : {}), + }, + }); + this.activeTurns.set(turn.id, threadId); + // The server can emit a complete fast turn before its start response reaches + // the renderer. Flush on the next task so React records the returned turn id + // before a terminal notification clears it. + setTimeout(() => this.flushTurn(turn.id), 0); + return { turnId: turn.id, threadId }; + } + + async resume(threadId: string): Promise { + await this.transport.connect(); + if (this.threadId && this.threadId !== threadId) { + await this.interruptActiveTurns(); + } + const thread = await this.transport.request('thread/resume', { threadId }); + this.adoptThread(thread.id); + return thread; + } + + clear(): void { + void this.interruptActiveTurns(); + this.threadId = null; + setActiveSessionId(null); + } + + async abort(turnId: string): Promise { + const threadId = this.activeTurns.get(turnId); + if (!threadId) return false; + const result = await this.transport.request<{ interrupted: boolean }>('turn/interrupt', { + threadId, + turnId, + }); + return result.interrupted; + } + + async approve(requestId: string, decision: 'allow' | 'deny' | 'always'): Promise { + const pending = this.requireInteraction(requestId, 'approval'); + await this.transport.request('approval/respond', { + threadId: pending.threadId, + turnId: pending.turnId, + requestId, + decision, + }); + this.pendingInteractions.delete(requestId); + } + + async answer(requestId: string, answer: string): Promise { + const pending = this.requireInteraction(requestId, 'user-input'); + await this.transport.request('user-input/respond', { + threadId: pending.threadId, + turnId: pending.turnId, + requestId, + answer, + }); + this.pendingInteractions.delete(requestId); + } + + private adoptThread(threadId: string): void { + this.threadId = threadId; + setActiveSessionId(threadId); + } + + private receive(event: ProtocolEvent): void { + const turnId = turnIdFrom(event); + if (event.type === 'turn.started' && !this.activeTurns.has(turnId!)) { + // A turn can finish before the response to turn/start reaches us. Buffer + // only notifications for the currently adopted thread; late events from + // an interrupted or previously selected thread must never reach the UI. + if (event.threadId === this.threadId) this.queuedTurns.set(turnId!, [event]); + return; + } + if (turnId && this.queuedTurns.has(turnId)) { + this.queuedTurns.get(turnId)!.push(event); + return; + } + if (turnId && !this.activeTurns.has(turnId)) return; + this.project(event); + } + + private flushTurn(turnId: string): void { + const events = this.queuedTurns.get(turnId) ?? []; + this.queuedTurns.delete(turnId); + for (const event of events) this.project(event); + } + + private project(event: ProtocolEvent): void { + switch (event.type) { + case 'item.delta': + this.emit({ kind: 'event', turnId: event.turnId, type: 'text_delta', text: event.delta }); + break; + case 'tool.started': + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'tool_use', + id: event.itemId, + name: event.name, + input: event.input, + }); + break; + case 'tool.completed': + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'tool_result', + id: event.itemId, + result: event.result, + }); + break; + case 'usage.updated': + this.emit({ kind: 'event', turnId: event.turnId, type: 'usage', ...event.usage }); + break; + case 'approval.requested': + this.pendingInteractions.set(event.requestId, { + kind: 'approval', + threadId: event.threadId, + turnId: event.turnId, + }); + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'permission_request', + requestId: event.requestId, + toolName: event.toolName, + reason: event.reason, + }); + break; + case 'user-input.requested': + this.pendingInteractions.set(event.requestId, { + kind: 'user-input', + threadId: event.threadId, + turnId: event.turnId, + }); + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'ask_user', + requestId: event.requestId, + question: event.question, + options: event.options, + multiSelect: event.multiSelect, + }); + break; + case 'turn.completed': + this.finish(event.turn.id, 'end_turn'); + break; + case 'turn.interrupted': + this.finish(event.turn.id, 'aborted'); + break; + case 'turn.failed': { + const error = [...event.turn.items].reverse().find((item) => item.type === 'error') + ?.payload.message; + if (typeof error === 'string') { + this.emit({ kind: 'event', turnId: event.turn.id, type: 'error', error }); + } + this.finish(event.turn.id, 'error'); + break; + } + } + } + + private finish(turnId: string, stopReason: 'end_turn' | 'aborted' | 'error'): void { + if (!this.activeTurns.delete(turnId)) return; + for (const [requestId, pending] of this.pendingInteractions) { + if (pending.turnId === turnId) this.pendingInteractions.delete(requestId); + } + this.emit({ kind: 'turn_done', turnId, stopReason }); + } + + private requireInteraction( + requestId: string, + kind: K, + ): Extract { + const pending = this.pendingInteractions.get(requestId); + if (!pending || pending.kind !== kind) { + throw new Error(`Pending ${kind} request not found: ${requestId}`); + } + return pending as Extract; + } + + private async interruptActiveTurns(): Promise { + const turns = [...this.activeTurns].map(([turnId, threadId]) => ({ turnId, threadId })); + // Detach first so late deltas and terminal notifications from the previous + // selection are ignored even if the interrupt response is delayed. + this.activeTurns.clear(); + this.pendingInteractions.clear(); + this.queuedTurns.clear(); + await Promise.allSettled( + turns.map(({ turnId, threadId }) => + this.transport.request('turn/interrupt', { threadId, turnId }), + ), + ); + } +} + +function turnIdFrom(event: ProtocolEvent): string | undefined { + if (event.type === 'thread.started') return undefined; + if (event.type === 'turn.started') return event.turn.id; + if ( + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ) { + return event.turn.id; + } + return event.turnId; +} + +let emitToRenderer: (event: DesktopAgentEvent) => void = () => undefined; +const defaultAgent = new DesktopProtocolAgent(new DesktopProtocolClient(), (event) => + emitToRenderer(event), +); + +export function installProtocolAgentEmitter(emit: (event: DesktopAgentEvent) => void): void { + emitToRenderer = emit; +} + +export function startProtocolTurn(args: StartProtocolTurnArgs) { + return defaultAgent.start(args); +} + +export function resumeProtocolThread(threadId: string) { + return defaultAgent.resume(threadId); +} + +export function clearProtocolThread(): void { + defaultAgent.clear(); +} + +export function abortProtocolTurn(turnId: string) { + return defaultAgent.abort(turnId); +} + +export function approveProtocolRequest(requestId: string, decision: 'allow' | 'deny' | 'always') { + return defaultAgent.approve(requestId, decision); +} + +export function answerProtocolRequest(requestId: string, answer: string) { + return defaultAgent.answer(requestId, answer); +} diff --git a/apps/desktop/src/lib/protocol-client.test.ts b/apps/desktop/src/lib/protocol-client.test.ts new file mode 100644 index 0000000..5ee0b2e --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.test.ts @@ -0,0 +1,113 @@ +import type { ProtocolRequest } from '@deepcode/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { + DesktopProtocolClient, + type AppServerOutput, + type ProtocolClientBridge, +} from './protocol-client.js'; + +class FakeBridge implements ProtocolClientBridge { + handler?: (output: AppServerOutput) => void; + started = 0; + stopped = 0; + requests: ProtocolRequest[] = []; + + async listen(handler: (output: AppServerOutput) => void) { + this.handler = handler; + return () => { + this.handler = undefined; + }; + } + + async start() { + this.started++; + } + + async send(raw: string) { + const request = JSON.parse(raw) as ProtocolRequest; + this.requests.push(request); + queueMicrotask(() => { + this.handler?.({ + stream: 'stdout', + line: JSON.stringify({ + id: request.id, + result: + request.method === 'initialize' + ? { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + configDiagnostics: true, + }, + } + : { ok: true }, + }), + }); + }); + } + + async stop() { + this.stopped++; + } +} + +describe('DesktopProtocolClient', () => { + it('starts the supervised process and negotiates protocol v1', async () => { + const bridge = new FakeBridge(); + const client = new DesktopProtocolClient(bridge); + + await expect(client.connect()).resolves.toEqual( + expect.objectContaining({ protocolVersion: 1 }), + ); + expect(bridge.started).toBe(1); + expect(bridge.requests[0]).toEqual({ id: 1, method: 'initialize', params: {} }); + await client.connect(); + expect(bridge.started).toBe(1); + await client.close(); + expect(bridge.stopped).toBe(1); + }); + + it('routes durable and transient notifications to subscribers', async () => { + const bridge = new FakeBridge(); + const client = new DesktopProtocolClient(bridge); + const subscriber = vi.fn(); + client.subscribe(subscriber); + await client.connect(); + + bridge.handler?.({ + stream: 'stdout', + line: JSON.stringify({ + method: 'event', + params: { + type: 'item.delta', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + delta: 'hello', + }, + }), + }); + + expect(subscriber).toHaveBeenCalledWith(expect.objectContaining({ type: 'item.delta' })); + await client.close(); + }); + + it('rejects pending requests when the supervised process terminates', async () => { + const bridge = new FakeBridge(); + const client = new DesktopProtocolClient(bridge, 1000); + await client.connect(); + bridge.send = async (raw) => { + bridge.requests.push(JSON.parse(raw) as ProtocolRequest); + }; + const pending = client.request('thread/read', { threadId: 'thread-1' }); + bridge.handler?.({ stream: 'terminated', line: '', code: 1 }); + + await expect(pending).rejects.toThrow('app-server terminated'); + }); +}); diff --git a/apps/desktop/src/lib/protocol-client.ts b/apps/desktop/src/lib/protocol-client.ts new file mode 100644 index 0000000..5af0eaa --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.ts @@ -0,0 +1,79 @@ +import { listen } from '@tauri-apps/api/event'; +import { ProtocolClient, type ProtocolClientConnection } from '@deepcode/protocol'; + +import { appServerSend, appServerStart, appServerStop } from './tauri-api.js'; + +export interface AppServerOutput { + stream: 'stdout' | 'stderr' | 'error' | 'terminated'; + line: string; + code?: number; + signal?: number; +} + +export interface ProtocolClientBridge { + listen(handler: (output: AppServerOutput) => void): Promise<() => void>; + start(): Promise; + send(message: string): Promise; + stop(): Promise; +} + +const tauriBridge: ProtocolClientBridge = { + async listen(handler) { + return listen('app-server-output', (event) => handler(event.payload)); + }, + start: appServerStart, + send: appServerSend, + stop: appServerStop, +}; + +class TauriProtocolConnection implements ProtocolClientConnection { + private unlisten?: () => void; + + constructor(private readonly bridge: ProtocolClientBridge) {} + + async open(onMessage: (message: string) => void, onDisconnect: (error: Error) => void) { + this.unlisten = await this.bridge.listen((output) => { + if (output.stream === 'stdout') { + onMessage(output.line); + return; + } + if (output.stream === 'terminated' || output.stream === 'error') { + this.detach(); + onDisconnect( + output.stream === 'terminated' + ? new Error( + `app-server terminated (code=${output.code ?? 'none'}, signal=${output.signal ?? 'none'})`, + ) + : new Error(output.line || 'app-server bridge failed'), + ); + } + }); + try { + await this.bridge.start(); + } catch (error) { + this.detach(); + throw error; + } + } + + send(message: string): Promise { + return this.bridge.send(message); + } + + async close(): Promise { + this.detach(); + await this.bridge.stop(); + } + + private detach(): void { + this.unlisten?.(); + this.unlisten = undefined; + } +} + +/** Tauri connection adapter over the shared provider-neutral protocol client. */ +export class DesktopProtocolClient extends ProtocolClient { + constructor(bridge: ProtocolClientBridge = tauriBridge, timeoutMs = 30_000) { + super(new TauriProtocolConnection(bridge), timeoutMs); + } +} diff --git a/apps/desktop/src/lib/repl-stream.test.ts b/apps/desktop/src/lib/repl-stream.test.ts index 3381f29..2240b85 100644 --- a/apps/desktop/src/lib/repl-stream.test.ts +++ b/apps/desktop/src/lib/repl-stream.test.ts @@ -68,6 +68,33 @@ describe('repl-stream mutators', () => { }); }); + it('falls back to only the newest running tool across resumed turns', () => { + const m: Msg[] = [ + { + role: 'assistant', + turn: { text: 'old', tools: [tool('old', 'Write')], streaming: false }, + }, + { role: 'user', text: 'next turn' }, + { + role: 'assistant', + turn: { text: 'new', tools: [tool('new', 'Edit')], streaming: true }, + }, + ]; + + const out = attachToolResult(m, 'provider-changed-id', 'updated', 'ok'); + const oldTurn = out[0]; + const newTurn = out[2]; + if (oldTurn?.role !== 'assistant' || newTurn?.role !== 'assistant') { + throw new Error('expected assistant turns'); + } + expect(oldTurn.turn.tools[0]).toMatchObject({ toolId: 'old', status: 'running' }); + expect(newTurn.turn.tools[0]).toMatchObject({ + toolId: 'new', + status: 'ok', + resultText: 'updated', + }); + }); + it('finalizeStreaming clears the flag on ALL assistant turns', () => { // Even if a prior turn was left streaming (defensive), finalize clears it. const m: Msg[] = [ diff --git a/apps/desktop/src/lib/repl-stream.ts b/apps/desktop/src/lib/repl-stream.ts index 0997550..bb08641 100644 --- a/apps/desktop/src/lib/repl-stream.ts +++ b/apps/desktop/src/lib/repl-stream.ts @@ -89,24 +89,50 @@ export function attachToolResult( content: string, status: 'ok' | 'err', ): Msg[] { - return msgs.map((m): Msg => { - if (m.role !== 'assistant') return m; - let idx = m.turn.tools.findIndex((t) => t.toolId === toolId); - if (idx === -1) { - for (let j = m.turn.tools.length - 1; j >= 0; j--) { - if (m.turn.tools[j]!.status === 'running') { - idx = j; - break; - } - } + let messageIndex = -1; + let toolIndex = -1; + + // Prefer an exact id, newest first. If a legacy provider omitted/mutated the + // id, fall back once to the globally newest running tool — never once per + // assistant message, which would rewrite unrelated resumed history. + for (let i = msgs.length - 1; i >= 0 && toolIndex === -1; i--) { + const message = msgs[i]!; + if (message.role !== 'assistant') continue; + const candidate = lastToolIndex(message.turn.tools, (tool) => tool.toolId === toolId); + if (candidate !== -1) { + messageIndex = i; + toolIndex = candidate; } - if (idx === -1) return m; - const tools = [...m.turn.tools]; - tools[idx] = { ...tools[idx]!, status, resultText: content }; - return { ...m, turn: { ...m.turn, tools } }; + } + for (let i = msgs.length - 1; i >= 0 && toolIndex === -1; i--) { + const message = msgs[i]!; + if (message.role !== 'assistant') continue; + const candidate = lastToolIndex(message.turn.tools, (tool) => tool.status === 'running'); + if (candidate !== -1) { + messageIndex = i; + toolIndex = candidate; + } + } + if (messageIndex === -1 || toolIndex === -1) return msgs; + + return msgs.map((message, index): Msg => { + if (index !== messageIndex || message.role !== 'assistant') return message; + const tools = [...message.turn.tools]; + tools[toolIndex] = { ...tools[toolIndex]!, status, resultText: content }; + return { ...message, turn: { ...message.turn, tools } }; }); } +function lastToolIndex( + tools: ToolInvocation[], + predicate: (tool: ToolInvocation) => boolean, +): number { + for (let i = tools.length - 1; i >= 0; i--) { + if (predicate(tools[i]!)) return i; + } + return -1; +} + /** Clear the streaming flag on ALL assistant turns (not just the last one). */ export function finalizeStreaming(msgs: Msg[]): Msg[] { return msgs.map( diff --git a/apps/desktop/src/lib/tauri-api.test.ts b/apps/desktop/src/lib/tauri-api.test.ts index 62dc029..6e9b63d 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -10,16 +10,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { invoke } from '@tauri-apps/api/core'; import { + appServerSend, + appServerStart, + appServerStatus, + appServerStop, appendAllowMatcher, + credentialStatus, getAppInfo, listPlugins, listSkills, loadSettingsFile, - readCredentials, saveCredentials, saveSettingsFile, - sessionAppend, - sessionCreate, } from './tauri-api.js'; vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn() })); @@ -29,26 +31,14 @@ beforeEach(() => { invokeMock.mockReset(); }); -describe('readCredentials', () => { - it('maps Rust snake_case → renderer camelCase (the §8a direction)', async () => { - invokeMock.mockResolvedValue({ - api_key: 'sk-123', - auth_token: 'tok-9', - base_url: 'https://api.deepseek.com/v1', - }); - const creds = await readCredentials(); - expect(invokeMock).toHaveBeenCalledWith('read_credentials'); - expect(creds).toEqual({ - apiKey: 'sk-123', - authToken: 'tok-9', +describe('credentialStatus', () => { + it('returns only presence and endpoint metadata to the renderer', async () => { + invokeMock.mockResolvedValue({ hasKey: true, baseUrl: 'https://api.deepseek.com/v1' }); + await expect(credentialStatus()).resolves.toEqual({ + hasKey: true, baseURL: 'https://api.deepseek.com/v1', }); - }); - - it('leaves missing fields undefined (does not invent empty strings)', async () => { - invokeMock.mockResolvedValue({ api_key: 'only-key' }); - const creds = await readCredentials(); - expect(creds).toEqual({ apiKey: 'only-key', authToken: undefined, baseURL: undefined }); + expect(invokeMock).toHaveBeenCalledWith('credential_status'); }); }); @@ -60,19 +50,25 @@ describe('saveCredentials', () => { creds: { api_key: 'sk-x', auth_token: 'tok', base_url: 'https://h/v1' }, }); }); - - it('round-trips with readCredentials (save shape decodes back to the same camelCase)', async () => { - invokeMock.mockResolvedValue(undefined); - const input = { apiKey: 'a', authToken: 'b', baseURL: 'c' }; - await saveCredentials(input); - const sent = invokeMock.mock.calls[0]![1] as { creds: Record }; - // Simulate the backend echoing those stored fields back on read. - invokeMock.mockResolvedValue(sent.creds); - expect(await readCredentials()).toEqual(input); - }); }); describe('command name + argument contracts', () => { + it('maps app-server supervision commands without exposing process details', async () => { + invokeMock.mockResolvedValue({ running: true, pid: 42 }); + await expect(appServerStart()).resolves.toEqual({ running: true, pid: 42 }); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_start'); + + await appServerSend('{"id":1,"method":"initialize","params":{}}'); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_send', { + message: '{"id":1,"method":"initialize","params":{}}', + }); + + await appServerStatus(); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_status'); + await appServerStop(); + expect(invokeMock).toHaveBeenLastCalledWith('app_server_stop'); + }); + it('getAppInfo → get_app_info (no args)', async () => { invokeMock.mockResolvedValue({ version: '1.0.0', platform: 'darwin', home_dir: '/Users/x' }); await getAppInfo(); @@ -98,20 +94,6 @@ describe('command name + argument contracts', () => { await appendAllowMatcher('Write'); expect(invokeMock).toHaveBeenCalledWith('append_allow_matcher', { matcher: 'Write' }); }); - - it('sessionCreate → session_create with { cwd } and returns the id', async () => { - invokeMock.mockResolvedValue('sess-abc'); - const id = await sessionCreate('/proj'); - expect(invokeMock).toHaveBeenCalledWith('session_create', { cwd: '/proj' }); - expect(id).toBe('sess-abc'); - }); - - it('sessionAppend → session_append with { id, message }', async () => { - invokeMock.mockResolvedValue(undefined); - const msg = { type: 'message', role: 'user', content: [] }; - await sessionAppend('sess-abc', msg); - expect(invokeMock).toHaveBeenCalledWith('session_append', { id: 'sess-abc', message: msg }); - }); }); describe('listPlugins', () => { diff --git a/apps/desktop/src/lib/tauri-api.ts b/apps/desktop/src/lib/tauri-api.ts index db7c053..dacaf28 100644 --- a/apps/desktop/src/lib/tauri-api.ts +++ b/apps/desktop/src/lib/tauri-api.ts @@ -17,6 +17,11 @@ export interface Credentials { baseURL?: string; } +export interface AppServerStatus { + running: boolean; + pid?: number; +} + export interface SessionMeta { id: string; path: string; @@ -30,18 +35,25 @@ export async function getAppInfo(): Promise { return invoke('get_app_info'); } -export async function readCredentials(): Promise { - // Backend uses snake_case Rust fields; convert. - const raw = (await invoke('read_credentials')) as { - api_key?: string; - auth_token?: string; - base_url?: string; - }; - return { - apiKey: raw.api_key, - authToken: raw.auth_token, - baseURL: raw.base_url, - }; +export async function appServerStart(): Promise { + return invoke('app_server_start'); +} + +export async function appServerSend(message: string): Promise { + await invoke('app_server_send', { message }); +} + +export async function appServerStop(): Promise { + await invoke('app_server_stop'); +} + +export async function appServerStatus(): Promise { + return invoke('app_server_status'); +} + +export async function credentialStatus(): Promise<{ hasKey: boolean; baseURL?: string }> { + const raw = (await invoke('credential_status')) as { hasKey: boolean; baseUrl?: string }; + return { hasKey: raw.hasKey, baseURL: raw.baseUrl }; } export async function saveCredentials(creds: Credentials): Promise { @@ -157,11 +169,6 @@ export async function listSkills(cwd?: string): Promise { return (await invoke('list_skills', { cwd })) as SkillInfo[]; } -/** Create a new session JSONL file. Returns the generated id. */ -export async function sessionCreate(cwd: string): Promise { - return (await invoke('session_create', { cwd })) as string; -} - /** Set (or clear, with '') a session's manual title. */ export async function sessionSetTitle(id: string, title: string): Promise { await invoke('session_set_title', { id, title }); @@ -177,11 +184,6 @@ export async function sessionArchive(id: string): Promise { await invoke('session_archive', { id }); } -/** Append one JSON message line to a session's JSONL file. */ -export async function sessionAppend(id: string, message: Record): Promise { - await invoke('session_append', { id, message }); -} - /** A stored message line as written to a session's JSONL. */ export interface StoredMessageLine { type?: string; diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index 0d4e206..db81581 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -2,25 +2,30 @@ // Keeps the existing React screens working after the Electron → Tauri pivot. // Canonical type lives in src/types/global.d.ts (DeepCodeAPI). -import type { AgentEvent, Mode } from '@deepcode/core/dist/types.js'; import type { DeepCodeAPI } from '../types/global.js'; -import { abortAgentTurn, clearHistory, resumeSession, startAgentTurn } from './mac-agent.js'; import { loadProjectPath } from './project.js'; import { - appendAllowMatcher, + abortProtocolTurn, + answerProtocolRequest, + approveProtocolRequest, + installProtocolAgentEmitter, + resumeProtocolThread, + startProtocolTurn, +} from './protocol-agent.js'; +import { + credentialStatus, getAppInfo, listPlugins, listSessions, listSkills, loadSettingsFile, openUrl, - readCredentials, saveCredentials, sessionRead, } from './tauri-api.js'; // In-memory event bus: every agent.start() call ID maps to an array of -// listeners. We fan-out the AgentEvents from mac-agent to every listener. +// listeners. We fan out stable protocol projections to every listener. type Listener = (e: unknown) => void; const listeners: Listener[] = []; @@ -34,20 +39,8 @@ function emitEvent(e: unknown): void { } } -// Approval round-trips: mac-agent calls onApproval with a promise; we emit -// a `permission_request` event carrying a unique requestId and stash the -// resolver here. The UI calls api.agent.approve({ requestId, decision }) -// which pops the resolver and resolves the original promise. -const pendingApprovals = new Map void>(); -// AskUserQuestion round-trips: same pattern — emit an `ask_user` event, stash -// the resolver, resolve it when the UI calls api.agent.answer({ requestId, answer }). -const pendingQuestions = new Map void>(); - -function nextRequestId(): string { - return `req-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; -} - export function installTauriShim(): void { + installProtocolAgentEmitter(emitEvent); const api: DeepCodeAPI = { async version() { const info = await getAppInfo(); @@ -55,8 +48,7 @@ export function installTauriShim(): void { }, creds: { async load() { - const c = await readCredentials(); - return { hasKey: !!(c.apiKey || c.authToken), baseURL: c.baseURL }; + return credentialStatus(); }, async save({ apiKey, baseURL }) { await saveCredentials({ apiKey, baseURL }); @@ -79,15 +71,13 @@ export function installTauriShim(): void { })); }, async resume({ id }) { - // Read the session's stored messages and adopt them into the agent so - // the conversation continues with full context + appends to this file. + await resumeProtocolThread(id); const lines = await sessionRead(id); const history = lines.map((l) => ({ role: l.role, content: l.content, timestamp: l.timestamp ?? '', })) as unknown as import('@deepcode/core/dist/types.js').StoredMessage[]; - resumeSession(id, history); return { history, sessionId: id }; }, }, @@ -148,72 +138,23 @@ export function installTauriShim(): void { }, agent: { async start({ userMessage, model, mode, effort, cwd }) { - // Pre-allocate turn ID so onEvent callbacks can reference it - // without waiting for the promise to resolve. - let pendingTurnId = `pending-${Date.now()}`; - const result = await startAgentTurn({ + const result = await startProtocolTurn({ userMessage, model, - mode: mode as Mode | undefined, + mode, cwd, - effort: effort as 'low' | 'medium' | 'high' | 'xhigh' | 'max' | undefined, - onEvent: (e: AgentEvent) => emitEvent({ kind: 'event', turnId: pendingTurnId, ...e }), - onDone: (reason) => - emitEvent({ kind: 'turn_done', turnId: pendingTurnId, stopReason: reason }), - onApproval: (toolName, reason) => { - // Mint a request ID, emit it as a synthetic event, and return - // a promise the UI resolves via agent.approve(). - const requestId = nextRequestId(); - return new Promise<'allow' | 'deny' | 'always'>((resolve) => { - pendingApprovals.set(requestId, resolve); - emitEvent({ - kind: 'event', - turnId: pendingTurnId, - type: 'permission_request', - requestId, - toolName, - reason, - }); - }); - }, - onAskUser: (req) => { - const requestId = nextRequestId(); - return new Promise((resolve) => { - pendingQuestions.set(requestId, resolve); - emitEvent({ - kind: 'event', - turnId: pendingTurnId, - type: 'ask_user', - requestId, - question: req.question, - options: req.options, - multiSelect: req.multiSelect, - }); - }); - }, + effort, }); - pendingTurnId = result.turnId; - return result; + return { turnId: result.turnId, sessionId: result.threadId }; }, async abort({ turnId }) { - return abortAgentTurn(turnId); + return abortProtocolTurn(turnId); }, async approve({ requestId, decision }) { - // Persistence note: when `decision === 'always'`, the caller is - // expected to also have called `appendAllowMatcher(toolName)` so - // the rule survives the next session. We don't do it here because - // the shim no longer has access to the toolName by the time the - // user decides. See ReplScreen.tsx where this is wired. - const resolver = pendingApprovals.get(requestId); - if (!resolver) return; // no-op if already resolved (e.g. stale click) - pendingApprovals.delete(requestId); - resolver(decision); + await approveProtocolRequest(requestId, decision); }, async answer({ requestId, answer }) { - const resolver = pendingQuestions.get(requestId); - if (!resolver) return; // stale / already answered - pendingQuestions.delete(requestId); - resolver(answer); + await answerProtocolRequest(requestId, answer); }, onEvent(cb: (e: unknown) => void): () => void { listeners.push(cb); diff --git a/apps/desktop/src/preview-app.tsx b/apps/desktop/src/preview-app.tsx index 89e0a7a..58d41fc 100644 --- a/apps/desktop/src/preview-app.tsx +++ b/apps/desktop/src/preview-app.tsx @@ -3,6 +3,14 @@ // a plain browser — lets us screenshot + iterate on the layout without the // Tauri backend or a rebuild. Not in the prod bundle (build input = index.html). +import type { + ProtocolEvent, + ProtocolRequest, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { emit } from '@tauri-apps/api/event'; +import { mockIPC } from '@tauri-apps/api/mocks'; import { createRoot } from 'react-dom/client'; import { App } from './App.js'; import { installTauriShim } from './lib/window-shim.js'; @@ -140,14 +148,171 @@ const MOCK_MESSAGES = [ { type: 'message', role: 'user', content: [{ type: 'text', text: '加一个 boss 关卡' }] }, ]; -// Mock the Tauri invoke bridge before the app calls it (no invoke runs at import). -(window as unknown as { __TAURI_INTERNALS__: unknown }).__TAURI_INTERNALS__ = { - invoke: async (cmd: string) => { +let nextThread = 1; +let nextTurn = 1; +let activeThreadId = MOCK_SESSIONS[0]!.id; +let activeTurn: TurnSnapshot | null = null; +const protocolRequests: ProtocolRequest[] = []; + +function threadSnapshot(id: string): ThreadSnapshot { + return { + id, + cwd: '/Users/oratis/Projects/DeepCode/test', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], + }; +} + +async function sendProtocol(message: unknown): Promise { + await emit('app-server-output', { + stream: 'stdout', + line: JSON.stringify(message), + }); +} + +async function sendEvent(event: ProtocolEvent): Promise { + await sendProtocol({ method: 'event', params: event }); +} + +async function handleProtocolRequest(request: ProtocolRequest): Promise { + protocolRequests.push(request); + const respond = (result: unknown) => sendProtocol({ id: request.id, result }); + switch (request.method) { + case 'initialize': + await respond({ + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + configDiagnostics: true, + }, + }); + break; + case 'thread/start': { + activeThreadId = `preview-thread-${nextThread++}`; + const thread = threadSnapshot(activeThreadId); + await sendEvent({ type: 'thread.started', thread }); + await respond(thread); + break; + } + case 'thread/read': + case 'thread/resume': { + activeThreadId = String(request.params.threadId); + await respond(threadSnapshot(activeThreadId)); + break; + } + case 'turn/start': { + const turnId = `preview-turn-${nextTurn++}`; + activeTurn = { + id: turnId, + threadId: activeThreadId, + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], + }; + // 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: 'item.delta', + threadId: activeThreadId, + turnId, + itemId: 'assistant', + delta: 'I’ll update the game safely. ', + }); + await sendEvent({ + type: 'tool.started', + threadId: activeThreadId, + turnId, + itemId: 'fixture-edit', + name: 'Edit', + input: { file_path: '/Users/oratis/Projects/DeepCode/test/打飞机.html' }, + }); + await sendEvent({ + type: 'approval.requested', + threadId: activeThreadId, + turnId, + requestId: 'fixture-approval', + toolName: 'Edit', + reason: 'The fixture verifies an approval-gated write.', + }); + break; + } + case 'approval/respond': { + await respond({ accepted: true }); + if (!activeTurn) break; + const { id: turnId, threadId } = activeTurn; + await sendEvent({ + type: 'tool.completed', + threadId, + turnId, + itemId: 'fixture-edit', + result: { content: 'Updated the boss encounter.' }, + }); + await sendEvent({ + type: 'item.delta', + threadId, + turnId, + itemId: 'assistant', + delta: 'The boss encounter is ready.', + }); + await sendEvent({ + type: 'usage.updated', + threadId, + turnId, + usage: { inputTokens: 2_048, outputTokens: 256, cacheReadTokens: 1_024 }, + }); + activeTurn = { ...activeTurn, status: 'completed', completedAt: '2026-08-01T00:00:02.000Z' }; + await sendEvent({ type: 'turn.completed', threadId, turn: activeTurn }); + break; + } + case 'user-input/respond': + await respond({ accepted: true }); + break; + case 'turn/interrupt': { + await respond({ interrupted: activeTurn !== null }); + if (!activeTurn) break; + activeTurn = { + ...activeTurn, + status: 'interrupted', + completedAt: '2026-08-01T00:00:02.000Z', + }; + await sendEvent({ + type: 'turn.interrupted', + threadId: activeTurn.threadId, + turn: activeTurn, + }); + break; + } + } +} + +// Use Tauri's official frontend mock, including event listener registration, +// so the preview exercises the same app-server bridge as the production UI. +mockIPC( + async (cmd: string, args?: unknown) => { + const payload = + args !== null && typeof args === 'object' && !Array.isArray(args) + ? (args as Record) + : {}; switch (cmd) { + case 'app_server_start': + case 'app_server_status': + return { running: true, pid: 4242 }; + case 'app_server_stop': + return null; + case 'app_server_send': + await handleProtocolRequest(JSON.parse(String(payload.message)) as ProtocolRequest); + return null; case 'load_settings_file': return { projectPath: '/Users/oratis/Projects/DeepCode/test' }; - case 'read_credentials': - return { api_key: 'sk-mock', base_url: 'https://api.deepseek.com/v1' }; + case 'credential_status': + return { hasKey: true, baseUrl: 'https://api.deepseek.com/v1' }; case 'get_app_info': return { version: '0.1.6', platform: 'macos', home_dir: '/Users/oratis' }; case 'get_settings_path': @@ -184,13 +349,30 @@ const MOCK_MESSAGES = [ return null; case 'voice_stop': return 'add a dark mode toggle to the settings screen'; + case 'save_settings_file': + case 'save_credentials': + case 'append_allow_matcher': + case 'session_set_title': + case 'session_archive': + case 'session_delete': + case 'plugin:updater|check': + return null; default: console.warn('[preview] unmocked invoke:', cmd); return null; } }, - transformCallback: (cb: unknown) => cb, -}; + { shouldMockEvents: true }, +); + +Object.defineProperty(window, '__DEEPCODE_FIXTURE__', { + configurable: true, + value: { + get protocolRequests() { + return [...protocolRequests]; + }, + }, +}); installTauriShim(); // Pretend a session is active so the file panel fetches the mock snapshots above. diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index bf25d70..5be107a 100644 --- a/apps/desktop/src/screens/Repl.tsx +++ b/apps/desktop/src/screens/Repl.tsx @@ -24,7 +24,7 @@ import { type KeyBinding, type VimMode, } from '@deepcode/core/dist/keybindings/vim.js'; -import { contextWindowFor } from '@deepcode/core/dist/providers/deepseek.js'; +import { contextWindowFor } from '@deepcode/core/dist/providers/model-metadata.js'; import { estimateCost } from '@deepcode/core/dist/providers/pricing.js'; import { Dropdown, type DropdownOption } from '../components/Dropdown.js'; import { Pill } from '../components/Pill.js'; @@ -55,6 +55,8 @@ interface ReplScreenProps { projectPath: string; /** Called after each turn ends so the parent can refresh the sidebar. */ onTurnComplete?: () => void; + /** Called once the backend creates/adopts the canonical thread id. */ + onSessionStarted?: (sessionId: string) => void; /** * Pre-seed the chat with a resumed session's reconstructed messages. The * parent remounts ReplScreen (via key) when this changes, so it's only read @@ -211,6 +213,7 @@ interface PendingQuestion { export function ReplScreen({ projectPath, onTurnComplete, + onSessionStarted, initialMessages, onInspector, onOpenFile, @@ -585,6 +588,7 @@ export function ReplScreen({ cwd: projectPath, }); setActiveTurnId(r.turnId); + if (r.sessionId) onSessionStarted?.(r.sessionId); } catch (err) { setBusy(false); setMessages((m) => [ diff --git a/apps/desktop/src/types/global.d.ts b/apps/desktop/src/types/global.d.ts index 5aff599..2283b13 100644 --- a/apps/desktop/src/types/global.d.ts +++ b/apps/desktop/src/types/global.d.ts @@ -74,7 +74,7 @@ export interface DeepCodeAPI { /** Absolute project folder path. When unset, tools error. */ cwd?: string; allowedTools?: string[]; - }) => Promise<{ turnId: string }>; + }) => Promise<{ turnId: string; sessionId?: string }>; abort: (args: { turnId: string }) => Promise; /** Resolve an in-flight permission_request event. `decision === 'always'` * also persists a matcher to ~/.deepcode/settings.json. */ diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index e84ce27..5319bfe 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -15,5 +15,9 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "dist-types", "src-tauri"], - "references": [{ "path": "../../packages/core" }, { "path": "../../packages/shared-ui" }] + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/protocol" }, + { "path": "../../packages/shared-ui" } + ] } diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts index 5f7ae9d..73bfe0f 100644 --- a/apps/desktop/vite.config.ts +++ b/apps/desktop/vite.config.ts @@ -38,16 +38,14 @@ export default defineConfig({ resolve: { alias: [ // Subpath imports — load directly from compiled dist/. The renderer - // can't bundle some core modules (node:fs deps), so we cherry-pick - // (only agent.js / providers/deepseek.js / types.js are referenced - // from the renderer code). + // can't bundle Node-backed core modules, so UI-only helpers are + // cherry-picked from compiled subpaths. The agent runtime is a sidecar. { find: /^@deepcode\/core\/dist\/(.+)$/, replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'dist') + '/$1', }, // Bare import — anything that resolves through the index. We avoid - // doing this in the renderer (use mac-tools/mac-agent which import - // from subpaths) but keep the alias so types still resolve. + // doing this in the renderer, but keep the alias so types still resolve. { find: '@deepcode/core', replacement: resolve(__dirname, '..', '..', 'packages', 'core', 'src', 'index.ts'), diff --git a/apps/lsp/README.md b/apps/lsp/README.md index 75f4a24..297f62e 100644 --- a/apps/lsp/README.md +++ b/apps/lsp/README.md @@ -1,30 +1,40 @@ # @deepcode/lsp — LSP bridge (v1.1) -Exposes DeepCode's agent loop as Language-Server-Protocol commands, so +Exposes DeepCode's app-server protocol as Language-Server-Protocol commands, so any LSP-capable editor (Neovim, Emacs lsp-mode, Sublime, JetBrains via LSP plugin) can drive DeepCode via `workspace/executeCommand`. ## Custom commands -| Command | Args | Returns | -| --------------------- | -------------------- | ------------------------------------- | -| `deepcode.runAgent` | `{ prompt: string }` | `{ turnId: string }` + streams events | -| `deepcode.abort` | `{ turnId: string }` | `{ aborted: boolean }` | -| `deepcode.listSkills` | none | `{ skills: SkillRow[] }` | +| Command | Args | Returns | +| --------------------------- | ----------------------------------------------- | ------------------------- | +| `deepcode.runAgent` | `{ prompt, threadId?, model?, effort?, mode? }` | `{ threadId, turnId }` | +| `deepcode.abort` | `{ turnId }` | `{ aborted }` | +| `deepcode.readThread` | `{ threadId }` | protocol thread snapshot | +| `deepcode.resumeThread` | `{ threadId }` | resumed protocol snapshot | +| `deepcode.respondApproval` | `{ turnId, requestId, decision }` | `{ accepted }` | +| `deepcode.respondUserInput` | `{ turnId, requestId, answer }` | `{ accepted }` | +| `deepcode.listSkills` | none | `{ skills: SkillRow[] }` | -Streamed events are sent as `deepcode/agentEvent` notifications: +Lifecycle, structured tool, usage, approval, and user-input events are sent unchanged as +`deepcode/protocolEvent` notifications: ```json { "jsonrpc": "2.0", - "method": "deepcode/agentEvent", - "params": { "turnId": "lsp-...", "kind": "text_delta", "text": "..." } + "method": "deepcode/protocolEvent", + "params": { + "type": "item.delta", + "threadId": "thread-...", + "turnId": "turn-...", + "itemId": "item-...", + "delta": "hello" + } } ``` -The `kind` field mirrors the AgentStreamEvent union from -`@deepcode/core/src/ipc/protocol.ts` (started / text_delta / tool_use / -tool_result / usage / turn_complete / turn_done / error). +The schema is the same provider-neutral `@deepcode/protocol` contract used by desktop and the +app-server. A `turn.completed`, `turn.interrupted`, or `turn.failed` event is the terminal signal. ## Install & run @@ -98,12 +108,13 @@ In `Preferences → Package Settings → LSP → Settings`: - Pure stdio LSP server. Framing: `Content-Length: N\r\n\r\n`. - Notifications (no `id`) silently dropped if unknown. - Requests (with `id`) errored with `-32603` if unknown method. -- Agent loop runs in-process; long turns spawn a child to keep the LSP - loop responsive (TODO in v1.1-rest). +- One app-server child owns runtime, credentials, tools, canonical sessions, and active turns. +- LSP uses the shared protocol client for initialize, correlation, disconnects, and event fan-out; + it never constructs a provider or reads credential secrets. +- Events that beat the `turn/start` response are buffered by turn id, so fast turns remain ordered. -## Skeleton vs ready-to-ship +## Current scope -This release ships the protocol skeleton (3 commands, 4 LSP boilerplate -handlers, stream events). The actual `runAgent` invocation emits a -placeholder event to confirm the channel — wiring to the real -`@deepcode/core` agent loop lands with the v1.1 release. +The bridge covers thread start/read/resume, turn start/interrupt, structured events, approvals, and +AskUserQuestion. Multi-client attachment and shared-daemon authentication remain intentionally out +of scope for protocol v1. diff --git a/apps/lsp/package.json b/apps/lsp/package.json index 5674424..1d2aaee 100644 --- a/apps/lsp/package.json +++ b/apps/lsp/package.json @@ -15,7 +15,9 @@ "clean": "rm -rf dist *.tsbuildinfo" }, "dependencies": { - "@deepcode/core": "workspace:*" + "@deepcode/app-server": "workspace:*", + "@deepcode/core": "workspace:*", + "@deepcode/protocol": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", diff --git a/apps/lsp/src/handler.test.ts b/apps/lsp/src/handler.test.ts index 6c2b404..aa4af20 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -1,141 +1,301 @@ -import { describe, expect, it } from 'vitest'; -import { handleMessage, type LspMessage } from './handler.js'; +import type { + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ProtocolRequest, + ThreadSnapshot, + TurnSnapshot, +} from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { __test, handleMessage, type LspMessage, type SendFn } from './handler.js'; + +const capabilities: InitializeResult = { + protocolVersion: 1, + capabilities: { + threadResume: true, + turnInterrupt: true, + completedItemPersistence: true, + transientDeltas: true, + structuredToolEvents: true, + interactiveRequests: true, + configDiagnostics: true, + }, +}; + +class FakeClient { + subscribers = new Set<(event: ProtocolEvent) => void>(); + requests: ProtocolRequest[] = []; + thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/tmp/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:00.000Z', + turns: [], + }; + turn: TurnSnapshot = { + id: 'turn-1', + threadId: 'thread-1', + status: 'in_progress', + startedAt: '2026-08-01T00:00:01.000Z', + items: [], + }; + completeTurns = true; + closed = 0; + + async connect() { + return capabilities; + } + + subscribe(handler: (event: ProtocolEvent) => void) { + this.subscribers.add(handler); + return () => this.subscribers.delete(handler); + } + + async request(method: ProtocolMethod, params: Record = {}): Promise { + this.requests.push({ id: this.requests.length + 1, method, params }); + switch (method) { + case 'thread/start': + this.emit({ type: 'thread.started', thread: this.thread }); + return this.thread as T; + case 'thread/read': + case 'thread/resume': + return this.thread as T; + case 'turn/start': { + // Deliberately precedes the response to exercise the LSP fast-turn queue. + this.emit({ type: 'turn.started', threadId: this.thread.id, turn: this.turn }); + queueMicrotask(() => { + this.emit({ + type: 'item.delta', + threadId: this.thread.id, + turnId: this.turn.id, + itemId: 'assistant', + delta: 'hello', + }); + if (this.completeTurns) { + this.turn = { + ...this.turn, + status: 'completed', + completedAt: '2026-08-01T00:00:02.000Z', + }; + this.emit({ type: 'turn.completed', threadId: this.thread.id, turn: this.turn }); + } + }); + return this.turn as T; + } + case 'turn/interrupt': + this.turn = { + ...this.turn, + status: 'interrupted', + completedAt: '2026-08-01T00:00:02.000Z', + }; + this.emit({ type: 'turn.interrupted', threadId: this.thread.id, turn: this.turn }); + return { interrupted: true } as T; + case 'approval/respond': + case 'user-input/respond': + return { accepted: true } as T; + default: + throw new Error(`Unexpected method: ${method}`); + } + } + + async close() { + this.closed++; + } + + emit(event: ProtocolEvent) { + for (const subscriber of this.subscribers) subscriber(event); + } +} + +afterEach(async () => { + await __test.reset(); +}); describe('handleMessage — initialize', () => { - it('returns capabilities + serverInfo + supported commands', async () => { + it('advertises lifecycle and interactive protocol commands', async () => { const out: LspMessage[] = []; await handleMessage( - { - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { rootUri: 'file:///tmp/x' }, - }, - (m) => out.push(m), + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { rootUri: 'file:///tmp/x' } }, + (message) => out.push(message), ); - expect(out).toHaveLength(1); - const r = out[0]!.result as { + const result = out[0]!.result as { capabilities: { executeCommandProvider: { commands: string[] } }; serverInfo: { name: string }; }; - expect(r.serverInfo.name).toBe('deepcode-lsp'); - expect(r.capabilities.executeCommandProvider.commands).toContain('deepcode.runAgent'); - expect(r.capabilities.executeCommandProvider.commands).toContain('deepcode.abort'); - expect(r.capabilities.executeCommandProvider.commands).toContain('deepcode.listSkills'); + expect(result.serverInfo.name).toBe('deepcode-lsp'); + expect(result.capabilities.executeCommandProvider.commands).toEqual( + expect.arrayContaining([ + 'deepcode.runAgent', + 'deepcode.abort', + 'deepcode.readThread', + 'deepcode.resumeThread', + 'deepcode.respondApproval', + 'deepcode.respondUserInput', + ]), + ); }); }); -describe('handleMessage — executeCommand', () => { - it('returns a turnId for deepcode.runAgent and streams events', async () => { +describe('handleMessage — protocol commands', () => { + it('starts a canonical thread and emits native protocol events in order', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - // Resolve as soon as the real completion signal (turn_done) is emitted, - // rather than polling on a fixed timer — the agent run streams events - // asynchronously after lazily importing @deepcode/core, which can take - // arbitrarily long on a loaded CI runner. - let signalDone!: () => void; - const done = new Promise((resolve) => { - signalDone = resolve; - }); - const send = (m: LspMessage) => { - out.push(m); - if ( - m.method === 'deepcode/agentEvent' && - (m.params as { kind: string }).kind === 'turn_done' - ) { - signalDone(); - } - }; - await handleMessage( - { - jsonrpc: '2.0', - id: 2, - method: 'workspace/executeCommand', - params: { command: 'deepcode.runAgent', arguments: [{ prompt: 'hi' }] }, - }, - send, + await execute(2, 'deepcode.runAgent', { prompt: 'hi', effort: 'high' }, (message) => + out.push(message), ); - // Synchronous: started event + reply - expect(out.some((m) => m.method === 'deepcode/agentEvent')).toBe(true); - const reply = out.find((m) => m.id === 2); - expect(reply).toBeDefined(); - expect((reply!.result as { turnId: string }).turnId).toMatch(/^lsp-/); - - // 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, which - // still emits turn_done). Wait on the real signal, bounded only by the - // test timeout below. - await done; - - const events = out.filter((m) => m.method === 'deepcode/agentEvent'); - const kinds = events.map((e) => (e.params as { kind: string }).kind); - expect(kinds).toContain('started'); - expect(kinds).toContain('turn_done'); - }, 15000); - - it('errors on missing prompt', async () => { + await Promise.resolve(); + + const reply = out.find((message) => message.id === 2); + expect(reply?.result).toEqual({ threadId: 'thread-1', turnId: 'turn-1' }); + const events = out + .filter((message) => message.method === 'deepcode/protocolEvent') + .map((message) => (message.params as ProtocolEvent).type); + expect(events).toEqual(['thread.started', 'turn.started', 'item.delta', 'turn.completed']); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'turn/start', + ]); + expect(client.requests[1]?.params.input).toEqual({ text: 'hi', effort: 'high' }); + }); + + it('interrupts the app-server turn instead of a local controller', async () => { + const client = new FakeClient(); + client.completeTurns = false; + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage( - { - jsonrpc: '2.0', - id: 3, - method: 'workspace/executeCommand', - params: { command: 'deepcode.runAgent', arguments: [{}] }, - }, - (m) => out.push(m), - ); - expect(out[0]!.error).toBeDefined(); - expect(out[0]!.error!.message).toMatch(/prompt is required/); + const send = (message: LspMessage) => out.push(message); + + await execute(3, 'deepcode.runAgent', { prompt: 'wait' }, send); + await execute(4, 'deepcode.abort', { turnId: 'turn-1' }, send); + + expect(out.find((message) => message.id === 4)?.result).toEqual({ aborted: true }); + expect(client.requests.at(-1)).toMatchObject({ + method: 'turn/interrupt', + params: { threadId: 'thread-1', turnId: 'turn-1' }, + }); + expect( + out.some( + (message) => + message.method === 'deepcode/protocolEvent' && + (message.params as ProtocolEvent).type === 'turn.interrupted', + ), + ).toBe(true); }); - it('deepcode.abort returns false for unknown turnId', async () => { + it('binds approval and user-input responses to the active thread and turn', async () => { + const client = new FakeClient(); + client.completeTurns = false; + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage( - { - jsonrpc: '2.0', - id: 4, - method: 'workspace/executeCommand', - params: { command: 'deepcode.abort', arguments: [{ turnId: 'no-such' }] }, - }, - (m) => out.push(m), + const send = (message: LspMessage) => out.push(message); + + await execute(5, 'deepcode.runAgent', { prompt: 'edit' }, send); + client.emit({ + type: 'approval.requested', + threadId: 'thread-1', + turnId: 'turn-1', + requestId: 'approval-1', + toolName: 'Edit', + reason: 'write', + }); + await execute( + 6, + 'deepcode.respondApproval', + { turnId: 'turn-1', requestId: 'approval-1', decision: 'allow' }, + send, + ); + await execute( + 7, + 'deepcode.respondUserInput', + { turnId: 'turn-1', requestId: 'question-1', answer: 'All' }, + send, ); - expect((out[0]!.result as { aborted: boolean }).aborted).toBe(false); + + expect(client.requests.slice(-2)).toEqual([ + expect.objectContaining({ + method: 'approval/respond', + params: expect.objectContaining({ threadId: 'thread-1', requestId: 'approval-1' }), + }), + expect.objectContaining({ + method: 'user-input/respond', + params: expect.objectContaining({ threadId: 'thread-1', answer: 'All' }), + }), + ]); }); - it('errors on unknown command', async () => { + it('reads and resumes protocol snapshots', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage( - { - jsonrpc: '2.0', - id: 5, - method: 'workspace/executeCommand', - params: { command: 'evil.command', arguments: [] }, - }, - (m) => out.push(m), - ); - expect(out[0]!.error).toBeDefined(); - expect(out[0]!.error!.message).toMatch(/Unknown command/); + const send = (message: LspMessage) => out.push(message); + + await execute(8, 'deepcode.resumeThread', { threadId: 'thread-1' }, send); + await execute(9, 'deepcode.readThread', { threadId: 'thread-1' }, send); + + expect(out.find((message) => message.id === 8)?.result).toMatchObject({ id: 'thread-1' }); + expect(out.find((message) => message.id === 9)?.result).toMatchObject({ id: 'thread-1' }); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/resume', + 'thread/read', + ]); }); -}); -describe('handleMessage — unknown method', () => { - it('returns -32603 internal error', async () => { + it('rejects missing prompts and unknown turns', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage({ jsonrpc: '2.0', id: 6, method: 'unknown/method' }, (m) => out.push(m)); - expect(out[0]!.error).toBeDefined(); + const send = (message: LspMessage) => out.push(message); + + await execute(10, 'deepcode.runAgent', {}, send); + await execute(11, 'deepcode.abort', { turnId: 'unknown' }, send); + + expect(out.find((message) => message.id === 10)?.error?.message).toMatch(/prompt is required/); + expect(out.find((message) => message.id === 11)?.result).toEqual({ aborted: false }); }); }); -describe('handleMessage — notifications', () => { - it('silently drops unknown notification', async () => { +describe('handleMessage — lifecycle', () => { + it('closes the app-server client on shutdown', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage({ jsonrpc: '2.0', method: 'unknown/notif' }, (m) => out.push(m)); - expect(out).toHaveLength(0); + await execute(12, 'deepcode.resumeThread', { threadId: 'thread-1' }, (message) => + out.push(message), + ); + + await handleMessage({ jsonrpc: '2.0', id: 13, method: 'shutdown' }, (message) => + out.push(message), + ); + + expect(client.closed).toBe(1); + expect(out.find((message) => message.id === 13)?.result).toBeNull(); }); - it('accepts initialized notification (no reply)', async () => { + it('silently drops unknown notifications and reports unsupported requests', async () => { const out: LspMessage[] = []; - await handleMessage({ jsonrpc: '2.0', method: 'initialized' }, (m) => out.push(m)); + await handleMessage({ jsonrpc: '2.0', method: 'unknown/notif' }, (message) => + out.push(message), + ); expect(out).toHaveLength(0); + + await handleMessage({ jsonrpc: '2.0', id: 14, method: 'unknown/method' }, (message) => + out.push(message), + ); + expect(out[0]?.error?.message).toMatch(/Method not supported/); }); }); + +async function execute(id: number, command: string, args: unknown, send: SendFn) { + await handleMessage( + { + jsonrpc: '2.0', + id, + method: 'workspace/executeCommand', + params: { command, arguments: [args] }, + }, + send, + ); +} diff --git a/apps/lsp/src/handler.ts b/apps/lsp/src/handler.ts index 94dee15..31393e1 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -1,5 +1,16 @@ -// LSP message handler — dispatches JSON-RPC methods to DeepCode actions. -// Separated from server.ts for testability. +// LSP compatibility handler backed by the shared app-server protocol client. + +import { fileURLToPath } from 'node:url'; + +import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; +import { + ProtocolClient, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ThreadSnapshot, + type TurnSnapshot, +} from '@deepcode/protocol'; export interface LspMessage { jsonrpc: '2.0'; @@ -12,17 +23,32 @@ export interface LspMessage { export type SendFn = (msg: LspMessage) => void; +interface AppServerClient { + connect(): Promise; + request(method: ProtocolMethod, params?: Record): Promise; + subscribe(handler: (event: ProtocolEvent) => void): () => void; + close(): Promise; +} + interface ServerState { initialized: boolean; - /** Workspace root URI from initialize. */ rootUri?: string; - /** In-flight turn IDs so /abort can cancel them. */ - activeTurns: Set; + threadId?: string; + client?: AppServerClient; + unsubscribe?: () => void; + clientFactory: () => AppServerClient; + activeTurns: Map; + turnSinks: Map; + queuedEvents: Map; + latestSend?: SendFn; } const state: ServerState = { initialized: false, - activeTurns: new Set(), + clientFactory: () => new ProtocolClient(new SpawnedAppServerConnection()), + activeTurns: new Map(), + turnSinks: new Map(), + queuedEvents: new Map(), }; const SERVER_INFO = { @@ -30,36 +56,44 @@ const SERVER_INFO = { version: '0.0.0', }; +const COMMANDS = [ + 'deepcode.runAgent', + 'deepcode.abort', + 'deepcode.readThread', + 'deepcode.resumeThread', + 'deepcode.respondApproval', + 'deepcode.respondUserInput', + 'deepcode.listSkills', +]; + export async function handleMessage(msg: LspMessage, send: SendFn): Promise { - // Notifications (no id) — no response expected. if (msg.id === undefined || msg.id === null) { - await handleNotification(msg, send); + await handleNotification(msg); return; } try { const result = await dispatch(msg, send); send({ jsonrpc: '2.0', id: msg.id, result }); - } catch (err) { - const e = err as Error; + } catch (error) { send({ jsonrpc: '2.0', id: msg.id, - error: { code: -32603, message: e.message }, + error: { code: -32603, message: (error as Error).message }, }); } } -async function handleNotification(msg: LspMessage, _send: SendFn): Promise { +async function handleNotification(msg: LspMessage): Promise { switch (msg.method) { case 'initialized': state.initialized = true; return; case 'exit': + await closeClient(); process.exit(state.initialized ? 0 : 1); return; default: - // Silently drop unknown notifications per LSP spec return; } } @@ -69,6 +103,7 @@ async function dispatch(msg: LspMessage, send: SendFn): Promise { case 'initialize': return handleInitialize(msg.params as { rootUri?: string }); case 'shutdown': + await closeClient(); return null; case 'workspace/executeCommand': return handleExecuteCommand(msg.params as ExecuteCommandParams, send); @@ -81,11 +116,7 @@ function handleInitialize(params: { rootUri?: string }): unknown { state.rootUri = params?.rootUri; return { capabilities: { - // We don't implement any LSP language features; we use the protocol - // as a transport for our custom commands. - executeCommandProvider: { - commands: ['deepcode.runAgent', 'deepcode.abort', 'deepcode.listSkills'], - }, + executeCommandProvider: { commands: COMMANDS }, textDocumentSync: 0, }, serverInfo: SERVER_INFO, @@ -98,11 +129,41 @@ interface ExecuteCommandParams { } async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): Promise { + state.latestSend = send; switch (params.command) { case 'deepcode.runAgent': - return handleRunAgent((params.arguments?.[0] ?? {}) as { prompt?: string }, send); + return handleRunAgent( + (params.arguments?.[0] ?? {}) as { + prompt?: string; + model?: string; + effort?: string; + mode?: string; + threadId?: string; + }, + send, + ); case 'deepcode.abort': return handleAbort((params.arguments?.[0] ?? {}) as { turnId?: string }); + case 'deepcode.readThread': + return handleReadThread((params.arguments?.[0] ?? {}) as { threadId?: string }); + case 'deepcode.resumeThread': + return handleResumeThread((params.arguments?.[0] ?? {}) as { threadId?: string }); + case 'deepcode.respondApproval': + return handleApproval( + (params.arguments?.[0] ?? {}) as { + turnId?: string; + requestId?: string; + decision?: 'allow' | 'deny' | 'always'; + }, + ); + case 'deepcode.respondUserInput': + return handleUserInput( + (params.arguments?.[0] ?? {}) as { + turnId?: string; + requestId?: string; + answer?: string; + }, + ); case 'deepcode.listSkills': return handleListSkills(); default: @@ -111,122 +172,233 @@ async function handleExecuteCommand(params: ExecuteCommandParams, send: SendFn): } async function handleRunAgent( - args: { prompt?: string; model?: string }, + args: { + prompt?: string; + model?: string; + effort?: string; + mode?: string; + threadId?: string; + }, send: SendFn, -): Promise<{ turnId: string }> { +): Promise<{ threadId: string; 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); - - // 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 }, + const client = await getClient(); + const thread = await ensureThread(client, args.threadId); + const turn = await client.request('turn/start', { + threadId: thread.id, + input: { + text: args.prompt, + ...(args.model ? { model: args.model } : {}), + ...(args.effort ? { effort: args.effort } : {}), + ...(args.mode ? { mode: args.mode } : {}), + }, }); + state.activeTurns.set(turn.id, thread.id); + state.turnSinks.set(turn.id, send); + flushEvents(turn.id); + return { threadId: thread.id, turnId: turn.id }; +} - // 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); - } - })(); +async function handleAbort(args: { turnId?: string }): Promise<{ aborted: boolean }> { + if (!args.turnId) throw new Error('turnId is required'); + const threadId = state.activeTurns.get(args.turnId); + if (!threadId) return { aborted: false }; + const client = await getClient(); + const result = await client.request<{ interrupted: boolean }>('turn/interrupt', { + threadId, + turnId: args.turnId, + }); + return { aborted: result.interrupted }; +} + +async function handleReadThread(args: { threadId?: string }): Promise { + if (!args.threadId) throw new Error('threadId is required'); + return (await getClient()).request('thread/read', { threadId: args.threadId }); +} - return { turnId }; +async function handleResumeThread(args: { threadId?: string }): Promise { + if (!args.threadId) throw new Error('threadId is required'); + const thread = await ( + await getClient() + ).request('thread/resume', { + threadId: args.threadId, + }); + state.threadId = thread.id; + return thread; +} + +async function handleApproval(args: { + turnId?: string; + requestId?: string; + decision?: 'allow' | 'deny' | 'always'; +}): Promise<{ accepted: boolean }> { + const { threadId, turnId, requestId } = interactionContext(args); + if (!args.decision) throw new Error('decision is required'); + return (await getClient()).request('approval/respond', { + threadId, + turnId, + requestId, + decision: args.decision, + }); } -function handleAbort(args: { turnId?: string }): { aborted: boolean } { +async function handleUserInput(args: { + turnId?: string; + requestId?: string; + answer?: string; +}): Promise<{ accepted: boolean }> { + const { threadId, turnId, requestId } = interactionContext(args); + if (args.answer === undefined) throw new Error('answer is required'); + return (await getClient()).request('user-input/respond', { + threadId, + turnId, + requestId, + answer: args.answer, + }); +} + +function interactionContext(args: { turnId?: string; requestId?: string }) { if (!args.turnId) throw new Error('turnId is required'); - const had = state.activeTurns.delete(args.turnId); - return { aborted: had }; + if (!args.requestId) throw new Error('requestId is required'); + const threadId = state.activeTurns.get(args.turnId); + if (!threadId) throw new Error(`Active turn not found: ${args.turnId}`); + return { threadId, turnId: args.turnId, requestId: args.requestId }; +} + +async function getClient(): Promise { + if (!state.client) { + const client = state.clientFactory(); + state.client = client; + state.unsubscribe = client.subscribe(routeEvent); + } + await state.client.connect(); + return state.client; +} + +async function ensureThread( + client: AppServerClient, + requestedThreadId?: string, +): Promise { + if (requestedThreadId && requestedThreadId !== state.threadId) { + const thread = await client.request('thread/resume', { + threadId: requestedThreadId, + }); + state.threadId = thread.id; + return thread; + } + if (state.threadId) { + return client.request('thread/read', { threadId: state.threadId }); + } + const thread = await client.request('thread/start', { cwd: workspacePath() }); + state.threadId = thread.id; + return thread; +} + +function routeEvent(event: ProtocolEvent): void { + const turnId = turnIdFrom(event); + if (!turnId) { + state.latestSend?.({ jsonrpc: '2.0', method: 'deepcode/protocolEvent', params: event }); + return; + } + const send = state.turnSinks.get(turnId); + if (!send) { + const queued = state.queuedEvents.get(turnId) ?? []; + queued.push(event); + state.queuedEvents.set(turnId, queued); + return; + } + sendProtocolEvent(send, event); +} + +function flushEvents(turnId: string): void { + const send = state.turnSinks.get(turnId); + if (!send) return; + const events = state.queuedEvents.get(turnId) ?? []; + state.queuedEvents.delete(turnId); + for (const event of events) sendProtocolEvent(send, event); +} + +function sendProtocolEvent(send: SendFn, event: ProtocolEvent): void { + send({ jsonrpc: '2.0', method: 'deepcode/protocolEvent', params: event }); + if (isTerminal(event)) { + const turnId = event.turn.id; + state.activeTurns.delete(turnId); + state.turnSinks.delete(turnId); + state.queuedEvents.delete(turnId); + } +} + +function turnIdFrom(event: ProtocolEvent): string | undefined { + if (event.type === 'thread.started') return undefined; + if ( + event.type === 'turn.started' || + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ) { + return event.turn.id; + } + return event.turnId; +} + +function isTerminal( + event: ProtocolEvent, +): event is Extract< + ProtocolEvent, + { type: 'turn.completed' | 'turn.interrupted' | 'turn.failed' } +> { + return ( + event.type === 'turn.completed' || + event.type === 'turn.interrupted' || + event.type === 'turn.failed' + ); +} + +function workspacePath(): string { + if (!state.rootUri) return process.cwd(); + try { + return fileURLToPath(state.rootUri); + } catch { + return process.cwd(); + } +} + +async function closeClient(): Promise { + state.unsubscribe?.(); + state.unsubscribe = undefined; + const client = state.client; + state.client = undefined; + state.threadId = undefined; + state.activeTurns.clear(); + state.turnSinks.clear(); + state.queuedEvents.clear(); + if (client) await client.close(); } async function handleListSkills(): Promise<{ skills: unknown[] }> { - // Lazy import so server.ts type-checks without @deepcode/core resolved. const { loadSkills } = await import('@deepcode/core'); - const skills = await loadSkills({ cwd: process.cwd() }); + const skills = await loadSkills({ cwd: workspacePath() }); return { - skills: skills.map((s) => ({ - name: s.qualifiedName, - description: s.frontmatter.description, - source: s.source, - path: s.path, + skills: skills.map((skill) => ({ + name: skill.qualifiedName, + description: skill.frontmatter.description, + source: skill.source, + path: skill.path, })), }; } -// Test exports export const __test = { state, dispatch, + setClientFactory(factory: () => AppServerClient) { + state.clientFactory = factory; + }, + async reset() { + await closeClient(); + state.initialized = false; + state.rootUri = undefined; + state.latestSend = undefined; + state.clientFactory = () => new ProtocolClient(new SpawnedAppServerConnection()); + }, }; diff --git a/apps/lsp/tsconfig.json b/apps/lsp/tsconfig.json index d717494..2ba0e80 100644 --- a/apps/lsp/tsconfig.json +++ b/apps/lsp/tsconfig.json @@ -11,5 +11,9 @@ }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"], - "references": [{ "path": "../../packages/core" }] + "references": [ + { "path": "../../packages/core" }, + { "path": "../../packages/protocol" }, + { "path": "../server" } + ] } diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..9a44c9a --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,24 @@ +# @deepcode/app-server + +Experimental line-delimited JSON runtime server for DeepCode clients. + +The server owns lifecycle state and delegates model work to `RuntimeHost`. Completed items and +terminal turn state are persisted; streaming and interactive requests are notifications only. +Approval and user-input responses are bound to their active thread and turn. The initial transport +is single-client stdio, matching the desktop packaging decision in +`docs/adr/0001-desktop-runtime-sidecar.md`. + +Lifecycle snapshots live under `threads-v1`; their message projection uses the same id in the +canonical session-v1 index. Legacy-only sessions are imported lazily on resume. + +After a workspace build, run `node apps/server/dist/cli.js` and send one JSON request per line: + +```json +{ "id": 1, "method": "initialize", "params": {} } +``` + +The transport is experimental. Clients must negotiate `protocolVersion` before using it. + +`config/diagnostics` accepts a workspace `cwd` and returns a value-free report containing loaded +layers, leaf provenance, trust-gated fields, and validation issues. Configuration values and +credentials never cross this protocol boundary. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..10e424b --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepcode/app-server", + "version": "0.0.0", + "private": true, + "description": "Experimental DeepCode runtime protocol server", + "license": "MIT", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "deepcode-app-server": "./dist/cli.js" + }, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./client": { + "types": "./dist/client.d.ts", + "import": "./dist/client.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "build:sidecar": "node scripts/build-sidecar.mjs", + "typecheck": "tsc -b", + "test": "vitest run", + "lint": "echo 'lint: configured at workspace root' && exit 0", + "clean": "rm -rf dist *.tsbuildinfo" + }, + "dependencies": { + "@deepcode/core": "workspace:*", + "@deepcode/protocol": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "esbuild": "^0.21.5", + "typescript": "^5.7.0", + "vitest": "^2.1.9" + }, + "engines": { + "node": ">=22" + } +} diff --git a/apps/server/scripts/build-sidecar.mjs b/apps/server/scripts/build-sidecar.mjs new file mode 100644 index 0000000..03114d8 --- /dev/null +++ b/apps/server/scripts/build-sidecar.mjs @@ -0,0 +1,25 @@ +import { mkdir, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const output = resolve(packageRoot, 'dist-sidecar', 'app-server.cjs'); +await mkdir(dirname(output), { recursive: true }); +await build({ + entryPoints: [resolve(packageRoot, 'src', 'sidecar-entry.ts')], + outfile: output, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + minify: true, + sourcemap: false, + legalComments: 'none', + banner: { js: '#!/usr/bin/env node' }, +}); + +const bytes = (await stat(output)).size; +process.stdout.write(`Built ${output} (${bytes} bytes)\n`); diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts new file mode 100644 index 0000000..eeeedb6 --- /dev/null +++ b/apps/server/src/cli.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +import process from 'node:process'; + +import { runAppServer } from './run.js'; + +const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; + +await runAppServer({ input: process.stdin, output: process.stdout, home }); diff --git a/apps/server/src/client.test.ts b/apps/server/src/client.test.ts new file mode 100644 index 0000000..c4e4507 --- /dev/null +++ b/apps/server/src/client.test.ts @@ -0,0 +1,52 @@ +import process from 'node:process'; + +import { ProtocolClient } from '@deepcode/protocol'; +import { describe, expect, it } from 'vitest'; + +import { SpawnedAppServerConnection } from './client.js'; + +const fixture = String.raw` +const readline = require('node:readline'); +const lines = readline.createInterface({ input: process.stdin }); +lines.on('line', (line) => { + const request = JSON.parse(line); + const result = request.method === 'initialize' + ? { protocolVersion: 1, capabilities: { + threadResume: true, turnInterrupt: true, completedItemPersistence: true, + transientDeltas: true, structuredToolEvents: true, interactiveRequests: true, + configDiagnostics: true + } } + : { echoed: request.method }; + process.stdout.write(JSON.stringify({ id: request.id, result }) + '\n'); +}); +`; + +describe('SpawnedAppServerConnection', () => { + it('carries correlated protocol requests over a real child stdio stream', async () => { + const client = new ProtocolClient( + new SpawnedAppServerConnection({ command: process.execPath, args: ['-e', fixture] }), + ); + + await expect(client.connect()).resolves.toEqual( + expect.objectContaining({ protocolVersion: 1 }), + ); + await expect(client.request('thread/read', { threadId: 'thread-1' })).resolves.toEqual({ + echoed: 'thread/read', + }); + await client.close(); + }); + + it('surfaces child termination with bounded stderr context', async () => { + const connection = new SpawnedAppServerConnection({ + command: process.execPath, + args: ['-e', "process.stderr.write('fixture failed'); process.exit(7)"], + }); + const disconnected = new Promise((resolve) => { + void connection.open(() => undefined, resolve); + }); + + await expect(disconnected).resolves.toEqual( + expect.objectContaining({ message: expect.stringMatching(/code=7.*fixture failed/) }), + ); + }); +}); diff --git a/apps/server/src/client.ts b/apps/server/src/client.ts new file mode 100644 index 0000000..748ef67 --- /dev/null +++ b/apps/server/src/client.ts @@ -0,0 +1,114 @@ +import { once } from 'node:events'; +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; +import { createInterface, type Interface as ReadlineInterface } from 'node:readline'; + +import type { ProtocolClientConnection } from '@deepcode/protocol'; + +export interface SpawnedAppServerOptions { + command?: string; + args?: string[]; + cwd?: string; + env?: NodeJS.ProcessEnv; + home?: string; + closeGraceMs?: number; +} + +/** Node stdio adapter for a single-owner app-server child process. */ +export class SpawnedAppServerConnection implements ProtocolClientConnection { + private child?: ChildProcessWithoutNullStreams; + private lines?: ReadlineInterface; + private closing = false; + private stderr = ''; + + constructor(private readonly options: SpawnedAppServerOptions = {}) {} + + async open(onMessage: (message: string) => void, onDisconnect: (error: Error) => void) { + if (this.child) throw new Error('app-server connection is already open'); + this.closing = false; + this.stderr = ''; + const args = this.options.args ?? [fileURLToPath(new URL('./cli.js', import.meta.url))]; + const child = spawn(this.options.command ?? process.execPath, args, { + cwd: this.options.cwd, + env: { + ...process.env, + ...this.options.env, + ...(this.options.home ? { DEEPCODE_HOME: this.options.home } : {}), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }); + this.child = child; + this.lines = createInterface({ input: child.stdout, crlfDelay: Infinity }); + this.lines.on('line', onMessage); + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { + this.stderr = `${this.stderr}${chunk}`.slice(-8_192); + }); + + try { + await new Promise((resolve, reject) => { + const handleSpawn = () => { + child.off('error', handleError); + resolve(); + }; + const handleError = (error: Error) => { + child.off('spawn', handleSpawn); + reject(error); + }; + child.once('spawn', handleSpawn); + child.once('error', handleError); + }); + } catch (error) { + this.detach(); + throw error; + } + + child.once('error', (error) => { + if (!this.closing) onDisconnect(error); + this.detach(); + }); + child.once('exit', (code, signal) => { + const detail = this.stderr.trim(); + if (!this.closing) { + onDisconnect( + new Error( + `app-server terminated (code=${code ?? 'none'}, signal=${signal ?? 'none'})${detail ? `: ${detail}` : ''}`, + ), + ); + } + this.detach(); + }); + } + + async send(message: string): Promise { + const child = this.child; + if (!child || child.stdin.destroyed) throw new Error('app-server connection is not open'); + if (!child.stdin.write(`${message}\n`)) await once(child.stdin, 'drain'); + } + + async close(): Promise { + const child = this.child; + if (!child) return; + this.closing = true; + child.stdin.end(); + if (child.exitCode === null && child.signalCode === null) { + const grace = this.options.closeGraceMs ?? 5_000; + const closed = once(child, 'close').then(() => true); + const timedOut = new Promise((resolve) => { + setTimeout(() => resolve(false), grace).unref(); + }); + if (!(await Promise.race([closed, timedOut]))) { + child.kill('SIGTERM'); + await once(child, 'close').catch(() => undefined); + } + } + this.detach(); + } + + private detach(): void { + this.lines?.close(); + this.lines = undefined; + this.child = undefined; + } +} diff --git a/apps/server/src/default-runtime.ts b/apps/server/src/default-runtime.ts new file mode 100644 index 0000000..23827aa --- /dev/null +++ b/apps/server/src/default-runtime.ts @@ -0,0 +1,51 @@ +import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; +import { DirectoryTrustStore, gateUntrustedSettings, loadSettings } from '@deepcode/core/config'; +import { DeepSeekProvider } from '@deepcode/core/dist/providers/deepseek.js'; +import { RuntimeHost, SAFE_READONLY_TOOLS } from '@deepcode/core/runtime'; +import { SessionManager } from '@deepcode/core/sessions'; +import { BUILTIN_TOOLS, ToolRegistry } from '@deepcode/core/tools'; + +import { RuntimeHostExecutor } from './runtime-executor.js'; + +export function createDefaultTurnExecutor( + home?: string, + options: { forceFileCredentials?: boolean } = {}, +): RuntimeHostExecutor { + const trustStore = new DirectoryTrustStore({ directory: home }); + const sessionManager = new SessionManager({ + root: home ? `${home}/sessions` : undefined, + }); + return new RuntimeHostExecutor({ + createHost: async (cwd, mode) => { + const loaded = await loadSettings({ cwd, directory: home }); + const trustStatus = await trustStore.statusFor(cwd); + const { settings } = gateUntrustedSettings(loaded, trustStatus); + const credentials = await resolveCredentials({ + store: new CredentialsStore({ + directory: home, + forceFile: options.forceFileCredentials, + }), + apiKeyHelper: settings.apiKeyHelper, + }); + if (!credentials.apiKey && !credentials.authToken) { + throw new Error( + 'No DeepSeek credentials. Run `deepcode` once to onboard, or set DEEPSEEK_API_KEY.', + ); + } + return new RuntimeHost({ + provider: new DeepSeekProvider({ + apiKey: credentials.apiKey ?? '', + authToken: credentials.authToken, + baseURL: credentials.baseURL ?? settings.baseURL, + }), + tools: new ToolRegistry(BUILTIN_TOOLS), + cwd, + mode, + permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + }); + }, + sessionManager, + }); +} diff --git a/apps/server/src/editor-entry.ts b/apps/server/src/editor-entry.ts new file mode 100644 index 0000000..90f0536 --- /dev/null +++ b/apps/server/src/editor-entry.ts @@ -0,0 +1,10 @@ +import process from 'node:process'; + +import { runAppServer } from './run.js'; + +const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; + +runAppServer({ input: process.stdin, output: process.stdout, home }).catch((error) => { + process.stderr.write(`DeepCode app-server fatal: ${(error as Error).message ?? String(error)}\n`); + process.exitCode = 1; +}); diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..1ea2807 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,7 @@ +export * from './server.js'; +export * from './store.js'; +export * from './runtime-executor.js'; +export * from './default-runtime.js'; +export * from './stdio.js'; +export * from './run.js'; +export * from './client.js'; diff --git a/apps/server/src/run.test.ts b/apps/server/src/run.test.ts new file mode 100644 index 0000000..e51c1a5 --- /dev/null +++ b/apps/server/src/run.test.ts @@ -0,0 +1,64 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { writeSettings } from '@deepcode/core/config'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { runAppServer } from './run.js'; + +let root: string | undefined; + +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +describe('runAppServer', () => { + it('wires trust-aware configuration diagnostics through stdio', async () => { + root = await mkdtemp(join(tmpdir(), 'dc-app-server-')); + const cwd = join(root, 'workspace'); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + permissions: { allow: ['Bash'] }, + }); + const input = new PassThrough(); + const output = new PassThrough(); + let raw = ''; + output.setEncoding('utf8'); + output.on('data', (chunk: string) => { + raw += chunk; + }); + + input.end( + `${JSON.stringify({ id: 1, method: 'initialize', params: {} })}\n` + + `${JSON.stringify({ id: 2, method: 'config/diagnostics', params: { cwd } })}\n`, + ); + await runAppServer({ + input, + output, + home: root, + executor: { execute: async () => ({}) }, + }); + + const responses = raw + .trim() + .split('\n') + .map((line) => JSON.parse(line) as { id: number; result: Record }); + expect(responses[0]?.result).toEqual( + expect.objectContaining({ + capabilities: expect.objectContaining({ configDiagnostics: true }), + }), + ); + expect(responses[1]).toEqual( + expect.objectContaining({ + id: 2, + result: expect.objectContaining({ + cwd, + trustStatus: 'untrusted', + gated: ['permissions'], + }), + }), + ); + }); +}); diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts new file mode 100644 index 0000000..dbaa697 --- /dev/null +++ b/apps/server/src/run.ts @@ -0,0 +1,45 @@ +import { join } from 'node:path'; +import type { Readable, Writable } from 'node:stream'; + +import type { ProtocolNotification } from '@deepcode/protocol'; +import { diagnoseSettings, DirectoryTrustStore } from '@deepcode/core/config'; + +import { createDefaultTurnExecutor } from './default-runtime.js'; +import { AppServer, type TurnExecutor } from './server.js'; +import { CanonicalThreadStore } from './store.js'; +import { ProtocolLineWriter, serveStdio } from './stdio.js'; + +export interface RunAppServerOptions { + input: Readable; + output: Writable; + home: string; + executor?: TurnExecutor; + forceFileCredentials?: boolean; +} + +export async function runAppServer(options: RunAppServerOptions): Promise { + const writer = new ProtocolLineWriter(options.output); + const trustStore = new DirectoryTrustStore({ directory: options.home }); + const server = new AppServer({ + executor: + options.executor ?? + createDefaultTurnExecutor(options.home, { + forceFileCredentials: options.forceFileCredentials, + }), + store: new CanonicalThreadStore( + join(options.home, 'threads-v1'), + join(options.home, 'sessions'), + ), + configDiagnostics: async (cwd) => + diagnoseSettings({ + cwd, + directory: options.home, + trustStatus: await trustStore.statusFor(cwd), + }), + onEvent: (event) => { + const notification: ProtocolNotification = { method: 'event', params: event }; + void writer.enqueue(notification); + }, + }); + await serveStdio(server, options.input, writer); +} diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts new file mode 100644 index 0000000..3e7c8a8 --- /dev/null +++ b/apps/server/src/runtime-executor.test.ts @@ -0,0 +1,290 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + RuntimeHost, + SessionManager, + ToolRegistry, + type Provider, + type ProviderResult, + type ProviderRunOpts, +} from '@deepcode/core'; +import type { ThreadSnapshot, TurnSnapshot } from '@deepcode/protocol'; +import { describe, expect, it } from 'vitest'; + +import { RuntimeHostExecutor, historyFromThread } from './runtime-executor.js'; + +function protocolCallbacks() { + return { + publishToolStarted: () => undefined, + publishToolCompleted: () => undefined, + publishUsage: () => undefined, + requestApproval: async () => 'deny' as const, + requestUserInput: async () => '', + }; +} + +const priorAssistant = { + role: 'assistant' as const, + content: [{ type: 'text' as const, text: 'prior answer' }], +}; + +const thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:01.000Z', + turns: [ + { + id: 'turn-prior', + threadId: 'thread-1', + status: 'completed', + startedAt: '2026-08-01T00:00:00.000Z', + completedAt: '2026-08-01T00:00:01.000Z', + items: [ + { + id: 'item-user', + type: 'user_message', + payload: { text: 'prior question' }, + completedAt: '2026-08-01T00:00:00.000Z', + }, + { + id: 'item-assistant', + type: 'assistant_message', + payload: { message: priorAssistant }, + completedAt: '2026-08-01T00:00:01.000Z', + }, + ], + }, + ], +}; + +class StreamingProvider implements Provider { + readonly name = 'streaming-test'; + seenMessages: ProviderRunOpts['messages'] = []; + + async runTurn(options: ProviderRunOpts): Promise { + this.seenMessages = options.messages; + options.handlers?.onTextDelta?.('new '); + options.handlers?.onTextDelta?.('answer'); + return { + content: [{ type: 'text', text: 'new answer' }], + stopReason: 'end_turn', + usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } +} + +class ToolProvider implements Provider { + readonly name = 'tool-test'; + calls = 0; + + async runTurn(options: ProviderRunOpts): Promise { + this.calls++; + if (this.calls === 1) { + return { + content: [{ type: 'tool_use', id: 'tool-1', name: 'WriteTest', input: { value: 'ok' } }], + stopReason: 'tool_use', + usage: { inputTokens: 3, outputTokens: 4, reasoningTokens: 1, cacheReadTokens: 2 }, + }; + } + options.handlers?.onTextDelta?.('done'); + return { + content: [{ type: 'text', text: 'done' }], + stopReason: 'end_turn', + usage: { inputTokens: 5, outputTokens: 6, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } +} + +describe('RuntimeHostExecutor', () => { + it('reconstructs history and returns only messages created by the new turn', async () => { + const provider = new StreamingProvider(); + const host = new RuntimeHost({ + provider, + tools: new ToolRegistry(), + cwd: '/workspace', + }); + const executor = new RuntimeHostExecutor({ createHost: () => host }); + const turn: TurnSnapshot = { + id: 'turn-current', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }; + const deltas: string[] = []; + + const result = await executor.execute({ + thread, + turn, + input: { text: 'current question' }, + signal: new AbortController().signal, + publishDelta: (_itemId, delta) => deltas.push(delta), + ...protocolCallbacks(), + }); + + expect(provider.seenMessages).toEqual([ + { role: 'user', content: [{ type: 'text', text: 'prior question' }] }, + priorAssistant, + expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'current question' }], + }), + ]); + expect(deltas).toEqual(['new ', 'answer']); + expect(result).toEqual({ + status: 'completed', + items: [ + { + type: 'assistant_message', + payload: { + message: expect.objectContaining({ + role: 'assistant', + content: [{ type: 'text', text: 'new answer' }], + }), + }, + }, + ], + }); + }); + + it('ignores non-message protocol items when rebuilding provider history', () => { + const withError: ThreadSnapshot = { + ...thread, + turns: [ + { + ...thread.turns[0]!, + items: [ + ...thread.turns[0]!.items, + { + id: 'item-error', + type: 'error', + payload: { message: 'transport failed' }, + completedAt: '2026-08-01T00:00:01.000Z', + }, + ], + }, + ], + }; + + expect(historyFromThread(withError)).toHaveLength(2); + }); + + it('projects tool, usage, and approval activity onto protocol callbacks', async () => { + const provider = new ToolProvider(); + const tools = new ToolRegistry(); + tools.register({ + name: 'WriteTest', + definition: { name: 'WriteTest', description: 'test', inputSchema: { type: 'object' } }, + execute: async () => ({ content: 'wrote test value' }), + }); + const host = new RuntimeHost({ provider, tools, cwd: '/workspace', mode: 'default' }); + const executor = new RuntimeHostExecutor({ createHost: () => host }); + const turn: TurnSnapshot = { + id: 'turn-tool', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }; + const started: string[] = []; + const completed: string[] = []; + const usage: number[] = []; + const approvals: string[] = []; + + const result = await executor.execute({ + thread, + turn, + input: { text: 'write it', effort: 'low' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + publishToolStarted: (itemId) => started.push(itemId), + publishToolCompleted: (itemId) => completed.push(itemId), + publishUsage: (value) => usage.push(value.inputTokens), + requestApproval: async (toolName) => { + approvals.push(toolName); + return 'allow'; + }, + requestUserInput: async () => '', + }); + + expect(started).toEqual(['tool-1']); + expect(completed).toEqual(['tool-1']); + expect(usage).toEqual([3, 5]); + expect(approvals).toEqual(['WriteTest']); + expect(result.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'approval' }), + expect.objectContaining({ type: 'assistant_message' }), + expect.objectContaining({ type: 'tool_result' }), + ]), + ); + }); + + it('keeps session snapshots without becoming a second message writer', async () => { + const root = await mkdtemp(join(tmpdir(), 'deepcode-executor-session-')); + try { + const workspace = join(root, 'workspace'); + const filePath = join(workspace, 'file.txt'); + await mkdir(workspace); + await writeFile(filePath, 'before'); + const provider = new ToolProvider(); + const tools = new ToolRegistry([]); + // The core snapshot pipeline recognizes canonical Write/Edit names. + provider.runTurn = async (options) => { + provider.calls++; + if (provider.calls === 1) { + return { + content: [ + { type: 'tool_use', id: 'tool-1', name: 'Write', input: { file_path: filePath } }, + ], + stopReason: 'tool_use', + usage: { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + } + options.handlers?.onTextDelta?.('done'); + return { + content: [{ type: 'text', text: 'done' }], + stopReason: 'end_turn', + usage: { inputTokens: 1, outputTokens: 1, reasoningTokens: 0, cacheReadTokens: 0 }, + }; + }; + tools.register({ + name: 'Write', + definition: { name: 'Write', description: 'write', inputSchema: { type: 'object' } }, + execute: async () => { + await writeFile(filePath, 'after'); + return { content: 'written' }; + }, + }); + const sessions = new SessionManager({ root: join(root, 'sessions') }); + const host = new RuntimeHost({ provider, tools, cwd: workspace, mode: 'default' }); + const executor = new RuntimeHostExecutor({ + createHost: () => host, + sessionManager: sessions, + }); + await executor.execute({ + thread: { ...thread, id: 'thread-snapshots', cwd: workspace, turns: [] }, + turn: { + id: 'turn-snapshots', + threadId: 'thread-snapshots', + status: 'in_progress', + startedAt: '2026-08-01T00:00:00.000Z', + items: [], + }, + input: { text: 'write' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + requestApproval: async () => 'allow', + }); + + await expect(sessions.load('thread-snapshots')).resolves.toBeNull(); + await expect(sessions.snapshots('thread-snapshots')).resolves.toHaveLength(2); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/server/src/runtime-executor.ts b/apps/server/src/runtime-executor.ts new file mode 100644 index 0000000..c97e1bd --- /dev/null +++ b/apps/server/src/runtime-executor.ts @@ -0,0 +1,177 @@ +import { + type AgentEvent, + type Effort, + type Mode, + type RuntimeHost, + type SessionManager, + type StoredMessage, +} from '@deepcode/core'; +import { EFFORT_PARAMS } from '@deepcode/core/dist/providers/deepseek.js'; +import type { CompletedItem, ThreadSnapshot } from '@deepcode/protocol'; + +import type { TurnExecutionArgs, TurnExecutionItem, TurnExecutor } from './server.js'; + +export interface RuntimeHostExecutorOptions { + createHost: (cwd: string, mode: Mode) => Promise | RuntimeHost; + systemPrompt?: string; + model?: string; + sessionManager?: SessionManager; +} + +const DEFAULT_SYSTEM_PROMPT = + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Be concise and accurate.'; + +export class RuntimeHostExecutor implements TurnExecutor { + constructor(private readonly options: RuntimeHostExecutorOptions) {} + + async execute(args: TurnExecutionArgs) { + const mode = parseMode(args.input.mode); + const host = await this.options.createHost(args.thread.cwd, mode); + const history = historyFromThread(args.thread); + const baselineLength = history.length; + const text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); + const streamingItemId = `${args.turn.id}-assistant`; + const events: AgentEvent[] = []; + const interactionItems: TurnExecutionItem[] = []; + const effort = parseEffort(args.input.effort); + const effortParams = EFFORT_PARAMS[effort]; + const result = await host.run({ + cwd: args.thread.cwd, + systemPrompt: this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, + userMessage: text, + history, + model: + typeof args.input.model === 'string' + ? args.input.model + : (this.options.model ?? 'deepseek-chat'), + maxTokens: effortParams.maxTokens, + temperature: effortParams.temperature, + signal: args.signal, + session: this.options.sessionManager + ? { manager: this.options.sessionManager, id: args.thread.id } + : undefined, + persistSessionMessages: false, + systemReminders: false, + approval: async (toolName, _input, verdict) => { + const decision = await args.requestApproval( + toolName, + verdict.reason ?? `Approve ${toolName}?`, + ); + interactionItems.push({ + type: 'approval', + payload: { toolName, decision, reason: verdict.reason }, + }); + return decision === 'always' ? 'always' : decision === 'allow'; + }, + askUser: async (request) => { + const answer = await args.requestUserInput(request); + interactionItems.push({ type: 'ask_user', payload: { ...request, answer } }); + return answer; + }, + onEvent: (event) => { + events.push(event); + switch (event.type) { + case 'text_delta': + args.publishDelta(streamingItemId, event.text); + break; + case 'tool_use': + args.publishToolStarted(event.id, event.name, event.input); + break; + case 'tool_result': + args.publishToolCompleted(event.id, event.result); + break; + case 'usage': + args.publishUsage({ + inputTokens: event.inputTokens, + outputTokens: event.outputTokens, + reasoningTokens: event.reasoningTokens, + cacheReadTokens: event.cacheReadTokens, + }); + break; + } + }, + }); + + const newMessages = result.history.slice(baselineLength); + const items = [...interactionItems, ...completedItemsFromMessages(newMessages, text)]; + if (result.stopReason === 'error') { + const error = [...events].reverse().find((event) => event.type === 'error'); + if (error?.type === 'error') items.push({ type: 'error', payload: { message: error.error } }); + } + return { + items, + status: result.stopReason === 'error' ? ('failed' as const) : ('completed' as const), + }; + } +} + +const MODES = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', +]); +const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + +function parseMode(value: unknown): Mode { + return typeof value === 'string' && MODES.has(value as Mode) ? (value as Mode) : 'default'; +} + +function parseEffort(value: unknown): Effort { + return typeof value === 'string' && EFFORTS.has(value as Effort) ? (value as Effort) : 'high'; +} + +export function historyFromThread(thread: ThreadSnapshot): StoredMessage[] { + const history: StoredMessage[] = []; + for (const turn of thread.turns) { + for (const item of turn.items) { + const message = messageFromItem(item); + if (message) history.push(message); + } + } + return history; +} + +function messageFromItem(item: CompletedItem): StoredMessage | null { + if (item.type === 'user_message' && typeof item.payload.text === 'string') { + return { role: 'user', content: [{ type: 'text', text: item.payload.text }] }; + } + const message = item.payload.message; + if (!isStoredMessage(message)) return null; + return message; +} + +function completedItemsFromMessages( + messages: StoredMessage[], + inputText: string, +): TurnExecutionItem[] { + const items: TurnExecutionItem[] = []; + for (const [index, message] of messages.entries()) { + if (index === 0 && isMatchingInputMessage(message, inputText)) continue; + items.push({ + type: message.role === 'assistant' ? 'assistant_message' : 'tool_result', + payload: { message }, + }); + } + return items; +} + +function isMatchingInputMessage(message: StoredMessage, text: string): boolean { + return ( + message.role === 'user' && + message.content.length === 1 && + message.content[0]?.type === 'text' && + message.content[0].text === text + ); +} + +function isStoredMessage(value: unknown): value is StoredMessage { + if (typeof value !== 'object' || value === null) return false; + const candidate = value as Partial; + return ( + (candidate.role === 'user' || candidate.role === 'assistant') && + Array.isArray(candidate.content) + ); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts new file mode 100644 index 0000000..efb2fd1 --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,326 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ProtocolEvent, ProtocolRequest } from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { AppServer, type TurnExecutor } from './server.js'; +import { FileThreadStore } from './store.js'; + +let temporaryRoots: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryRoots.map((root) => rm(root, { recursive: true, force: true }))); + temporaryRoots = []; +}); + +function request( + id: number, + method: ProtocolRequest['method'], + params: Record = {}, +): ProtocolRequest { + return { id, method, params }; +} + +function deterministicOptions() { + let sequence = 0; + let tick = 0; + return { + now: () => `2026-08-01T00:00:0${tick++}.000Z`, + newId: (prefix: 'thread' | 'turn' | 'item') => `${prefix}-${++sequence}`, + }; +} + +describe('AppServer', () => { + it('advertises and returns value-free configuration diagnostics when provided', async () => { + const server = new AppServer({ + executor: { execute: async () => ({}) }, + configDiagnostics: async (cwd) => ({ + cwd, + trustStatus: 'untrusted', + layers: [], + provenance: { '/model': { layer: 'user', path: '/home/.deepcode/settings.json' } }, + gated: [], + issues: [], + }), + }); + + await expect(server.handle(request(1, 'initialize'))).resolves.toEqual({ + id: 1, + result: expect.objectContaining({ + capabilities: expect.objectContaining({ configDiagnostics: true }), + }), + }); + await expect( + server.handle(request(2, 'config/diagnostics', { cwd: '/workspace' })), + ).resolves.toEqual({ + id: 2, + result: expect.objectContaining({ + cwd: '/workspace', + provenance: expect.objectContaining({ + '/model': expect.objectContaining({ layer: 'user' }), + }), + }), + }); + }); + + it('does not advertise unavailable configuration diagnostics', async () => { + const server = new AppServer({ executor: { execute: async () => ({}) } }); + const initialized = await server.handle(request(1, 'initialize')); + expect(initialized.result).toEqual( + expect.objectContaining({ + capabilities: expect.objectContaining({ configDiagnostics: false }), + }), + ); + await expect( + server.handle(request(2, 'config/diagnostics', { cwd: '/workspace' })), + ).resolves.toEqual({ + id: 2, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + }); + + it('routes initialization and thread lifecycle requests', async () => { + const server = new AppServer({ + executor: { execute: async () => ({}) }, + ...deterministicOptions(), + }); + + await expect(server.handle(request(1, 'initialize'))).resolves.toEqual({ + id: 1, + result: expect.objectContaining({ protocolVersion: 1 }), + }); + const started = await server.handle(request(2, 'thread/start', { cwd: '/workspace' })); + expect(started).toEqual({ + id: 2, + result: expect.objectContaining({ id: 'thread-1', cwd: '/workspace' }), + }); + await expect( + server.handle(request(3, 'thread/read', { threadId: 'thread-1' })), + ).resolves.toEqual(started.id === 2 ? { id: 3, result: started.result } : undefined); + }); + + it('persists completed items and terminal state while publishing deltas transiently', async () => { + const events: ProtocolEvent[] = []; + const executor: TurnExecutor = { + execute: async ({ publishDelta, publishToolStarted, publishToolCompleted, publishUsage }) => { + publishDelta('assistant-stream', 'hel'); + publishToolStarted('tool-1', 'Read', { file_path: 'README.md' }); + publishToolCompleted('tool-1', { content: 'contents' }); + publishUsage({ inputTokens: 1, outputTokens: 2 }); + return { + items: [{ type: 'assistant_message', payload: { text: 'hello' } }], + }; + }, + }; + const server = new AppServer({ + executor, + onEvent: (event) => events.push(event), + ...deterministicOptions(), + }); + await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const started = await server.handle( + request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'hello' } }), + ); + expect(started).toEqual({ + id: 2, + result: expect.objectContaining({ id: 'turn-3', status: 'in_progress' }), + }); + await server.waitForIdle(); + + const read = await server.handle(request(3, 'thread/read', { threadId: 'thread-1' })); + expect(read).toEqual({ + id: 3, + result: expect.objectContaining({ + turns: [ + expect.objectContaining({ + status: 'completed', + items: [ + expect.objectContaining({ type: 'user_message' }), + expect.objectContaining({ type: 'assistant_message', payload: { text: 'hello' } }), + ], + }), + ], + }), + }); + expect(events.map((event) => event.type)).toContain('item.delta'); + expect(events.map((event) => event.type)).toEqual( + expect.arrayContaining(['tool.started', 'tool.completed', 'usage.updated']), + ); + expect((read.result as { turns: Array<{ items: unknown[] }> }).turns[0]?.items).toHaveLength(2); + }); + + it('interrupts the actual executor and emits one terminal event', async () => { + const events: ProtocolEvent[] = []; + let observedAbort!: () => void; + const aborted = new Promise((resolve) => { + observedAbort = resolve; + }); + const executor: TurnExecutor = { + execute: async ({ signal }) => { + await new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + observedAbort(); + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }); + }, + }; + const server = new AppServer({ + executor, + onEvent: (event) => events.push(event), + ...deterministicOptions(), + }); + await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + await server.handle( + request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'wait' } }), + ); + + await expect( + server.handle(request(3, 'turn/interrupt', { threadId: 'thread-1', turnId: 'turn-3' })), + ).resolves.toEqual({ id: 3, result: { interrupted: true } }); + await aborted; + await server.waitForIdle(); + expect(events.filter((event) => event.type === 'turn.interrupted')).toHaveLength(1); + expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(0); + }); + + it('round-trips approval and user-input requests through the active turn', async () => { + const events: ProtocolEvent[] = []; + const responses: string[] = []; + const executor: TurnExecutor = { + execute: async ({ requestApproval, requestUserInput }) => { + responses.push(await requestApproval('Bash', 'Run tests?')); + responses.push( + await requestUserInput({ + question: 'Choose scope', + options: [{ label: 'All', description: 'Run every test' }], + }), + ); + return {}; + }, + }; + const server = new AppServer({ executor, onEvent: (event) => events.push(event) }); + const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (thread.result as { id: string }).id; + const started = await server.handle( + request(2, 'turn/start', { threadId, input: { text: 'test' } }), + ); + const turnId = (started.result as { id: string }).id; + const approval = events.find((event) => event.type === 'approval.requested'); + expect(approval).toEqual( + expect.objectContaining({ type: 'approval.requested', threadId, turnId, toolName: 'Bash' }), + ); + + await expect( + server.handle( + request(3, 'approval/respond', { + threadId, + turnId, + requestId: approval?.type === 'approval.requested' ? approval.requestId : '', + decision: 'allow', + }), + ), + ).resolves.toEqual({ id: 3, result: { accepted: true } }); + await Promise.resolve(); + + const question = events.find((event) => event.type === 'user-input.requested'); + expect(question).toEqual( + expect.objectContaining({ type: 'user-input.requested', threadId, turnId }), + ); + await server.handle( + request(4, 'user-input/respond', { + threadId, + turnId, + requestId: question?.type === 'user-input.requested' ? question.requestId : '', + answer: 'All', + }), + ); + await server.waitForIdle(); + + expect(responses).toEqual(['allow', 'All']); + expect(events.filter((event) => event.type === 'turn.completed')).toHaveLength(1); + await expect( + server.handle( + request(5, 'approval/respond', { + threadId, + turnId, + requestId: approval?.type === 'approval.requested' ? approval.requestId : '', + decision: 'allow', + }), + ), + ).resolves.toEqual({ + id: 5, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + }); + + it('releases a pending interaction when its turn is interrupted', async () => { + let decision: string | undefined; + const executor: TurnExecutor = { + execute: async ({ requestApproval }) => { + decision = await requestApproval('Bash', 'Run forever?'); + return {}; + }, + }; + const server = new AppServer({ executor }); + const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (thread.result as { id: string }).id; + const started = await server.handle( + request(2, 'turn/start', { threadId, input: { text: 'wait' } }), + ); + const turnId = (started.result as { id: string }).id; + + await server.handle(request(3, 'turn/interrupt', { threadId, turnId })); + await server.waitForIdle(); + + expect(decision).toBe('deny'); + const read = await server.handle(request(4, 'thread/read', { threadId })); + expect(read.result).toEqual( + expect.objectContaining({ turns: [expect.objectContaining({ status: 'interrupted' })] }), + ); + }); + + it('marks an orphaned active turn interrupted when a new process resumes it', async () => { + const root = await mkdtemp(join(tmpdir(), 'deepcode-app-server-')); + temporaryRoots.push(root); + const store = new FileThreadStore(root); + const first = new AppServer({ + store, + executor: { execute: () => new Promise(() => {}) }, + ...deterministicOptions(), + }); + await first.handle(request(1, 'thread/start', { cwd: '/workspace' })); + await first.handle( + request(2, 'turn/start', { threadId: 'thread-1', input: { text: 'unfinished' } }), + ); + + const restarted = new AppServer({ store, executor: { execute: async () => ({}) } }); + const response = await restarted.handle(request(3, 'thread/resume', { threadId: 'thread-1' })); + expect(response).toEqual({ + id: 3, + result: expect.objectContaining({ + turns: [expect.objectContaining({ status: 'interrupted' })], + }), + }); + }); + + it('returns structured errors for invalid requests', async () => { + const server = new AppServer({ executor: { execute: async () => ({}) } }); + await expect(server.handle(request(1, 'thread/start'))).resolves.toEqual({ + id: 1, + error: { code: 'invalid_request', message: 'cwd is required' }, + }); + await expect( + server.handle(request(2, 'thread/read', { threadId: '../credentials' })), + ).resolves.toEqual({ + id: 2, + error: { code: 'invalid_request', message: 'threadId is invalid' }, + }); + }); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts new file mode 100644 index 0000000..3d577a0 --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,422 @@ +import { + MemoryThreadStore, + ProtocolInvariantError, + ProtocolRuntime, + type CompletedItemType, + type ConfigDiagnosticsResult, + type ProtocolEvent, + type ProtocolRequest, + type ProtocolResponse, + type ThreadSnapshot, + type ThreadStore, + type TurnSnapshot, +} from '@deepcode/protocol'; + +export interface TurnExecutionItem { + type: CompletedItemType; + payload: Record; +} + +export interface TurnExecutionResult { + items?: TurnExecutionItem[]; + status?: 'completed' | 'failed'; +} + +export interface TurnExecutionArgs { + thread: ThreadSnapshot; + turn: TurnSnapshot; + input: Record; + signal: AbortSignal; + publishDelta: (itemId: string, delta: string) => void; + publishToolStarted: (itemId: string, name: string, input: Record) => void; + publishToolCompleted: (itemId: string, result: { content: string; isError?: boolean }) => void; + publishUsage: (usage: { + inputTokens: number; + outputTokens: number; + reasoningTokens?: number; + cacheReadTokens?: number; + }) => void; + requestApproval: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; + requestUserInput: (request: { + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: boolean; + }) => Promise; +} + +export interface TurnExecutor { + execute(args: TurnExecutionArgs): Promise; +} + +export interface AppServerOptions { + executor: TurnExecutor; + store?: ThreadStore; + now?: () => string; + newId?: (prefix: 'thread' | 'turn' | 'item') => string; + onEvent?: (event: ProtocolEvent) => void; + configDiagnostics?: (cwd: string) => Promise; +} + +interface ActiveTurn { + threadId: string; + controller: AbortController; + task: Promise; +} + +type PendingInteraction = + | { + kind: 'approval'; + threadId: string; + turnId: string; + resolve: (decision: 'allow' | 'deny' | 'always') => void; + } + | { + kind: 'user-input'; + threadId: string; + turnId: string; + resolve: (answer: string) => void; + }; + +class RequestValidationError extends Error {} + +export class AppServer { + private readonly lifecycle: ProtocolRuntime; + private readonly activeTurns = new Map(); + private readonly terminalTransitions = new Map>(); + private readonly pendingInteractions = new Map(); + private interactionSequence = 0; + + constructor(private readonly options: AppServerOptions) { + this.lifecycle = new ProtocolRuntime({ + store: options.store ?? new MemoryThreadStore(), + now: options.now, + newId: options.newId, + onEvent: options.onEvent, + configDiagnostics: options.configDiagnostics !== undefined, + }); + } + + async handle(request: ProtocolRequest): Promise { + try { + return { id: request.id, result: await this.dispatch(request) }; + } catch (error) { + const code = + error instanceof ProtocolInvariantError + ? 'invalid_state' + : error instanceof RequestValidationError + ? 'invalid_request' + : 'internal_error'; + return { + id: request.id, + error: { + code, + message: (error as Error).message ?? String(error), + }, + }; + } + } + + async waitForIdle(): Promise { + await Promise.all([...this.activeTurns.values()].map(({ task }) => task)); + } + + async shutdown(): Promise { + const active = [...this.activeTurns.entries()]; + await Promise.all( + active.map(async ([turnId, turn]) => { + turn.controller.abort(); + this.cancelInteractions(turnId); + await this.finishOnce(turnId, () => this.lifecycle.interruptTurn(turn.threadId, turnId)); + }), + ); + await Promise.allSettled(active.map(([, { task }]) => task)); + } + + private async dispatch(request: ProtocolRequest): Promise { + switch (request.method) { + case 'initialize': + return this.lifecycle.initialize(); + case 'config/diagnostics': + if (!this.options.configDiagnostics) { + throw new RequestValidationError('Configuration diagnostics are not available'); + } + return this.options.configDiagnostics(requiredString(request.params, 'cwd')); + case 'thread/start': + return this.lifecycle.startThread(requiredString(request.params, 'cwd')); + case 'thread/read': + return this.lifecycle.readThread(requiredId(request.params, 'threadId')); + case 'thread/resume': + return this.resumeThread(requiredId(request.params, 'threadId')); + case 'turn/start': + return this.startTurn(request.params); + case 'turn/interrupt': + return this.interruptTurn(request.params); + case 'approval/respond': + return this.respondToApproval(request.params); + case 'user-input/respond': + return this.respondToUserInput(request.params); + } + } + + private async resumeThread(threadId: string): Promise { + let thread = await this.lifecycle.resumeThread(threadId); + const orphaned = thread.turns.find( + (turn) => turn.status === 'in_progress' && !this.activeTurns.has(turn.id), + ); + if (orphaned) { + await this.lifecycle.interruptTurn(threadId, orphaned.id); + thread = await this.lifecycle.resumeThread(threadId); + } + return thread; + } + + private async startTurn(params: Record): Promise { + const threadId = requiredId(params, 'threadId'); + const input = requiredRecord(params, 'input'); + const thread = await this.lifecycle.resumeThread(threadId); + const turn = await this.lifecycle.startTurn(threadId, input); + const controller = new AbortController(); + const task = this.executeTurn(thread, turn, input, controller); + this.activeTurns.set(turn.id, { threadId, controller, task }); + return turn; + } + + private async interruptTurn(params: Record): Promise<{ interrupted: boolean }> { + const threadId = requiredId(params, 'threadId'); + const turnId = requiredId(params, 'turnId'); + const active = this.activeTurns.get(turnId); + if (!active) return { interrupted: false }; + if (active.threadId !== threadId) + throw new RequestValidationError(`Turn ${turnId} does not belong to ${threadId}`); + active.controller.abort(); + this.cancelInteractions(turnId); + const terminal = await this.finishOnce(turnId, () => + this.lifecycle.interruptTurn(threadId, turnId), + ); + return { interrupted: terminal.status === 'interrupted' }; + } + + private async executeTurn( + thread: ThreadSnapshot, + turn: TurnSnapshot, + input: Record, + controller: AbortController, + ): Promise { + try { + const result = await this.options.executor.execute({ + thread, + turn, + input, + signal: controller.signal, + publishDelta: (itemId, delta) => { + this.lifecycle.publishDelta({ + threadId: thread.id, + turnId: turn.id, + itemId, + delta, + }); + }, + publishToolStarted: (itemId, name, input) => { + this.options.onEvent?.({ + type: 'tool.started', + threadId: thread.id, + turnId: turn.id, + itemId, + name, + input, + }); + }, + publishToolCompleted: (itemId, result) => { + this.options.onEvent?.({ + type: 'tool.completed', + threadId: thread.id, + turnId: turn.id, + itemId, + result, + }); + }, + publishUsage: (usage) => { + this.options.onEvent?.({ + type: 'usage.updated', + threadId: thread.id, + turnId: turn.id, + usage, + }); + }, + requestApproval: (toolName, reason) => + this.requestApproval(thread.id, turn.id, toolName, reason), + requestUserInput: (request) => this.requestUserInput(thread.id, turn.id, request), + }); + if (controller.signal.aborted) { + await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); + return; + } + for (const item of result.items ?? []) { + await this.lifecycle.appendCompletedItem(thread.id, turn.id, item.type, item.payload); + } + if (result.status === 'failed') { + await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); + } else { + await this.finishOnce(turn.id, () => this.lifecycle.completeTurn(thread.id, turn.id)); + } + } catch (error) { + if (controller.signal.aborted || (error as Error).name === 'AbortError') { + await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); + } else { + await this.lifecycle.appendCompletedItem(thread.id, turn.id, 'error', { + message: (error as Error).message ?? String(error), + }); + await this.finishOnce(turn.id, () => this.lifecycle.failTurn(thread.id, turn.id)); + } + } finally { + this.cancelInteractions(turn.id); + this.activeTurns.delete(turn.id); + } + } + + private requestApproval( + threadId: string, + turnId: string, + toolName: string, + reason: string, + ): Promise<'allow' | 'deny' | 'always'> { + const requestId = this.nextInteractionId(); + const response = new Promise<'allow' | 'deny' | 'always'>((resolve) => { + this.pendingInteractions.set(requestId, { + kind: 'approval', + threadId, + turnId, + resolve, + }); + }); + this.options.onEvent?.({ + type: 'approval.requested', + threadId, + turnId, + requestId, + toolName, + reason, + }); + return response; + } + + private requestUserInput( + threadId: string, + turnId: string, + request: { + question: string; + options: Array<{ label: string; description: string }>; + multiSelect?: boolean; + }, + ): Promise { + const requestId = this.nextInteractionId(); + const response = new Promise((resolve) => { + this.pendingInteractions.set(requestId, { + kind: 'user-input', + threadId, + turnId, + resolve, + }); + }); + this.options.onEvent?.({ + type: 'user-input.requested', + threadId, + turnId, + requestId, + ...request, + }); + return response; + } + + private respondToApproval(params: Record): { accepted: true } { + const interaction = this.requireInteraction(params, 'approval'); + const decision = requiredString(params, 'decision'); + if (decision !== 'allow' && decision !== 'deny' && decision !== 'always') { + throw new RequestValidationError('decision is invalid'); + } + this.pendingInteractions.delete(requiredId(params, 'requestId')); + interaction.resolve(decision); + return { accepted: true }; + } + + private respondToUserInput(params: Record): { accepted: true } { + const interaction = this.requireInteraction(params, 'user-input'); + const answer = requiredString(params, 'answer'); + this.pendingInteractions.delete(requiredId(params, 'requestId')); + interaction.resolve(answer); + return { accepted: true }; + } + + private requireInteraction( + params: Record, + kind: K, + ): Extract { + const requestId = requiredId(params, 'requestId'); + const threadId = requiredId(params, 'threadId'); + const turnId = requiredId(params, 'turnId'); + const interaction = this.pendingInteractions.get(requestId); + if (!interaction || interaction.kind !== kind) { + throw new RequestValidationError(`Pending ${kind} request not found: ${requestId}`); + } + if (interaction.threadId !== threadId || interaction.turnId !== turnId) { + throw new RequestValidationError( + `Request ${requestId} does not belong to ${threadId}/${turnId}`, + ); + } + return interaction as Extract; + } + + private cancelInteractions(turnId: string): void { + for (const [requestId, interaction] of this.pendingInteractions) { + if (interaction.turnId !== turnId) continue; + this.pendingInteractions.delete(requestId); + if (interaction.kind === 'approval') interaction.resolve('deny'); + else interaction.resolve(''); + } + } + + private nextInteractionId(): string { + return `request-${Date.now().toString(36)}-${++this.interactionSequence}`; + } + + private finishOnce( + turnId: string, + transition: () => Promise, + ): Promise { + const existing = this.terminalTransitions.get(turnId); + if (existing) return existing; + const pending = transition(); + this.terminalTransitions.set(turnId, pending); + const cleanup = () => { + if (this.terminalTransitions.get(turnId) === pending) { + this.terminalTransitions.delete(turnId); + } + }; + void pending.then(cleanup, cleanup); + return pending; + } +} + +function requiredString(params: Record, key: string): string { + const value = params[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new RequestValidationError(`${key} is required`); + } + return value; +} + +function requiredId(params: Record, key: string): string { + const value = requiredString(params, key); + if (!/^[a-zA-Z0-9._-]+$/.test(value)) { + throw new RequestValidationError(`${key} is invalid`); + } + return value; +} + +function requiredRecord(params: Record, key: string): Record { + const value = params[key]; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new RequestValidationError(`${key} must be an object`); + } + return value as Record; +} diff --git a/apps/server/src/sidecar-entry.ts b/apps/server/src/sidecar-entry.ts new file mode 100644 index 0000000..446af74 --- /dev/null +++ b/apps/server/src/sidecar-entry.ts @@ -0,0 +1,15 @@ +import process from 'node:process'; + +import { runAppServer } from './run.js'; + +const home = process.env.DEEPCODE_HOME ?? `${process.env.HOME ?? process.cwd()}/.deepcode`; + +runAppServer({ + input: process.stdin, + output: process.stdout, + home, + forceFileCredentials: true, +}).catch((error) => { + process.stderr.write(`DeepCode app-server fatal: ${(error as Error).message ?? String(error)}\n`); + process.exitCode = 1; +}); diff --git a/apps/server/src/stdio.test.ts b/apps/server/src/stdio.test.ts new file mode 100644 index 0000000..54d4da9 --- /dev/null +++ b/apps/server/src/stdio.test.ts @@ -0,0 +1,105 @@ +import { PassThrough, Writable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import { AppServer } from './server.js'; +import { ProtocolLineWriter, serveStdio } from './stdio.js'; + +describe('stdio transport', () => { + it('continues after malformed input and writes one response per valid request', async () => { + const input = new PassThrough(); + let output = ''; + const writer = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + }, + }); + const server = new AppServer({ executor: { execute: async () => ({}) } }); + const serving = serveStdio(server, input, writer); + + input.end('not-json\n{"id":1,"method":"initialize","params":{}}\n'); + await serving; + + const messages = output + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record); + expect(messages).toEqual([ + { id: null, error: { code: 'parse_error', message: expect.any(String) } }, + { id: 1, result: expect.objectContaining({ protocolVersion: 1 }) }, + ]); + }); + + it('honors writable backpressure and drops only excess transient deltas', async () => { + let output = ''; + let release!: () => void; + const destination = new Writable({ + highWaterMark: 1, + write(chunk, _encoding, callback) { + output += chunk.toString(); + release = callback; + }, + }); + const writer = new ProtocolLineWriter(destination, 1); + const durable = writer.enqueue({ id: 1, result: { ok: true } }); + await Promise.resolve(); + await writer.enqueue({ + method: 'event', + params: { + type: 'item.delta', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + delta: 'drop under pressure', + }, + }); + release(); + await durable; + await writer.flush(); + + expect(output.trim()).toBe('{"id":1,"result":{"ok":true}}'); + }); + + it('interrupts active work when the single owning client disconnects', async () => { + const input = new PassThrough(); + const output = new Writable({ write: (_chunk, _encoding, callback) => callback() }); + let aborted = false; + let sequence = 0; + const server = new AppServer({ + newId: (prefix) => `${prefix}-${++sequence}`, + executor: { + execute: ({ signal }) => + new Promise((_resolve, reject) => { + signal.addEventListener( + 'abort', + () => { + aborted = true; + reject(new DOMException('aborted', 'AbortError')); + }, + { once: true }, + ); + }), + }, + }); + const serving = serveStdio(server, input, output); + input.end( + '{"id":1,"method":"thread/start","params":{"cwd":"/workspace"}}\n' + + '{"id":2,"method":"turn/start","params":{"threadId":"thread-1","input":{"text":"wait"}}}\n', + ); + + await serving; + expect(aborted).toBe(true); + const read = await server.handle({ + id: 3, + method: 'thread/read', + params: { threadId: 'thread-1' }, + }); + expect(read).toEqual({ + id: 3, + result: expect.objectContaining({ + turns: [expect.objectContaining({ status: 'interrupted' })], + }), + }); + }); +}); diff --git a/apps/server/src/stdio.ts b/apps/server/src/stdio.ts new file mode 100644 index 0000000..0503548 --- /dev/null +++ b/apps/server/src/stdio.ts @@ -0,0 +1,81 @@ +import { createInterface } from 'node:readline'; +import type { Readable, Writable } from 'node:stream'; +import { once } from 'node:events'; + +import { + decodeProtocolRequest, + encodeProtocolMessage, + type ProtocolNotification, + type ProtocolRequest, + type ProtocolResponse, +} from '@deepcode/protocol'; + +import type { AppServer } from './server.js'; + +type OutboundMessage = ProtocolResponse | ProtocolNotification; + +export class ProtocolLineWriter { + private tail = Promise.resolve(); + private failure: unknown; + private pending = 0; + + constructor( + private readonly output: Writable, + private readonly maxPending = 1024, + ) {} + + enqueue(message: OutboundMessage): Promise { + if (this.pending >= this.maxPending && isTransientDelta(message)) { + return Promise.resolve(); + } + this.pending++; + const task = this.tail.then(async () => { + if (this.failure) throw this.failure; + const accepted = this.output.write(`${encodeProtocolMessage(message)}\n`); + if (!accepted) await once(this.output, 'drain'); + }); + this.tail = task.catch((error) => { + this.failure = error; + }); + void task.then( + () => this.pending--, + () => this.pending--, + ); + return task; + } + + async flush(): Promise { + await this.tail; + if (this.failure) throw this.failure; + } +} + +export async function serveStdio( + server: AppServer, + input: Readable, + destination: Writable | ProtocolLineWriter, +): Promise { + const writer = + destination instanceof ProtocolLineWriter ? destination : new ProtocolLineWriter(destination); + const lines = createInterface({ input, crlfDelay: Infinity }); + for await (const line of lines) { + if (!line.trim()) continue; + let request: ProtocolRequest; + try { + request = decodeProtocolRequest(line); + } catch (error) { + await writer.enqueue({ + id: null, + error: { code: 'parse_error', message: (error as Error).message ?? String(error) }, + }); + continue; + } + await writer.enqueue(await server.handle(request)); + } + await server.shutdown(); + await writer.flush(); +} + +function isTransientDelta(message: OutboundMessage): boolean { + return 'method' in message && message.method === 'event' && message.params.type === 'item.delta'; +} diff --git a/apps/server/src/store.test.ts b/apps/server/src/store.test.ts new file mode 100644 index 0000000..b608806 --- /dev/null +++ b/apps/server/src/store.test.ts @@ -0,0 +1,124 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { SessionManager, writeMeta } from '@deepcode/core/sessions'; +import type { ThreadSnapshot } from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { CanonicalThreadStore } from './store.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +async function fixture() { + const root = await mkdtemp(join(tmpdir(), 'deepcode-thread-store-')); + roots.push(root); + const sessionsRoot = join(root, 'sessions'); + return { + store: new CanonicalThreadStore(join(root, 'threads-v1'), sessionsRoot), + sessions: new SessionManager({ root: sessionsRoot }), + }; +} + +describe('CanonicalThreadStore', () => { + it('materializes protocol history into the canonical session index', async () => { + const { store, sessions } = await fixture(); + const thread: ThreadSnapshot = { + id: 'thread-1', + cwd: '/workspace', + createdAt: '2026-08-01T00:00:00.000Z', + updatedAt: '2026-08-01T00:00:02.000Z', + turns: [ + { + id: 'turn-1', + threadId: 'thread-1', + status: 'completed', + startedAt: '2026-08-01T00:00:01.000Z', + completedAt: '2026-08-01T00:00:02.000Z', + items: [ + { + id: 'item-1', + type: 'user_message', + payload: { text: 'Review the repository', model: 'deepseek-chat' }, + completedAt: '2026-08-01T00:00:01.000Z', + }, + { + id: 'item-2', + type: 'assistant_message', + payload: { + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Done' }], + }, + }, + completedAt: '2026-08-01T00:00:02.000Z', + }, + ], + }, + ], + }; + + await store.save(thread); + + await expect(sessions.list()).resolves.toEqual([ + expect.objectContaining({ + id: thread.id, + cwd: '/workspace', + title: 'Review the repository', + model: 'deepseek-chat', + }), + ]); + await expect(sessions.load(thread.id)).resolves.toEqual({ + meta: expect.objectContaining({ id: thread.id }), + messages: [ + { role: 'user', content: [{ type: 'text', text: 'Review the repository' }] }, + { role: 'assistant', content: [{ type: 'text', text: 'Done' }] }, + ], + }); + + const current = await sessions.load(thread.id); + await writeMeta(sessions.root, { ...current!.meta, title: 'Renamed by user' }); + await store.save({ ...thread, updatedAt: '2026-08-01T00:00:03.000Z' }); + await expect(sessions.load(thread.id)).resolves.toEqual({ + meta: expect.objectContaining({ title: 'Renamed by user' }), + messages: expect.any(Array), + }); + }); + + it('lazily imports a canonical or legacy session as a resumable protocol thread', async () => { + const { store, sessions } = await fixture(); + const meta = await sessions.create('/legacy', { title: 'Existing chat' }); + await sessions.append(meta.id, { + role: 'user', + content: [{ type: 'text', text: 'Continue this' }], + }); + await sessions.append(meta.id, { + role: 'assistant', + content: [{ type: 'text', text: 'Ready' }], + }); + + const imported = await store.load(meta.id); + + expect(imported).toEqual( + expect.objectContaining({ + id: meta.id, + cwd: '/legacy', + turns: [ + expect.objectContaining({ + status: 'completed', + items: [ + expect.objectContaining({ type: 'user_message' }), + expect.objectContaining({ type: 'assistant_message' }), + ], + }), + ], + }), + ); + await expect(store.load(meta.id)).resolves.toEqual(imported); + }); +}); diff --git a/apps/server/src/store.ts b/apps/server/src/store.ts new file mode 100644 index 0000000..ee7599e --- /dev/null +++ b/apps/server/src/store.ts @@ -0,0 +1,145 @@ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import process from 'node:process'; + +import { type StoredMessage } from '@deepcode/core'; +import { SessionManager, type SessionMeta } from '@deepcode/core/sessions'; +import type { ThreadSnapshot, ThreadStore } from '@deepcode/protocol'; + +import { historyFromThread } from './runtime-executor.js'; + +function validThreadId(threadId: string): boolean { + return /^[a-zA-Z0-9._-]+$/.test(threadId); +} + +export class FileThreadStore implements ThreadStore { + private sequence = 0; + + constructor(readonly directory: string) {} + + async load(threadId: string): Promise { + const path = this.pathFor(threadId); + try { + return JSON.parse(await readFile(path, 'utf8')) as ThreadSnapshot; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + } + + async save(thread: ThreadSnapshot): Promise { + const path = this.pathFor(thread.id); + await mkdir(this.directory, { recursive: true }); + const temporaryPath = `${path}.${process.pid}.${++this.sequence}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify(thread)}\n`, { mode: 0o600 }); + await rename(temporaryPath, path); + } + + private pathFor(threadId: string): string { + if (!validThreadId(threadId)) throw new Error(`Invalid thread id: ${threadId}`); + return join(this.directory, `${threadId}.json`); + } +} + +/** + * Rich protocol snapshots plus a canonical session-v1 message projection. + * + * The protocol snapshot preserves lifecycle/items. The canonical projection + * keeps the existing CLI/desktop session index and legacy readers continuous + * during rollout. Both use the same id, and legacy-only sessions are imported + * lazily the first time the app-server resumes them. + */ +export class CanonicalThreadStore implements ThreadStore { + private readonly snapshots: FileThreadStore; + private readonly sessions: SessionManager; + + constructor(snapshotDirectory: string, sessionsDirectory: string) { + this.snapshots = new FileThreadStore(snapshotDirectory); + this.sessions = new SessionManager({ root: sessionsDirectory }); + } + + async load(threadId: string): Promise { + const snapshot = await this.snapshots.load(threadId); + if (snapshot) return snapshot; + const session = await this.sessions.load(threadId); + if (!session) return null; + const imported = threadFromSession(session.meta, session.messages); + await this.snapshots.save(imported); + return imported; + } + + async save(thread: ThreadSnapshot): Promise { + const messages = historyFromThread(thread); + await this.sessions.materialize(metaFromThread(thread), messages); + await this.snapshots.save(thread); + } +} + +function metaFromThread(thread: ThreadSnapshot): SessionMeta { + const firstInput = thread.turns + .flatMap((turn) => turn.items) + .find((item) => item.type === 'user_message'); + const text = typeof firstInput?.payload.text === 'string' ? firstInput.payload.text : ''; + const model = + typeof firstInput?.payload.model === 'string' ? firstInput.payload.model : undefined; + return { + id: thread.id, + cwd: thread.cwd, + createdAt: thread.createdAt, + updatedAt: thread.updatedAt, + title: titleFrom(text), + model, + }; +} + +function titleFrom(text: string): string | undefined { + const firstLine = text + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + return firstLine ? [...firstLine].slice(0, 60).join('') : undefined; +} + +function threadFromSession(meta: SessionMeta, messages: StoredMessage[]): ThreadSnapshot { + if (messages.length === 0) { + return { + id: meta.id, + cwd: meta.cwd, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + turns: [], + }; + } + return { + id: meta.id, + cwd: meta.cwd, + createdAt: meta.createdAt, + updatedAt: meta.updatedAt, + turns: [ + { + id: `legacy-${meta.id}`, + threadId: meta.id, + status: 'completed', + startedAt: meta.createdAt, + completedAt: meta.updatedAt, + items: messages.map((message, index) => itemFromMessage(message, meta, index)), + }, + ], + }; +} + +function itemFromMessage(message: StoredMessage, meta: SessionMeta, index: number) { + const only = message.content.length === 1 ? message.content[0] : undefined; + const simpleUserText = message.role === 'user' && only?.type === 'text' ? only.text : undefined; + return { + id: `legacy-item-${index + 1}`, + type: + message.role === 'assistant' + ? ('assistant_message' as const) + : simpleUserText !== undefined + ? ('user_message' as const) + : ('tool_result' as const), + payload: simpleUserText !== undefined ? { text: simpleUserText } : { message }, + completedAt: message.timestamp ?? meta.updatedAt, + }; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..2b3d3d0 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "tsBuildInfoFile": "./dist/.tsbuildinfo" + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"], + "references": [{ "path": "../../packages/core" }, { "path": "../../packages/protocol" }] +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts new file mode 100644 index 0000000..41f954b --- /dev/null +++ b/apps/server/vitest.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { environment: 'node', include: ['src/**/*.test.ts'] }, +}); diff --git a/apps/vscode/.vscodeignore b/apps/vscode/.vscodeignore new file mode 100644 index 0000000..200435f --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,9 @@ +src/** +scripts/** +node_modules/** +dist/*.map +dist/.tsbuildinfo +**/*.test.* +tsconfig.json +vitest.config.ts +*.tsbuildinfo diff --git a/apps/vscode/LICENSE b/apps/vscode/LICENSE new file mode 100644 index 0000000..a5cd724 --- /dev/null +++ b/apps/vscode/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Oratis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/vscode/README.md b/apps/vscode/README.md index 3235452..c1485e4 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -1,37 +1,40 @@ -# @deepcode/vscode — DeepCode VS Code extension (v1.1) +# DeepCode VS Code extension -DeepSeek-powered coding agent inside VS Code. Same agent loop as the CLI -and Mac client — Claude-Code parity. +DeepSeek-powered coding agent inside VS Code, backed by the same provider-neutral app-server +protocol and canonical threads as the desktop client. -## Current state — v1.1 skeleton +## Current state -- `package.json` — extension manifest with 3 commands, configuration, - activity bar + chat view, default keybinding (`Cmd/Ctrl+Shift+D`). -- `src/extension.ts` — activate / deactivate + Chat webview + 3 command - stubs. Uses lazy `require('vscode')` so the package type-checks without - `@types/vscode` installed. +- Three commands, an activity-bar chat view, model/effort settings, and a default + `Cmd/Ctrl+Shift+D` keybinding. +- Canonical thread reuse, structured text/tool events, real interrupt plumbing, approval via + warning actions, and AskUserQuestion via QuickPick/InputBox. +- A real extension bundle plus a dedicated app-server child bundle; the extension host never reads + credentials or constructs a provider/runtime. ## Activate the extension toolchain ```bash -pnpm add -D --filter @deepcode/vscode @vscode/vsce @types/vscode +pnpm add -D --filter deepcode @vscode/vsce ``` Then: -| Command | Result | -| ----------------------------------------- | ------------------------------------------------- | -| `pnpm --filter @deepcode/vscode build` | Compile `src/extension.ts` → `dist/extension.cjs` | -| `pnpm --filter @deepcode/vscode package` | Produce a `.vsix` file (vsce) | -| Press F5 in VS Code with this folder open | Launch Extension Development Host | +| Command | Result | +| ----------------------------------------- | ------------------------------------------------ | +| `pnpm --filter deepcode build` | Bundle extension + app-server child into `dist/` | +| `pnpm --filter deepcode package` | Produce a `.vsix` file (vsce) | +| Press F5 in VS Code with this folder open | Launch Extension Development Host | ## Architecture - The extension runs in the VS Code **extension host** (Node process). -- Talks directly to `@deepcode/core` — no IPC layer needed (the extension - host IS a Node runtime). -- Long-running agent loops dispatch to a child process to avoid blocking - the host (TODO in v1.1-rest). +- A single owned app-server child contains credentials, provider, RuntimeHost, tools, permissions, + and canonical session storage. +- The extension uses the shared `ProtocolClient`; model deltas, tool lifecycle, usage, approval, + questions, and terminal state use the same ids/schema as desktop and LSP. +- Closing the extension closes child stdin, allowing active turns to interrupt and persist before + the process exits. ## Commands @@ -43,17 +46,17 @@ Then: ## Settings -| Key | Type | Default | Notes | -| ----------------- | ------ | ----------------- | -------------------------------------------- | -| `deepcode.apiKey` | string | `""` | Falls back to `~/.deepcode/credentials.json` | -| `deepcode.model` | enum | `"deepseek-chat"` | Standard alias + concrete model names | -| `deepcode.effort` | enum | `"medium"` | low / medium / high / xhigh / max | +| Key | Type | Default | Notes | +| ----------------- | ---- | ----------------- | ------------------------------------- | +| `deepcode.model` | enum | `"deepseek-chat"` | Standard alias + concrete model names | +| `deepcode.effort` | enum | `"medium"` | low / medium / high / xhigh / max | + +Credentials stay in the shared DeepCode credential store and are resolved only by the child. ## Roadmap -- Real `runAgent` invocation in `deepcode.run` (instead of the info popup) - Real diff fetch via `vscode.git` API for `deepcode.review` - File panel showing live edits as the agent works -- Inline tool-approval prompts via QuickPick +- Inline webview approval cards (host-native warning actions work today) - Custom commands via skills (mirror CLI's `/skills` dir) -- LSP-style command palette integration (see `@deepcode/lsp`) +- VS Code Extension Host integration tests in addition to the protocol-runtime unit gate diff --git a/apps/vscode/media/icon.svg b/apps/vscode/media/icon.svg new file mode 100644 index 0000000..d9256b4 --- /dev/null +++ b/apps/vscode/media/icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/vscode/package.json b/apps/vscode/package.json index 66a4ec3..10531bf 100644 --- a/apps/vscode/package.json +++ b/apps/vscode/package.json @@ -1,11 +1,15 @@ { - "name": "@deepcode/vscode", + "name": "deepcode", "displayName": "DeepCode", "description": "DeepSeek-powered coding agent — Claude-Code parity inside VS Code.", "version": "0.0.0", "publisher": "deepcode", "private": true, "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/oratis/deepcode.git" + }, "engines": { "node": ">=22", "vscode": "^1.85.0" @@ -39,12 +43,6 @@ "configuration": { "title": "DeepCode", "properties": { - "deepcode.apiKey": { - "type": "string", - "default": "", - "description": "DeepSeek API key. Leave empty to use ~/.deepcode/credentials.json.", - "scope": "machine-overridable" - }, "deepcode.model": { "type": "string", "default": "deepseek-chat", @@ -96,20 +94,23 @@ ] }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "node scripts/build.mjs", + "vscode:prepublish": "pnpm build", "typecheck": "tsc -b", - "test": "vitest run --passWithNoTests", - "package": "vsce package", + "test": "vitest run", + "package": "vsce package --no-dependencies", "clean": "rm -rf dist *.vsix *.tsbuildinfo" }, "dependencies": { - "@deepcode/core": "workspace:*" + "@deepcode/app-server": "workspace:*", + "@deepcode/protocol": "workspace:*" }, "devDependencies": { "@types/node": "^22.10.0", "@types/vscode": "^1.85.0", + "@vscode/vsce": "^3.9.2", + "esbuild": "^0.21.5", "typescript": "^5.7.0", "vitest": "^2.1.9" - }, - "//notes": "vsce + @types/vscode pull ~30 MB; install when ready to ship via `pnpm add -D --filter @deepcode/vscode @vscode/vsce @types/vscode`" + } } diff --git a/apps/vscode/scripts/build.mjs b/apps/vscode/scripts/build.mjs new file mode 100644 index 0000000..84b2984 --- /dev/null +++ b/apps/vscode/scripts/build.mjs @@ -0,0 +1,49 @@ +import { mkdir, stat } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import { build } from 'esbuild'; + +const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +const outputRoot = resolve(packageRoot, 'dist'); +await mkdir(outputRoot, { recursive: true }); + +await Promise.all([ + build({ + entryPoints: [resolve(packageRoot, 'src', 'extension.ts')], + outfile: resolve(outputRoot, 'extension.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + external: ['vscode'], + define: { + 'import.meta.url': '__deepcode_import_meta_url', + }, + banner: { + js: 'const __deepcode_import_meta_url = require("node:url").pathToFileURL(__filename).href;', + }, + sourcemap: true, + legalComments: 'none', + }), + build({ + entryPoints: [resolve(packageRoot, '..', 'server', 'src', 'editor-entry.ts')], + outfile: resolve(outputRoot, 'app-server.cjs'), + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + minify: true, + sourcemap: false, + legalComments: 'none', + }), +]); + +const [extension, appServer] = await Promise.all([ + stat(resolve(outputRoot, 'extension.cjs')), + stat(resolve(outputRoot, 'app-server.cjs')), +]); +process.stdout.write( + `Built VS Code extension (${extension.size} bytes) + app-server (${appServer.size} bytes)\n`, +); diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 9fc5f16..d2e20f4 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,17 +1,27 @@ -// VS Code extension entry — DeepCode "Chat" view + 3 commands. -// Spec: docs/DEVELOPMENT_PLAN.md §v1.1 (VS Code extension) +// VS Code extension entry — thin UI over the shared app-server protocol. import type * as vscode from 'vscode'; +import { ProtocolClient, type ProtocolEvent } from '@deepcode/protocol'; +import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; + +import { EditorProtocolRuntime } from './protocol-runtime.js'; -// 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'); +let activeRuntime: EditorProtocolRuntime | undefined; + export async function activate(context: vscode.ExtensionContext): Promise { const vscodeMod = await loadVscode(); const { commands, window, workspace } = vscodeMod; + const appServer = context.asAbsolutePath('dist/app-server.cjs'); + const runtime = new EditorProtocolRuntime( + new ProtocolClient( + new SpawnedAppServerConnection({ command: process.execPath, args: [appServer] }), + ), + () => workspace.workspaceFolders?.[0]?.uri.fsPath ?? process.cwd(), + ); + activeRuntime = runtime; - // ── Commands ──────────────────────────────────────────────────────── context.subscriptions.push( commands.registerCommand('deepcode.openPanel', () => { void commands.executeCommand('workbench.view.extension.deepcode'); @@ -32,165 +42,211 @@ export async function activate(context: vscode.ExtensionContext): Promise value: 'Explain this code.', }); if (!prompt) return; - const composed = `${prompt}\n\n----- Selected code -----\n${selection}`; - await runAgent(composed, vscodeMod); + await runInOutput(`${prompt}\n\n----- Selected code -----\n${selection}`, vscodeMod, runtime); }), commands.registerCommand('deepcode.review', async () => { - // 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) { + if (!workspace.workspaceFolders?.[0]) { void window.showInformationMessage('DeepCode: open a folder first.'); return; } - const prompt = + await runInOutput( 'Review the current uncommitted diff. Cite file:line for each finding. ' + - 'Categorize as BUG / LATENT / SUGGESTION.'; - await runAgent(prompt, vscodeMod, root); + 'Categorize as BUG / LATENT / SUGGESTION.', + vscodeMod, + runtime, + ); }), - ); - - // ── Chat view provider ────────────────────────────────────────────── - context.subscriptions.push( - window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod)), + window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod, runtime)), ); } -export function deactivate(): void { - /* no-op */ +export async function deactivate(): Promise { + const runtime = activeRuntime; + activeRuntime = undefined; + await runtime?.close(); } -// ────────────────────────────────────────────────────────────────────────── -// Real runAgent invocation — same @deepcode/core code drives CLI / Mac / LSP -// ────────────────────────────────────────────────────────────────────────── - -async function runAgent( +async function runInOutput( userMessage: string, vscodeMod: V, - cwd: string = process.cwd(), + runtime: EditorProtocolRuntime, ): 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(` ${truncate(userMessage, 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) { + await runtime.start(modelInput(userMessage, vscodeMod), (event) => { + projectOutputEvent(event, out); + void respondToInteraction(event, vscodeMod, runtime); + }); + } catch (error) { + out.appendLine(`\n✕ ${(error as Error).message ?? String(error)}`); + } +} + +function modelInput(text: string, vscodeMod: V) { + const config = vscodeMod.workspace.getConfiguration('deepcode'); + return { + text, + model: config.get('model', 'deepseek-chat'), + effort: config.get('effort', 'medium'), + mode: 'default', + }; +} + +function projectOutputEvent(event: ProtocolEvent, out: vscode.OutputChannel): void { + switch (event.type) { + case 'item.delta': + out.append(event.delta); + break; + case 'tool.started': + out.appendLine(`\n[${event.name}] ${formatInput(event.input)}`); + break; + case 'tool.completed': out.appendLine( - '✕ No DeepSeek credentials. Run `deepcode` once in a terminal to onboard, or set DEEPSEEK_API_KEY.', + ` ${event.result.isError ? '✕' : '✓'} ${truncate(event.result.content, 200)}`, ); - 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)}`); + break; + case 'approval.requested': + out.appendLine(`\n[approval] ${event.toolName}: ${event.reason}`); + break; + case 'user-input.requested': + out.appendLine(`\n[input] ${event.question}`); + break; + case 'turn.completed': + out.appendLine('\n'); + break; + case 'turn.interrupted': + out.appendLine('\n⏹ interrupted\n'); + break; + case 'turn.failed': + out.appendLine(`\n✕ ${turnError(event.turn) ?? 'turn failed'}\n`); + break; + } +} + +async function respondToInteraction( + event: ProtocolEvent, + vscodeMod: V, + runtime: EditorProtocolRuntime, +): Promise { + if (event.type === 'approval.requested') { + const choice = await vscodeMod.window.showWarningMessage( + `${event.toolName}: ${event.reason}`, + 'Allow once', + 'Deny', + 'Always allow', + ); + const decision = + choice === 'Always allow' ? 'always' : choice === 'Allow once' ? 'allow' : 'deny'; + await runtime.approve(event.turnId, event.requestId, decision); + } else if (event.type === 'user-input.requested') { + const answer = event.options.length + ? await vscodeMod.window.showQuickPick( + event.options.map((option) => ({ label: option.label, description: option.description })), + { placeHolder: event.question }, + ) + : await vscodeMod.window.showInputBox({ prompt: event.question }); + await runtime.answer( + event.turnId, + event.requestId, + typeof answer === 'string' ? answer : (answer?.label ?? ''), + ); } } 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; + const value = input[key]; + if (typeof value === 'string') return value; } return JSON.stringify(input).slice(0, 80); } -function truncate(s: string, n: number): string { - return s.length > n ? s.slice(0, n) + '…' : s; +function turnError(turn: Extract['turn']) { + return [...turn.items].reverse().find((item) => item.type === 'error')?.payload.message as + | string + | undefined; +} + +function truncate(value: string, length: number): string { + return value.length > length ? `${value.slice(0, length)}…` : value; } class ChatViewProvider implements vscode.WebviewViewProvider { - constructor(private readonly vscodeMod: V) {} + constructor( + private readonly vscodeMod: V, + private readonly runtime: EditorProtocolRuntime, + ) {} resolveWebviewView(view: vscode.WebviewView): void { view.webview.options = { enableScripts: true }; view.webview.html = chatHtml(); - view.webview.onDidReceiveMessage((msg: unknown) => { - void this.handleMessage(view, msg as { kind: string; text?: string }); + view.webview.onDidReceiveMessage((message: unknown) => { + void this.handleMessage(view, message as { kind: string; text?: string }); }); } private async handleMessage( view: vscode.WebviewView, - msg: { kind: string; text?: string }, + message: { kind: string; text?: string }, ): Promise { - if (msg.kind !== 'send' || !msg.text) return; + if (message.kind !== 'send' || !message.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}` }); - } - }, + await this.runtime.start(modelInput(message.text, this.vscodeMod), (event) => { + projectWebviewEvent(event, view); + void respondToInteraction(event, this.vscodeMod, this.runtime); }); - if (buffer) view.webview.postMessage({ kind: 'assistant_end' }); - } catch (err) { - view.webview.postMessage({ + } catch (error) { + void view.webview.postMessage({ kind: 'assistant', - text: `✕ ${(err as Error).message ?? String(err)}`, + text: `✕ ${(error as Error).message ?? String(error)}`, }); } } } +function projectWebviewEvent(event: ProtocolEvent, view: vscode.WebviewView): void { + switch (event.type) { + case 'item.delta': + void view.webview.postMessage({ kind: 'assistant_stream', text: event.delta }); + break; + case 'tool.started': + void view.webview.postMessage({ + kind: 'tool', + text: `[${event.name}] ${formatInput(event.input)}`, + }); + break; + case 'tool.completed': + void view.webview.postMessage({ + kind: 'tool', + text: `${event.result.isError ? '✕' : '✓'} ${truncate(event.result.content, 200)}`, + }); + break; + case 'approval.requested': + void view.webview.postMessage({ + kind: 'tool', + text: `[approval] ${event.toolName}: ${event.reason}`, + }); + break; + case 'user-input.requested': + void view.webview.postMessage({ kind: 'tool', text: `[input] ${event.question}` }); + break; + case 'turn.completed': + case 'turn.interrupted': + void view.webview.postMessage({ kind: 'assistant_end' }); + break; + case 'turn.failed': + void view.webview.postMessage({ + kind: 'assistant', + text: `✕ ${turnError(event.turn) ?? 'turn failed'}`, + }); + break; + } +} + function chatHtml(): string { return `