diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a3d8feb..630f17c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,8 +68,17 @@ jobs: DC_SANDBOX_NET_TEST: '1' run: pnpm test - - name: Build - run: pnpm build + - name: Build + app-server release gate + run: pnpm release:check + + - name: Upload release-gate diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-gate-${{ matrix.os }} + path: apps/vscode/dist/release-gate-report.json + if-no-files-found: ignore + retention-days: 7 link-check: name: Docs link check @@ -80,6 +89,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 +125,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..99f40c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: validate: name: Validate before release runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 25 outputs: version: ${{ steps.version.outputs.version }} channel: ${{ steps.version.outputs.channel }} @@ -30,8 +30,29 @@ jobs: cache: 'pnpm' - run: pnpm install --frozen-lockfile - run: pnpm typecheck + - run: pnpm lint + - run: pnpm format:check - run: pnpm test - - run: pnpm build + - run: pnpm docs:check + - run: pnpm release:check + + - name: Install Chromium + run: pnpm --filter @deepcode/desktop exec playwright install --with-deps chromium + + - name: Exercise desktop protocol fixture + run: pnpm --filter @deepcode/desktop test:e2e + + - name: Upload validation diagnostics + if: failure() + uses: actions/upload-artifact@v4 + with: + name: release-validation-diagnostics + path: | + apps/vscode/dist/release-gate-report.json + apps/desktop/playwright-report + apps/desktop/test-results + if-no-files-found: ignore + retention-days: 14 - id: version name: Parse version + channel from tag @@ -53,7 +74,9 @@ jobs: # ---------------------------------------------------------------------- publish-cli: name: Publish deepcode-cli to npm - needs: validate + # Avoid a partial release: do not publish npm until both installable + # desktop/editor artifacts have built successfully. + needs: [validate, build-vscode, build-mac] runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -83,6 +106,39 @@ jobs: env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + # ---------------------------------------------------------------------- + # Build installable VS Code extension + # ---------------------------------------------------------------------- + build-vscode: + name: Build VS Code extension + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - uses: pnpm/action-setup@v6 + - uses: actions/setup-node@v6 + with: + node-version: '22' + cache: 'pnpm' + - run: pnpm install --frozen-lockfile + + - name: Set extension version from tag + run: npm version "${{ needs.validate.outputs.version }}" --no-git-tag-version + working-directory: apps/vscode + + - name: Package VSIX + run: | + mkdir -p release-artifacts + pnpm --dir apps/vscode package \ + --out "../../release-artifacts/deepcode-${{ needs.validate.outputs.version }}.vsix" + + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: vscode-release + path: release-artifacts/deepcode-*.vsix + # ---------------------------------------------------------------------- # Build + sign Mac client (.dmg) via Tauri # ---------------------------------------------------------------------- @@ -116,6 +172,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 @@ -180,7 +249,7 @@ jobs: # ---------------------------------------------------------------------- github-release: name: Publish GitHub Release - needs: [validate, publish-cli, build-mac] + needs: [validate, publish-cli, build-vscode, build-mac] runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -199,6 +268,12 @@ jobs: name: mac-release path: release-artifacts/ + - name: Download VS Code extension + uses: actions/download-artifact@v4 + with: + name: vscode-release + path: release-artifacts/ + - name: Generate release notes id: notes run: | @@ -218,4 +293,6 @@ jobs: body_path: release-notes.md prerelease: ${{ needs.validate.outputs.channel != 'stable' }} generate_release_notes: false - files: release-artifacts/*.dmg + files: | + release-artifacts/*.dmg + release-artifacts/*.vsix 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..4d89c7c 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -3,9 +3,11 @@ // Spec: docs/DEVELOPMENT_PLAN.md §5 / §5a // M2: onboarding + REPL + slash commands + settings + permissions matcher. -import { CredentialsStore, VERSION, redact } from '@deepcode/core'; +import { CredentialsStore, VERSION, diagnoseSettings, redact } from '@deepcode/core'; +import { runAppServer } from '@deepcode/app-server'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import { runDiagnosticsCommand } from './diagnostics-cmd.js'; import { runHeadless } from './headless.js'; import { runMcpCommand } from './mcp-cmd.js'; import { runOnboarding } from './onboarding.js'; @@ -13,9 +15,11 @@ import { helpText, parseArgs } from './parse-args.js'; import { startRepl } from './repl.js'; import { runCronCommand, runSchedulerRun } from './scheduler.js'; import { runTrustCommand } from './trust-cmd.js'; +import { TrustStore } from './trust.js'; import { runPluginsCommand, runSkillsCommand } from './list-cmd.js'; import { runSetupToken } from './setup-token.js'; import { runCompletion } from './completion.js'; +import { runHooksCommand } from './hooks-cmd.js'; async function main(): Promise { const args = parseArgs(process.argv.slice(2)); @@ -80,12 +84,36 @@ 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] === 'diagnostics') { + const home = process.env.DEEPCODE_HOME ?? resolve(homedir(), '.deepcode'); + return runDiagnosticsCommand(args.positional.slice(1), { + home, + cwd: process.cwd(), + output: process.stdout, + errOutput: process.stderr, + }); + } if (args.positional[0] === 'trust') { return runTrustCommand(args.positional.slice(1), { cwd: process.cwd(), output: process.stdout, }); } + if (args.positional[0] === 'hooks') { + return runHooksCommand(args.positional.slice(1), { + cwd: process.cwd(), + output: process.stdout, + errOutput: process.stderr, + }); + } if (args.positional[0] === 'setup-token') { return runSetupToken({ token: args.positional[1] }); } @@ -175,11 +203,13 @@ async function main(): Promise { } async function doctor(): Promise { + const cwd = resolve(process.cwd()); process.stdout.write(`DeepCode v${VERSION}\n`); process.stdout.write(`Node: ${process.version}\n`); process.stdout.write(`Platform: ${process.platform} ${process.arch}\n`); process.stdout.write(`Home: ${homedir()}\n`); - process.stdout.write(`CWD: ${resolve(process.cwd())}\n`); + process.stdout.write(`CWD: ${cwd}\n`); + let failed = false; try { const store = new CredentialsStore(); const creds = await store.load(); @@ -188,7 +218,26 @@ async function doctor(): Promise { } catch (err) { process.stdout.write(`Credentials error: ${(err as Error).message}\n`); } - return 0; + try { + const trustStatus = await new TrustStore().statusFor(cwd); + const config = await diagnoseSettings({ cwd, trustStatus }); + process.stdout.write(`Configuration trust: ${config.trustStatus}\n`); + for (const layer of config.layers) { + const status = layer.present ? (layer.trusted ? 'active' : 'untrusted') : 'missing'; + process.stdout.write(`Config ${layer.layer}: ${status} (${layer.path})\n`); + } + process.stdout.write( + `Config gated: ${config.gated.length ? config.gated.join(', ') : 'none'}\n`, + ); + for (const issue of config.issues) { + process.stdout.write(`Config ${issue.severity}: [${issue.code}] ${issue.message}\n`); + if (issue.severity === 'error') failed = true; + } + } catch (error) { + process.stdout.write(`Configuration error: ${(error as Error).message}\n`); + failed = true; + } + return failed ? 1 : 0; } main().then( 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/diagnostics-cmd.test.ts b/apps/cli/src/diagnostics-cmd.test.ts new file mode 100644 index 0000000..0eb1f62 --- /dev/null +++ b/apps/cli/src/diagnostics-cmd.test.ts @@ -0,0 +1,52 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { runDiagnosticsCommand } from './diagnostics-cmd.js'; + +let root: string | undefined; + +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +function capture(stream: PassThrough): () => string { + let value = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + value += chunk; + }); + return () => value; +} + +describe('runDiagnosticsCommand', () => { + it('exports through the shared app-server sanitizer', async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-diagnostics-cli-')); + const cwd = join(root, 'private-workspace-name'); + const output = new PassThrough(); + const errOutput = new PassThrough(); + const outputText = capture(output); + + await expect( + runDiagnosticsCommand(['export'], { cwd, home: root, output, errOutput }), + ).resolves.toBe(0); + const path = outputText().trim().replace('Wrote redacted diagnostic bundle: ', ''); + const bundle = await readFile(path, 'utf8'); + expect(bundle).not.toContain('private-workspace-name'); + }); + + it('rejects unknown diagnostics actions', async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-diagnostics-cli-')); + const output = new PassThrough(); + const errOutput = new PassThrough(); + const errorText = capture(errOutput); + await expect( + runDiagnosticsCommand([], { cwd: root, home: root, output, errOutput }), + ).resolves.toBe(2); + expect(errorText()).toContain('diagnostics export'); + }); +}); diff --git a/apps/cli/src/diagnostics-cmd.ts b/apps/cli/src/diagnostics-cmd.ts new file mode 100644 index 0000000..2bd2ab9 --- /dev/null +++ b/apps/cli/src/diagnostics-cmd.ts @@ -0,0 +1,35 @@ +import { join, resolve } from 'node:path'; +import type { Writable } from 'node:stream'; + +import { exportDiagnosticBundle } from '@deepcode/app-server/diagnostics'; +import { diagnoseSettings } from '@deepcode/core'; + +import { TrustStore } from './trust.js'; + +export interface DiagnosticsCommandOptions { + cwd: string; + home: string; + output: Writable; + errOutput: Writable; +} + +export async function runDiagnosticsCommand( + args: string[], + options: DiagnosticsCommandOptions, +): Promise { + if (args[0] !== 'export') { + options.errOutput.write('Usage: deepcode diagnostics export\n'); + return 2; + } + const cwd = resolve(options.cwd); + const trustStatus = await new TrustStore({ directory: options.home }).statusFor(cwd); + const config = await diagnoseSettings({ cwd, directory: options.home, trustStatus }); + const result = await exportDiagnosticBundle({ + home: options.home, + cwd, + config, + logPath: join(options.home, 'logs', 'app-server.ndjson'), + }); + options.output.write(`Wrote redacted diagnostic bundle: ${result.path}\n`); + return 0; +} diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index c24213b..3437d2d 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -20,7 +20,9 @@ import { DeepSeekProvider, EFFORT_PARAMS, HookDispatcher, + HookTrustStore, ReadTool, + RuntimeHost, SessionManager, ToolRegistry, WebFetchTool, @@ -39,7 +41,6 @@ import { loadSkills, makeSkillTool, resolveCredentials, - runAgent, wirePlugins, collectPluginContributions, type AgentEvent, @@ -94,13 +95,27 @@ export async function runHeadless(opts: HeadlessOpts): Promise { const loaded = await loadSettings({ cwd, home: opts.home, settingsPath: opts.settingsPath }); const trustStore = new TrustStore({ home: opts.home }); const trustStatus = await trustStore.statusFor(cwd); - const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); + const gate = gateUntrustedSettings(loaded, trustStatus); + let settings = gate.settings; + const gated = gate.gated; if (gated.length > 0) { errOutput.write( `Untrusted directory — ignoring project ${gated.join(', ')} (can execute code). ` + `Run \`deepcode trust\` to enable.\n`, ); } + const hookReview = await new HookTrustStore({ home: opts.home }).review( + cwd, + loaded, + settings.hooks, + ); + settings = { ...settings, hooks: hookReview.hooks }; + const pendingHooks = hookReview.reviews.filter((review) => !review.trusted); + if (pendingHooks.length > 0) { + errOutput.write( + `${pendingHooks.length} project command hook(s) disabled; review with \`deepcode hooks list\`.\n`, + ); + } const credsStore = new CredentialsStore({ home: opts.home }); const creds = await resolveCredentials({ store: credsStore, @@ -271,9 +286,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 +305,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 +459,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/hooks-cmd.test.ts b/apps/cli/src/hooks-cmd.test.ts new file mode 100644 index 0000000..2ffa441 --- /dev/null +++ b/apps/cli/src/hooks-cmd.test.ts @@ -0,0 +1,57 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Writable } from 'node:stream'; + +import { DirectoryTrustStore, writeSettings } from '@deepcode/core'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { runHooksCommand } from './hooks-cmd.js'; + +function sink(): { stream: Writable; text: () => string } { + let value = ''; + return { + stream: new Writable({ + write(chunk, _encoding, callback) { + value += chunk.toString(); + callback(); + }, + }), + text: () => value, + }; +} + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +describe('runHooksCommand', () => { + it('lists, trusts, and revokes exact project command hooks', async () => { + const home = await mkdtemp(join(tmpdir(), 'dc-hooks-command-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-hooks-command-cwd-')); + roots.push(home, cwd); + await new DirectoryTrustStore({ home }).trust(cwd, 'full'); + await writeSettings(join(cwd, '.deepcode', 'settings.json'), { + hooks: { Stop: [{ hooks: [{ type: 'command', command: 'echo reviewed' }] }] }, + }); + + const pending = sink(); + expect(await runHooksCommand(['list'], { cwd, home, output: pending.stream })).toBe(0); + expect(pending.text()).toMatch(/pending.*Stop.*echo reviewed/); + + const trusted = sink(); + await runHooksCommand(['trust', '--all'], { cwd, home, output: trusted.stream }); + expect(trusted.text()).toContain('Trusted 1'); + const listed = sink(); + await runHooksCommand(['list'], { cwd, home, output: listed.stream }); + expect(listed.text()).toMatch(/trusted.*Stop.*echo reviewed/); + + await runHooksCommand(['revoke'], { cwd, home, output: sink().stream }); + const revoked = sink(); + await runHooksCommand(['list'], { cwd, home, output: revoked.stream }); + expect(revoked.text()).toMatch(/pending/); + }); +}); diff --git a/apps/cli/src/hooks-cmd.ts b/apps/cli/src/hooks-cmd.ts new file mode 100644 index 0000000..222d0b8 --- /dev/null +++ b/apps/cli/src/hooks-cmd.ts @@ -0,0 +1,71 @@ +import type { Writable } from 'node:stream'; + +import { + DirectoryTrustStore, + gateUntrustedSettings, + HookTrustStore, + loadSettings, +} from '@deepcode/core'; + +export async function runHooksCommand( + args: string[], + deps: { cwd: string; home?: string; output?: Writable; errOutput?: Writable }, +): Promise { + const out = deps.output ?? process.stdout; + const err = deps.errOutput ?? process.stderr; + const directoryTrust = new DirectoryTrustStore({ home: deps.home }); + const trustStatus = await directoryTrust.statusFor(deps.cwd); + if (trustStatus !== 'trusted') { + err.write('Trust this directory with `deepcode trust` before reviewing project hooks.\n'); + return 2; + } + const loaded = await loadSettings({ cwd: deps.cwd, home: deps.home }); + const gate = gateUntrustedSettings(loaded, trustStatus); + const store = new HookTrustStore({ home: deps.home }); + const result = await store.review(deps.cwd, loaded, gate.settings.hooks); + const action = args[0] ?? 'list'; + + if (action === 'trust') { + const pending = result.reviews.filter((review) => !review.trusted); + const requested = args.slice(1); + if (requested.length === 0) { + for (const review of pending) { + out.write(`pending ${review.hash} ${review.event} ${review.command}\n`); + } + err.write('Pass one or more hook hashes, or `--all`, after reviewing the definitions.\n'); + return 2; + } + const selected = requested.includes('--all') + ? pending + : pending.filter((review) => requested.includes(review.hash)); + const unknown = requested.filter( + (value) => value !== '--all' && !pending.some((review) => review.hash === value), + ); + if (unknown.length > 0) { + err.write(`Unknown or already-trusted hook hash: ${unknown.join(', ')}\n`); + return 2; + } + await store.trust(deps.cwd, selected); + out.write(`Trusted ${selected.length} project command hook definition(s) in ${deps.cwd}.\n`); + return 0; + } + if (action === 'revoke') { + await store.revoke(deps.cwd); + out.write(`Revoked project command hook trust in ${deps.cwd}.\n`); + return 0; + } + if (action !== 'list') { + err.write('Usage: deepcode hooks [list | trust | revoke]\n'); + return 2; + } + if (result.reviews.length === 0) { + out.write('No project command hooks require review.\n'); + return 0; + } + for (const review of result.reviews) { + out.write( + `${review.trusted ? 'trusted' : 'pending'} ${review.hash} ${review.event} ${review.command}\n`, + ); + } + return 0; +} diff --git a/apps/cli/src/parse-args.ts b/apps/cli/src/parse-args.ts index f2fc27f..6b4ab73 100644 --- a/apps/cli/src/parse-args.ts +++ b/apps/cli/src/parse-args.ts @@ -285,7 +285,10 @@ 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 diagnostics export Write a redacted app-server support bundle deepcode trust [--plan-only] Trust this directory's project config (hooks/MCP/...) + deepcode hooks list|trust|revoke Review exact project command-hook definitions deepcode plugins list [--json] List installed plugins deepcode plugins install Install a plugin (gh:owner/repo | name@npm | ./path) deepcode plugins uninstall Remove an installed plugin diff --git a/apps/cli/src/repl.ts b/apps/cli/src/repl.ts index 150854d..35abfa1 100644 --- a/apps/cli/src/repl.ts +++ b/apps/cli/src/repl.ts @@ -7,7 +7,9 @@ import { DeepSeekProvider, EFFORT_PARAMS, HookDispatcher, + HookTrustStore, ReadTool, + RuntimeHost, SessionManager, TaskManager, ToolRegistry, @@ -37,7 +39,6 @@ import { contextWindowFor, makeSkillTool, resolveCredentials, - runAgent, settingsPaths, wirePlugins, collectPluginContributions, @@ -212,13 +213,27 @@ export async function startRepl(opts: ReplOpts): Promise { const loaded = await loadSettings({ cwd, home: opts.home, settingsPath: opts.settingsPath }); const trustStore = new TrustStore({ home: opts.home }); const trustStatus = await trustStore.statusFor(cwd); - const { settings, gated } = gateUntrustedSettings(loaded, trustStatus); + const gate = gateUntrustedSettings(loaded, trustStatus); + let settings = gate.settings; + const gated = gate.gated; if (gated.length > 0) { output.write( ` ⚠ Untrusted directory — ignoring project ${gated.join(', ')} (can execute code).\n` + ` Run \`deepcode trust\` here to enable them.\n`, ); } + const hookReview = await new HookTrustStore({ home: opts.home }).review( + cwd, + loaded, + settings.hooks, + ); + settings = { ...settings, hooks: hookReview.hooks }; + const pendingHooks = hookReview.reviews.filter((review) => !review.trusted); + if (pendingHooks.length > 0) { + output.write( + ` ⚠ ${pendingHooks.length} project command hook(s) disabled; review with \`deepcode hooks list\`.\n`, + ); + } const credsStore = new CredentialsStore({ home: opts.home }); const creds = await resolveCredentials({ store: credsStore, @@ -429,6 +444,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 +497,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 +670,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 +682,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 +776,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-cmd.test.ts b/apps/cli/src/trust-cmd.test.ts index efebdca..452c84d 100644 --- a/apps/cli/src/trust-cmd.test.ts +++ b/apps/cli/src/trust-cmd.test.ts @@ -31,7 +31,7 @@ describe('runTrustCommand', () => { const out = sink(); const code = await runTrustCommand([], { cwd, home, output: out.stream }); expect(code).toBe(0); - expect(out.text()).toMatch(/Trusted .* enabled here/); + expect(out.text()).toMatch(/Trusted .* review command hooks/); expect(await new TrustStore({ home }).statusFor(cwd)).toBe('trusted'); }); diff --git a/apps/cli/src/trust-cmd.ts b/apps/cli/src/trust-cmd.ts index 9702c19..7c0b10c 100644 --- a/apps/cli/src/trust-cmd.ts +++ b/apps/cli/src/trust-cmd.ts @@ -1,8 +1,8 @@ // `deepcode trust [--plan-only | --remove | --list]` — manage directory trust. // Spec: docs/DEVELOPMENT_PLAN.md §3.15.10 // -// Trusting a directory lets its project-local settings.json run code (hooks, -// MCP servers, apiKeyHelper, statusLine). Until trusted, those are gated (see +// Trusting a directory lets its project-local settings.json contribute authority-bearing +// settings. Command hooks still require definition-level review. Until trusted, those are gated (see // core/config/trust-gate). The user-global layer is always trusted. import type { Writable } from 'node:stream'; @@ -43,7 +43,7 @@ export async function runTrustCommand(args: string[], deps: TrustCmdDeps): Promi out.write( mode === 'plan-only' ? `Trusted ${deps.cwd} (plan-only — project config can run, but the session starts in plan mode).\n` - : `Trusted ${deps.cwd} — project hooks, MCP servers, apiKeyHelper, and statusLine are now enabled here.\n`, + : `Trusted ${deps.cwd} — project config is enabled; review command hooks with \`deepcode hooks list\`.\n`, ); return 0; } 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..95a865a 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -15,13 +15,15 @@ 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/file_preview.rs 只读文件预览(不包含工作区变更能力) + src/snapshots.rs app-server snapshot 的只读 Diff/History 投影 src/lib.rs Tauri builder / 插件注册 tauri.conf.json 窗口 + 构建 + 打包配置 capabilities/ 权限能力声明 @@ -31,6 +33,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 +47,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 +68,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..116cec0 --- /dev/null +++ b/apps/desktop/e2e/desktop-preview.spec.ts @@ -0,0 +1,92 @@ +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); +}); + +test('shows the shared trust-aware configuration diagnostics in About', async ({ page }) => { + await page.getByRole('button', { name: 'Settings', exact: true }).click(); + await page.getByRole('button', { name: 'ⓘ About', exact: true }).click(); + + const main = page.getByRole('main'); + await expect(main.getByText('Diagnostics', { exact: true })).toBeVisible(); + await expect(main.getByText('untrusted', { exact: true })).toBeVisible(); + await expect(main.getByText('permissions', { exact: true })).toBeVisible(); + await expect(main.getByText('1', { exact: true })).toBeVisible(); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b26b64f..e7be91e 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,15 +17,16 @@ "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", - "@tauri-apps/plugin-fs": "^2.0.0", "@tauri-apps/plugin-opener": "^2.0.0", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-shell": "^2.0.0", @@ -32,6 +35,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..159f136 100644 --- a/apps/desktop/src-tauri/Cargo.lock +++ b/apps/desktop/src-tauri/Cargo.lock @@ -675,13 +675,12 @@ name = "deepcode_desktop" version = "0.1.6" dependencies = [ "dirs 5.0.1", + "libc", "serde", "serde_json", - "sha2", "tauri", "tauri-build", "tauri-plugin-dialog", - "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-process", "tauri-plugin-shell", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index 703e41d..dfdd2cb 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -16,17 +16,16 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-dialog = "2" -tauri-plugin-fs = "2" tauri-plugin-opener = "2" tauri-plugin-shell = "2" tauri-plugin-updater = "2" tauri-plugin-process = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -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..310ec03 100644 --- a/apps/desktop/src-tauri/capabilities/default.json +++ b/apps/desktop/src-tauri/capabilities/default.json @@ -10,12 +10,9 @@ "core:window:allow-minimize", "core:window:allow-maximize", "core:window:allow-close", - "dialog:default", - "fs:default", - "opener:default", - "shell:default", + "dialog:allow-open", + "opener:allow-default-urls", "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..2d91855 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 @@ -669,7 +721,7 @@ pub fn list_plugins() -> Vec { // ── Serde contract ───────────────────────────────────────────────────── // AppInfo + SessionMeta are read by tauri-api.ts using snake_case keys // (home_dir, size_bytes, updated_at_secs). They intentionally do NOT use -// rename_all="camelCase" (unlike the tool output structs in tools.rs). Lock +// rename_all="camelCase" (unlike the read-only file preview response). Lock // that so a stray rename_all can't silently break the renderer. See HANDOFF §8a. #[cfg(test)] mod contract_tests { @@ -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/file_preview.rs b/apps/desktop/src-tauri/src/file_preview.rs new file mode 100644 index 0000000..55dd288 --- /dev/null +++ b/apps/desktop/src-tauri/src/file_preview.rs @@ -0,0 +1,130 @@ +// Read-only file preview exposed to the desktop renderer. Runtime mutations +// belong to the bundled app-server and are intentionally absent from Tauri's +// command surface. + +use serde::Serialize; +use std::path::Path; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ReadOk { + pub content: String, + pub lines_total: usize, + pub lines_shown: usize, + pub offset: usize, +} + +#[tauri::command] +pub async fn tool_read( + file_path: String, + offset: Option, + limit: Option, +) -> Result { + 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(); + let offset = offset.unwrap_or(1).max(1); + let limit = limit.unwrap_or(2000).max(1); + let start = (offset - 1).min(lines.len()); + let end = (start + limit).min(lines.len()); + let slice = &lines[start..end]; + + let numbered: Vec = slice + .iter() + .enumerate() + .map(|(i, line)| { + let n = offset + i; + let truncated = if line.chars().count() > 2000 { + format!("{}... [truncated]", line.chars().take(2000).collect::()) + } else { + line.to_string() + }; + format!("{:>6}\t{}", n, truncated) + }) + .collect(); + let mut content = numbered.join("\n"); + let shown = slice.len(); + let total = lines.len(); + if shown < total.saturating_sub(start) { + content.push_str(&format!( + "\n\n[Showing lines {}-{} of {}. Use offset/limit to see more.]", + offset, + offset + shown - 1, + total + )); + } + Ok(ReadOk { + content, + lines_total: total, + lines_shown: shown, + offset, + }) +} + +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(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn read_ok_serializes_camel_case() { + let value = serde_json::to_value(ReadOk { + content: String::new(), + lines_total: 10, + lines_shown: 5, + offset: 1, + }) + .unwrap(); + let object = value.as_object().unwrap(); + assert!(object.contains_key("linesTotal")); + assert!(object.contains_key("linesShown")); + assert!(!object.contains_key("lines_total")); + } + + #[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()); + } + + #[tokio::test] + async fn read_handles_unicode_truncation_and_offset_past_eof() { + let path = std::env::temp_dir().join(format!( + "deepcode-preview-{}-{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "界".repeat(2001)).unwrap(); + let preview = tool_read(path.to_string_lossy().into_owned(), None, None) + .await + .unwrap(); + assert!(preview.content.ends_with("... [truncated]")); + let empty = tool_read(path.to_string_lossy().into_owned(), Some(99), None) + .await + .unwrap(); + assert_eq!(empty.lines_shown, 0); + assert!(empty.content.is_empty()); + std::fs::remove_file(path).ok(); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 866d119..eef5f07 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -3,28 +3,30 @@ // // 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 file_preview; mod settings; mod snapshots; -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, + append_allow_matcher, cli_path, credential_status, get_app_info, get_settings_path, + list_plugins, list_sessions, 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 file_preview::tool_read; use snapshots::session_snapshots; use tauri::Manager; -use tools::{tool_bash, tool_edit, tool_glob, tool_grep, tool_read, tool_write}; use voice::{voice_cancel, voice_start, voice_status, voice_stop, VoiceState}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -32,14 +34,18 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) - .plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_shell::init()) .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 +53,6 @@ pub fn run() { append_allow_matcher, load_keybindings, save_keybindings, - session_create, - session_append, session_read, session_set_title, session_delete, @@ -59,11 +63,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/snapshots.rs b/apps/desktop/src-tauri/src/snapshots.rs index a97ccbe..be0e5c8 100644 --- a/apps/desktop/src-tauri/src/snapshots.rs +++ b/apps/desktop/src-tauri/src/snapshots.rs @@ -1,103 +1,18 @@ -// File snapshots — captured before & after each Edit/Write so the right-side -// file panel's Diff/History tabs (and the CLI's `/rewind`) share one data source. -// -// The desktop runs @deepcode/core's `runAgent` IN THE RENDERER, which (by design) -// has no node:fs and so passes no SessionManager — meaning core's own snapshot -// capture (packages/core/src/agent.ts) never fires for desktop sessions. We -// therefore mirror it here on the Rust side: tool_write / tool_edit call -// `capture_file_snapshot` for the pre- and post-mutation states. -// -// On-disk layout MATCHES core (packages/core/src/sessions/{storage,snapshots}.ts) -// so the two interoperate: -// ~/.deepcode/sessions//snapshots/ -// manifest.jsonl — one JSON Snapshot per line -// --.blob — the captured file bytes -// -// Each manifest line is the core `Snapshot` shape: { filePath, capturedAt, -// reason, hash, size, seq, blobPath, kind } plus a `capturedAtMs` convenience -// field (core ignores unknown keys) so the renderer needn't parse ISO strings. +// Read-only projection of app-server-owned file snapshots for the desktop +// Diff/History panel. Snapshot capture and every workspace mutation happen in +// @deepcode/core behind the versioned app-server protocol. use serde::Serialize; -use sha2::{Digest, Sha256}; use std::path::{Path, PathBuf}; /// `~/.deepcode/sessions//snapshots` — the per-session snapshot directory. -pub fn snapshots_dir(home: &Path, session_id: &str) -> PathBuf { +fn snapshots_dir(home: &Path, session_id: &str) -> PathBuf { home.join(".deepcode") .join("sessions") .join(session_id) .join("snapshots") } -/// Next sequence number for a session = count of existing manifest lines. -/// Snapshots are append-only and the desktop captures them one tool-call at a -/// time, so a line count is a sufficient monotonic counter (mirrors core's -/// per-session `snapshotSeq`). -pub fn next_seq(dir: &Path) -> u64 { - let manifest = dir.join("manifest.jsonl"); - match std::fs::read_to_string(&manifest) { - Ok(t) => t.lines().filter(|l| !l.trim().is_empty()).count() as u64, - Err(_) => 0, - } -} - -/// Milliseconds since the Unix epoch (0 if the clock is before 1970). -pub fn now_ms() -> u128 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis()) - .unwrap_or(0) -} - -/// Capture one file snapshot: write the blob and append a manifest line. -/// Best-effort by contract — callers ignore the error so a snapshot hiccup never -/// fails the user's edit. `content` is the exact file bytes for this revision. -pub fn capture_file_snapshot( - home: &Path, - session_id: &str, - file_path: &str, - content: &[u8], - reason: &str, - seq: u64, - captured_ms: u128, -) -> std::io::Result<()> { - let dir = snapshots_dir(home, session_id); - std::fs::create_dir_all(&dir)?; - - let mut hasher = Sha256::new(); - hasher.update(content); - // core: sha256 hex truncated to 16 chars == the first 8 bytes. - let hash16: String = hasher - .finalize() - .iter() - .take(8) - .map(|b| format!("{b:02x}")) - .collect(); - - let blob_name = format!("{:05}-{}-{}.blob", seq, fmt_blob_ts(captured_ms), hash16); - let blob_path = dir.join(&blob_name); - std::fs::write(&blob_path, content)?; - - let entry = serde_json::json!({ - "filePath": file_path, - "capturedAt": fmt_iso(captured_ms), - "capturedAtMs": captured_ms as u64, - "reason": reason, - "hash": hash16, - "size": content.len(), - "seq": seq, - "blobPath": blob_path.to_string_lossy(), - "kind": "file", - }); - - use std::io::Write; - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(dir.join("manifest.jsonl"))?; - writeln!(f, "{entry}") -} - // ── session_snapshots command ─────────────────────────────────────────────── /// One snapshot returned to the renderer for a single file. `content` is the @@ -160,9 +75,18 @@ pub fn list_file_snapshots(dir: &Path, file_path: &str) -> Result) -> false } -// ── time formatting (no chrono dep) ───────────────────────────────────────── - -/// (year, month, day) from days-since-Unix-epoch. Howard Hinnant's -/// civil_from_days — same algorithm as commands.rs::format_date. -fn civil_from_days(days: i64) -> (i64, u64, u64) { - let z = days + 719_468; - let era = if z >= 0 { z } else { z - 146_096 } / 146_097; - let doe = (z - era * 146_097) as u64; // [0, 146096] - let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] - let y = yoe as i64 + era * 400; - let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] - let mp = (5 * doy + 2) / 153; // [0, 11] - let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31] - let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12] - let y = if m <= 2 { y + 1 } else { y }; - (y, m, d) -} - -/// ISO-8601 UTC with millis, e.g. "2026-06-04T12:30:45.123Z" (mirrors JS -/// `new Date(ms).toISOString()`). -fn fmt_iso(ms: u128) -> String { - let total_secs = (ms / 1000) as i64; - let millis = (ms % 1000) as u64; - let days = total_secs.div_euclid(86_400); - let tod = total_secs.rem_euclid(86_400) as u64; - let (y, mo, d) = civil_from_days(days); - let (h, mi, s) = (tod / 3600, (tod % 3600) / 60, tod % 60); - format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.{millis:03}Z") -} - -/// Compact timestamp for blob filenames: core's -/// `toISOString().replace(/[-:.]/g,'').slice(0,15)` → "YYYYMMDDtHHMMSS". -fn fmt_blob_ts(ms: u128) -> String { - fmt_iso(ms) - .chars() - .filter(|c| *c != '-' && *c != ':' && *c != '.') - .take(15) - .collect() +/// Parse core's fixed-width UTC ISO timestamp without adding a date-time +/// dependency to the desktop binary. +fn parse_iso_millis(value: &str) -> Option { + if value.len() != 24 + || !value.is_ascii() + || &value[4..5] != "-" + || &value[7..8] != "-" + || &value[10..11] != "T" + || &value[13..14] != ":" + || &value[16..17] != ":" + || &value[19..20] != "." + || &value[23..24] != "Z" + { + return None; + } + let year = value[0..4].parse::().ok()?; + let month = value[5..7].parse::().ok()?; + let day = value[8..10].parse::().ok()?; + let hour = value[11..13].parse::().ok()?; + let minute = value[14..16].parse::().ok()?; + let second = value[17..19].parse::().ok()?; + let millis = value[20..23].parse::().ok()?; + if !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || !(0..=23).contains(&hour) + || !(0..=59).contains(&minute) + || !(0..=59).contains(&second) + { + return None; + } + let adjusted_year = year - i64::from(month <= 2); + let era = adjusted_year.div_euclid(400); + let year_of_era = adjusted_year - era * 400; + let shifted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + let days = era * 146_097 + day_of_era - 719_468; + let total = (((days * 24 + hour) * 60 + minute) * 60 + second) * 1000 + millis; + u64::try_from(total).ok() } #[cfg(test)] @@ -243,19 +168,13 @@ mod tests { } #[test] - fn fmt_iso_known_values() { - assert_eq!(fmt_iso(0), "1970-01-01T00:00:00.000Z"); - assert_eq!(fmt_iso(86_400_000), "1970-01-02T00:00:00.000Z"); - // 2023-11-14T22:13:20.123Z - assert_eq!(fmt_iso(1_700_000_000_123), "2023-11-14T22:13:20.123Z"); - } - - #[test] - fn fmt_blob_ts_is_15_chars_with_t_separator() { - let ts = fmt_blob_ts(0); - assert_eq!(ts, "19700101T000000"); - assert_eq!(ts.chars().count(), 15); - assert_eq!(ts.as_bytes()[8], b'T'); + fn parses_core_iso_timestamp() { + assert_eq!(parse_iso_millis("1970-01-01T00:00:00.000Z"), Some(0)); + assert_eq!( + parse_iso_millis("2023-11-14T22:13:20.123Z"), + Some(1_700_000_000_123) + ); + assert_eq!(parse_iso_millis("not-a-timestamp"), None); } #[test] @@ -277,37 +196,37 @@ mod tests { } #[test] - fn capture_then_list_roundtrips_and_filters() { - let home = std::env::temp_dir().join(format!("dc-snap-{}", std::process::id())); - let sid = "2026-06-04-test01"; + fn list_reads_core_manifest_and_filters() { + let root = std::env::temp_dir().join(format!("dc-snap-{}", std::process::id())); let file = "/tmp/example/app.ts"; - let _ = std::fs::remove_dir_all(&home); - - // Two edits → 4 snapshots (pre/post each), distinct ms so ordering holds. - let dir = snapshots_dir(&home, sid); + let other = "/tmp/other.ts"; + let _ = std::fs::remove_dir_all(&root); + let dir = root.join("snapshots"); std::fs::create_dir_all(&dir).unwrap(); - let base0 = next_seq(&dir); - capture_file_snapshot(&home, sid, file, b"v0\n", "pre-Edit", base0, 1000).unwrap(); - capture_file_snapshot(&home, sid, file, b"v1\n", "post-Edit", base0 + 1, 1001).unwrap(); - let base1 = next_seq(&dir); - assert_eq!(base1, 2, "seq advances with manifest lines"); - capture_file_snapshot(&home, sid, file, b"v1\n", "pre-Edit", base1, 2000).unwrap(); - capture_file_snapshot(&home, sid, file, b"v2\n", "post-Edit", base1 + 1, 2001).unwrap(); - - // A snapshot for a DIFFERENT file must be filtered out. - capture_file_snapshot(&home, sid, "/tmp/other.ts", b"z\n", "pre-Write", 99, 3000).unwrap(); + let first_blob = dir.join("first.blob"); + let second_blob = dir.join("second.blob"); + std::fs::write(&first_blob, "v0\n").unwrap(); + std::fs::write(&second_blob, "v1\n").unwrap(); + let manifest = [ + serde_json::json!({"filePath": file, "capturedAt": "2023-11-14T22:13:20.123Z", "reason": "pre-Edit", "hash": "a", "seq": 2, "blobPath": first_blob}), + serde_json::json!({"filePath": file, "capturedAtMs": 1_700_000_000_124_u64, "reason": "post-Edit", "hash": "b", "seq": 3, "blobPath": second_blob}), + serde_json::json!({"filePath": other, "capturedAtMs": 1_u64, "reason": "pre-Write", "hash": "c", "seq": 1, "blobPath": ""}), + ] + .into_iter() + .map(|row| row.to_string()) + .collect::>() + .join("\n"); + std::fs::write(dir.join("manifest.jsonl"), manifest).unwrap(); let rows = list_file_snapshots(&dir, file).unwrap(); - let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::remove_dir_all(&root); - assert_eq!(rows.len(), 4, "only the 4 snapshots for `file`"); - assert_eq!(rows[0].seq, 0); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].seq, 2); assert_eq!(rows[0].content, "v0\n"); assert_eq!(rows[0].reason, "pre-Edit"); - assert_eq!(rows[0].captured_at_ms, 1000); - assert_eq!(rows[3].content, "v2\n"); - // ascending by seq - assert!(rows.windows(2).all(|w| w[0].seq < w[1].seq)); + assert_eq!(rows[0].captured_at_ms, 1_700_000_000_123); + assert_eq!(rows[1].captured_at_ms, 1_700_000_000_124); } #[test] diff --git a/apps/desktop/src-tauri/src/tools.rs b/apps/desktop/src-tauri/src/tools.rs deleted file mode 100644 index 3f43c07..0000000 --- a/apps/desktop/src-tauri/src/tools.rs +++ /dev/null @@ -1,523 +0,0 @@ -// Tool IO primitives exposed to the renderer. -// The renderer runs @deepcode/core's `runAgent` directly; its tools call -// these Tauri commands for actual fs / subprocess work (the webview can't -// do node:fs / node:child_process itself). - -use crate::snapshots; -use serde::{Deserialize, Serialize}; -use std::path::Path; -use std::process::Stdio; -use tokio::io::AsyncReadExt; -use tokio::process::Command; - -// ────────────────────────────────────────────────────────────────────────── -// Snapshot capture -// ────────────────────────────────────────────────────────────────────────── -// Edit/Write record a pre- and post-mutation snapshot so the desktop file -// panel's Diff/History tabs (and `/rewind`) have data — mirroring core's -// agent.ts, which never runs for desktop sessions (no SessionManager in the -// renderer). Best-effort: capture failures are logged and ignored so a -// snapshot hiccup never fails the user's edit. - -/// Capture the pre + post pair for one file mutation under the user's home dir. -fn capture_pair(session_id: &str, file_path: &str, pre: &[u8], post: &[u8], tool: &str) { - let Some(home) = dirs::home_dir() else { - return; - }; - capture_pair_in(&home, session_id, file_path, pre, post, tool); -} - -/// home-parameterized body of `capture_pair` (testable without the real home). -/// `pre`/`post` are the file bytes before/after the change. The post snapshot is -/// stamped 1ms after the pre so the two never collide on a millisecond timeline -/// (the renderer keys history entries by timestamp). -fn capture_pair_in( - home: &Path, - session_id: &str, - file_path: &str, - pre: &[u8], - post: &[u8], - tool: &str, -) { - let dir = snapshots::snapshots_dir(home, session_id); - let base = snapshots::next_seq(&dir); - let t = snapshots::now_ms(); - if let Err(e) = snapshots::capture_file_snapshot( - home, - session_id, - file_path, - pre, - &format!("pre-{tool}"), - base, - t, - ) { - eprintln!("snapshot pre-{tool} {file_path}: {e}"); - } - if let Err(e) = snapshots::capture_file_snapshot( - home, - session_id, - file_path, - post, - &format!("post-{tool}"), - base + 1, - t + 1, - ) { - eprintln!("snapshot post-{tool} {file_path}: {e}"); - } -} - -// ────────────────────────────────────────────────────────────────────────── -// Read -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct ReadOk { - pub content: String, - pub lines_total: usize, - pub lines_shown: usize, - pub offset: usize, -} - -#[tauri::command] -pub async fn tool_read( - file_path: String, - offset: Option, - limit: Option, -) -> Result { - let raw = tokio::fs::read_to_string(&file_path) - .await - .map_err(|e| format!("read {}: {}", file_path, e))?; - let lines: Vec<&str> = raw.split('\n').collect(); - let offset = offset.unwrap_or(1).max(1); - let limit = limit.unwrap_or(2000).max(1); - let start = offset - 1; - let end = (start + limit).min(lines.len()); - let slice = &lines[start..end]; - - let numbered: Vec = slice - .iter() - .enumerate() - .map(|(i, line)| { - let n = offset + i; - let truncated = if line.len() > 2000 { - format!("{}... [truncated]", &line[..2000]) - } else { - line.to_string() - }; - format!("{:>6}\t{}", n, truncated) - }) - .collect(); - let mut content = numbered.join("\n"); - let shown = slice.len(); - let total = lines.len(); - if shown < total.saturating_sub(start) { - content.push_str(&format!( - "\n\n[Showing lines {}-{} of {}. Use offset/limit to see more.]", - offset, - offset + shown - 1, - total - )); - } - Ok(ReadOk { - content, - lines_total: total, - lines_shown: shown, - offset, - }) -} - -// ────────────────────────────────────────────────────────────────────────── -// Write -// ────────────────────────────────────────────────────────────────────────── - -#[tauri::command] -pub async fn tool_write( - file_path: String, - content: String, - session_id: Option, -) -> Result<(), String> { - // Pre-state: the existing file bytes (empty when the file is new) — read - // before the overwrite so the post-Write diff has a baseline. - let pre = tokio::fs::read(&file_path).await.unwrap_or_default(); - if let Some(parent) = Path::new(&file_path).parent() { - if !parent.as_os_str().is_empty() { - tokio::fs::create_dir_all(parent) - .await - .map_err(|e| format!("mkdir {}: {}", parent.display(), e))?; - } - } - tokio::fs::write(&file_path, &content) - .await - .map_err(|e| format!("write {}: {}", file_path, e))?; - if let Some(sid) = session_id.as_deref() { - capture_pair(sid, &file_path, &pre, content.as_bytes(), "Write"); - } - Ok(()) -} - -// ────────────────────────────────────────────────────────────────────────── -// Edit -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -pub struct EditInput { - pub file_path: String, - pub old_string: String, - pub new_string: String, - pub replace_all: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct EditOk { - pub replaced: usize, - pub diff_preview: String, -} - -#[tauri::command] -pub async fn tool_edit(input: EditInput, session_id: Option) -> Result { - let raw = tokio::fs::read_to_string(&input.file_path) - .await - .map_err(|e| format!("read {}: {}", input.file_path, e))?; - let replace_all = input.replace_all.unwrap_or(false); - let (new_content, count) = if replace_all { - let count = raw.matches(&input.old_string).count(); - (raw.replace(&input.old_string, &input.new_string), count) - } else { - // Uniqueness check (matching the CLI's Edit tool behavior) - let count = raw.matches(&input.old_string).count(); - if count == 0 { - return Err("old_string not found in file".into()); - } - if count > 1 { - return Err(format!( - "old_string is not unique (found {count} occurrences). Use replace_all=true or provide more context." - )); - } - (raw.replacen(&input.old_string, &input.new_string, 1), 1) - }; - tokio::fs::write(&input.file_path, &new_content) - .await - .map_err(|e| format!("write {}: {}", input.file_path, e))?; - if let Some(sid) = session_id.as_deref() { - capture_pair( - sid, - &input.file_path, - raw.as_bytes(), - new_content.as_bytes(), - "Edit", - ); - } - let diff_preview = format!( - "- {}\n+ {}", - input.old_string.lines().next().unwrap_or(""), - input.new_string.lines().next().unwrap_or("") - ); - Ok(EditOk { - replaced: count, - diff_preview, - }) -} - -// ────────────────────────────────────────────────────────────────────────── -// Bash -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -pub struct BashInput { - pub command: String, - pub cwd: Option, - pub timeout_ms: Option, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -pub struct BashOk { - pub stdout: String, - pub stderr: String, - pub exit_code: i32, - pub timed_out: bool, -} - -#[tauri::command] -pub async fn tool_bash(input: BashInput) -> 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); - if let Some(cwd) = input.cwd.as_ref() { - cmd.current_dir(cwd); - } - cmd.stdout(Stdio::piped()).stderr(Stdio::piped()); - - let mut child = cmd.spawn().map_err(|e| format!("spawn: {e}"))?; - let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; - let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; - - // Read both streams concurrently - let stdout_task = tokio::spawn(async move { - let mut s = String::new(); - let _ = stdout_pipe.read_to_string(&mut s).await; - s - }); - let stderr_task = tokio::spawn(async move { - let mut s = String::new(); - let _ = stderr_pipe.read_to_string(&mut s).await; - 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; - 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, - }); - } - }; - let stdout = stdout_task.await.unwrap_or_default(); - let stderr = stderr_task.await.unwrap_or_default(); - Ok(BashOk { - stdout, - stderr, - exit_code: exit_status.code().unwrap_or(-1), - timed_out, - }) -} - -// ────────────────────────────────────────────────────────────────────────── -// Glob (filesystem pattern match) -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Serialize)] -pub struct GlobOk { - pub files: Vec, - pub truncated: bool, -} - -#[tauri::command] -pub async fn tool_glob(pattern: String, cwd: Option) -> Result { - // Walk + filter using the `walkdir` style approach via shell `find -path`. - // We don't depend on the `globwalk` crate to keep deps slim; shell out instead. - let cwd_path = cwd.unwrap_or_else(|| ".".into()); - // For safety, only run if pattern doesn't contain a quote injection - if pattern.contains('\'') || pattern.contains('`') { - return Err("unsafe pattern (contains quote)".into()); - } - let script = format!( - "find {} -type f -path '{}/{}' 2>/dev/null | head -1000", - shell_escape(&cwd_path), - shell_escape(&cwd_path), - pattern - ); - let output = Command::new("/bin/sh") - .arg("-c") - .arg(&script) - .output() - .await - .map_err(|e| format!("spawn find: {e}"))?; - let stdout = String::from_utf8_lossy(&output.stdout); - let files: Vec = stdout.lines().map(|s| s.to_string()).collect(); - let truncated = files.len() >= 1000; - Ok(GlobOk { files, truncated }) -} - -fn shell_escape(s: &str) -> String { - // Minimal escape — wrap in single quotes, escape any existing single quotes - format!("'{}'", s.replace('\'', "'\\''")) -} - -// ────────────────────────────────────────────────────────────────────────── -// Grep (ripgrep-like; uses /usr/bin/grep) -// ────────────────────────────────────────────────────────────────────────── - -#[derive(Deserialize)] -#[serde(rename_all = "snake_case")] -pub struct GrepInput { - pub pattern: String, - pub path: Option, - pub include: Option, - pub case_insensitive: Option, -} - -#[derive(Serialize)] -pub struct GrepOk { - pub matches: Vec, - pub truncated: bool, -} - -#[derive(Serialize)] -pub struct GrepMatch { - pub file: String, - pub line: usize, - pub text: String, -} - -#[tauri::command] -pub async fn tool_grep(input: GrepInput) -> Result { - let path = input.path.unwrap_or_else(|| ".".into()); - let mut cmd = Command::new("/usr/bin/grep"); - cmd.arg("-rn"); - if input.case_insensitive.unwrap_or(false) { - cmd.arg("-i"); - } - if let Some(include) = input.include.as_ref() { - cmd.arg(format!("--include={include}")); - } - cmd.arg("--").arg(&input.pattern).arg(&path); - let output = cmd.output().await.map_err(|e| format!("spawn grep: {e}"))?; - // grep returns 1 if no matches — that's not an error for us - if !output.status.success() && output.status.code() != Some(1) { - return Err(format!( - "grep failed ({}): {}", - output.status, - String::from_utf8_lossy(&output.stderr) - )); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let mut matches = Vec::new(); - for line in stdout.lines().take(500) { - // format: :: - let mut parts = line.splitn(3, ':'); - let file = parts.next().unwrap_or("").to_string(); - let lineno: usize = parts.next().unwrap_or("0").parse().unwrap_or(0); - let text = parts.next().unwrap_or("").to_string(); - matches.push(GrepMatch { - file, - line: lineno, - text, - }); - } - let truncated = matches.len() == 500; - Ok(GrepOk { matches, truncated }) -} - -// ── Serde casing contract ────────────────────────────────────────────── -// Regression guard for HANDOFF §8a: Tauri's serde does NOT auto-convert case -// between Rust and JS. Every multi-word *output* field must serialize as -// camelCase, because the renderer reads e.g. `r.exitCode` / `r.linesTotal`. -// This bug shipped twice (Read line counts, Bash exit-code "error" badge); these -// tests fail loudly if a `#[serde(rename_all = "camelCase")]` is ever dropped. -#[cfg(test)] -mod casing_tests { - use super::*; - - fn keys(v: &serde_json::Value) -> Vec { - v.as_object().unwrap().keys().cloned().collect() - } - - #[test] - fn read_ok_serializes_camel_case() { - let v = serde_json::to_value(ReadOk { - content: String::new(), - lines_total: 10, - lines_shown: 5, - offset: 0, - }) - .unwrap(); - let k = keys(&v); - assert!(k.contains(&"linesTotal".to_string()), "got {k:?}"); - assert!(k.contains(&"linesShown".to_string()), "got {k:?}"); - assert!( - !k.contains(&"lines_total".to_string()), - "snake_case leaked: {k:?}" - ); - } - - #[test] - fn edit_ok_serializes_camel_case() { - let v = serde_json::to_value(EditOk { - replaced: 1, - diff_preview: String::new(), - }) - .unwrap(); - let k = keys(&v); - assert!(k.contains(&"diffPreview".to_string()), "got {k:?}"); - assert!( - !k.contains(&"diff_preview".to_string()), - "snake_case leaked: {k:?}" - ); - } - - #[test] - fn bash_ok_serializes_camel_case() { - let v = serde_json::to_value(BashOk { - stdout: String::new(), - stderr: String::new(), - exit_code: 0, - timed_out: 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(&"exit_code".to_string()), - "snake_case leaked: {k:?}" - ); - } -} - -// ── snapshot capture path ─────────────────────────────────────────────── -// End-to-end coverage of the Edit/Write → manifest path (against a real temp -// fs) via the home-injectable `capture_pair_in`. -#[cfg(test)] -mod snapshot_capture_tests { - use super::*; - - #[test] - fn capture_pair_writes_pre_then_post_with_distinct_ms() { - let home = std::env::temp_dir().join(format!("dc-cap-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&home); - let sid = "2026-06-04-cap01"; - let file = "/tmp/example/app.ts"; - - capture_pair_in(&home, sid, file, b"old\n", b"new\n", "Edit"); - - let dir = snapshots::snapshots_dir(&home, sid); - let rows = snapshots::list_file_snapshots(&dir, file).unwrap(); - let _ = std::fs::remove_dir_all(&home); - - assert_eq!(rows.len(), 2); - assert_eq!(rows[0].reason, "pre-Edit"); - assert_eq!(rows[0].content, "old\n"); - assert_eq!(rows[0].seq, 0); - assert_eq!(rows[1].reason, "post-Edit"); - assert_eq!(rows[1].content, "new\n"); - assert_eq!(rows[1].seq, 1); - // Distinct timestamps so the renderer's history keys never collide. - assert_eq!(rows[1].captured_at_ms, rows[0].captured_at_ms + 1); - } - - #[test] - fn capture_pair_appends_across_calls_with_monotonic_seq() { - let home = std::env::temp_dir().join(format!("dc-cap2-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&home); - let sid = "2026-06-04-cap02"; - let file = "/tmp/x.ts"; - - capture_pair_in(&home, sid, file, b"a", b"b", "Write"); - capture_pair_in(&home, sid, file, b"b", b"c", "Edit"); - - let dir = snapshots::snapshots_dir(&home, sid); - let rows = snapshots::list_file_snapshots(&dir, file).unwrap(); - let _ = std::fs::remove_dir_all(&home); - - assert_eq!( - rows.iter().map(|r| r.seq).collect::>(), - vec![0, 1, 2, 3] - ); - assert_eq!(rows[0].reason, "pre-Write"); - assert_eq!(rows[3].reason, "post-Edit"); - assert_eq!(rows[3].content, "c"); - } -} diff --git a/apps/desktop/src-tauri/src/voice.rs b/apps/desktop/src-tauri/src/voice.rs index d05474b..82d7e53 100644 --- a/apps/desktop/src-tauri/src/voice.rs +++ b/apps/desktop/src-tauri/src/voice.rs @@ -246,7 +246,7 @@ pub async fn voice_start(state: tauri::State<'_, VoiceState>) -> Result<(), Stri let wav = std::env::temp_dir().join(format!( "deepcode-voice-{}-{}.wav", std::process::id(), - crate::snapshots::now_ms() + unix_time_millis() )); // Replace any orphaned prior recording. @@ -278,6 +278,13 @@ pub async fn voice_start(state: tauri::State<'_, VoiceState>) -> Result<(), Stri Ok(()) } +fn unix_time_millis() -> u128 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis()) + .unwrap_or(0) +} + /// Stop recording, transcribe the clip, delete the audio, return the text. #[tauri::command] pub async fn voice_stop(state: tauri::State<'_, VoiceState>) -> Result { diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index fc38f3a..0a1f7b2 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": [ @@ -25,15 +25,17 @@ } ], "security": { - "csp": "default-src 'self' tauri:; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: tauri: http://asset.localhost https://asset.localhost; font-src 'self' data:; connect-src 'self' tauri: ipc: ws://localhost:5173 https://api.deepseek.com" + "csp": "default-src 'self' tauri:; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: tauri: http://asset.localhost https://asset.localhost; font-src 'self' data:; connect-src 'self' tauri: ipc: ws://localhost:5173" } }, "bundle": { "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..8cea3ea 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,22 +301,12 @@ function renderScreen( setScreen: (s: ScreenName) => void, projectPath: string, onTurnComplete: () => void, + onSessionStarted: (sessionId: string) => void, onInspector: (patch: Partial) => void, initialMessages?: Msg[], onOpenFile?: (path: string) => void, ): JSX.Element { switch (screen) { - case 'chat': - // 'chat' folded into 'repl' — the new shell has only the REPL surface. - return ( - - ); case 'sessions': return setScreen('repl')} onNew={() => setScreen('repl')} />; // Settings-family screens share the Settings shell's left nav so they're @@ -327,6 +328,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..79220e2 --- /dev/null +++ b/apps/desktop/src/lib/protocol-agent.test.ts @@ -0,0 +1,306 @@ +import type { + ConfigDiagnosticsResult, + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ReviewFindingPayload, + ThreadSnapshot, + TurnSnapshot, + WorkspaceDiffResult, +} 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, + reviewActions: true, + configDiagnostics: true, + diagnosticExport: true, + workspaceDiff: 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 === 'review/apply') return turn as T; + if (method === 'review/revert') return turn as T; + if (method === 'turn/interrupt') return { interrupted: true } as T; + if (method === 'config/diagnostics') return diagnostics as T; + if (method === 'workspace/diff') return workspaceDiff as T; + return { accepted: true } as T; + } +} + +const diagnostics: ConfigDiagnosticsResult = { + cwd: '/workspace', + trustStatus: 'untrusted', + layers: [], + provenance: {}, + gated: ['permissions'], + issues: [], +}; + +const workspaceDiff: WorkspaceDiffResult = { + repository: true, + base: 'HEAD', + files: [], + truncated: false, +}; + +const finding: ReviewFindingPayload = { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, +}; + +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('reads workspace diff only through an adopted canonical thread', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await expect(agent.diff()).rejects.toThrow('No active workspace thread'); + await agent.resume(thread.id); + await expect(agent.diff()).resolves.toEqual(workspaceDiff); + expect(transport.requests.at(-1)).toEqual({ + method: 'workspace/diff', + params: { threadId: thread.id }, + }); + }); + + it('applies a finding as a normal agent turn', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await agent.resume(thread.id); + await agent.applyFinding(finding); + expect(transport.requests.at(-1)).toEqual({ + method: 'review/apply', + params: { threadId: thread.id, findingIds: ['finding-1'] }, + }); + }); + + it('reverts a review action through the canonical conflict-safe turn', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + await agent.resume(thread.id); + await agent.revertAction('turn-apply'); + expect(transport.requests.at(-1)).toEqual({ + method: 'review/revert', + params: { threadId: thread.id, actionId: 'turn-apply' }, + }); + }); + + it('reads value-free diagnostics from the shared app-server', async () => { + const transport = new FakeTransport(); + const agent = new DesktopProtocolAgent(transport, () => undefined); + + await expect(agent.diagnostics('/workspace')).resolves.toEqual(diagnostics); + expect(transport.requests).toEqual([ + { method: 'config/diagnostics', params: { cwd: '/workspace' } }, + ]); + }); + + 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('projects durable review findings for line-addressable UI rendering', 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: 'review' }); + transport.handler?.({ + type: 'item.completed', + threadId: thread.id, + turnId: turn.id, + item: { + id: 'item-1', + type: 'review_finding', + completedAt: '2026-08-01T00:00:02.000Z', + payload: { + findingId: 'finding-1', + title: 'Null crash', + body: 'This branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'review_finding', path: 'src/a.ts', startLine: 4 }), + ); + transport.handler?.({ + type: 'item.completed', + threadId: thread.id, + turnId: turn.id, + item: { + id: 'item-2', + type: 'review_action', + completedAt: '2026-08-01T00:00:03.000Z', + payload: { actionId: turn.id, kind: 'apply', findingIds: ['finding-1'] }, + }, + }); + expect(events).toContainEqual( + expect.objectContaining({ type: 'review_action', findingIds: ['finding-1'] }), + ); + }); + + 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..1122580 --- /dev/null +++ b/apps/desktop/src/lib/protocol-agent.ts @@ -0,0 +1,391 @@ +import { + type ConfigDiagnosticsResult, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ReviewFindingPayload, + type ThreadSnapshot, + type TurnSnapshot, + type WorkspaceDiffResult, +} 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; + } + + async diagnostics(cwd: string): Promise { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.configDiagnostics) { + throw new Error('The app-server does not support configuration diagnostics'); + } + return this.transport.request('config/diagnostics', { cwd }); + } + + async diff(): Promise { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.workspaceDiff) { + throw new Error('The app-server does not support workspace diff'); + } + if (!this.threadId) throw new Error('No active workspace thread'); + return this.transport.request('workspace/diff', { threadId: this.threadId }); + } + + applyFinding(finding: ReviewFindingPayload) { + return this.applyFindings([finding]); + } + + async applyFindings(findings: ReviewFindingPayload[]) { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const threadId = this.threadId; + if (!threadId) throw new Error('No active workspace thread'); + const turn = await this.transport.request('review/apply', { + threadId, + findingIds: findings.map((finding) => finding.findingId), + }); + this.activeTurns.set(turn.id, threadId); + setTimeout(() => this.flushTurn(turn.id), 0); + return { turnId: turn.id, threadId }; + } + + async revertAction(actionId: string) { + const initialized = await this.transport.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const threadId = this.threadId; + if (!threadId) throw new Error('No active workspace thread'); + const turn = await this.transport.request('review/revert', { + threadId, + actionId, + }); + this.activeTurns.set(turn.id, threadId); + setTimeout(() => this.flushTurn(turn.id), 0); + return { turnId: turn.id, threadId }; + } + + 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 'item.completed': + if (event.item.type === 'review_finding') { + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'review_finding', + ...event.item.payload, + }); + } else if (event.item.type === 'review_action') { + this.emit({ + kind: 'event', + turnId: event.turnId, + type: 'review_action', + ...event.item.payload, + }); + } + 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 getConfigDiagnostics(cwd: string) { + return defaultAgent.diagnostics(cwd); +} + +export function getWorkspaceDiff() { + return defaultAgent.diff(); +} + +export function applyReviewFinding(finding: ReviewFindingPayload) { + return defaultAgent.applyFinding(finding); +} + +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..ba2d7d5 --- /dev/null +++ b/apps/desktop/src/lib/protocol-client.test.ts @@ -0,0 +1,114 @@ +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, + reviewActions: 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..f4c969d 100644 --- a/apps/desktop/src/lib/tauri-api.test.ts +++ b/apps/desktop/src/lib/tauri-api.test.ts @@ -3,23 +3,25 @@ // These lock the command names and the snake_case↔camelCase mapping that the // Rust #[tauri::command] handlers expect. HANDOFF §8a: casing mismatches across // this boundary shipped real bugs twice. The Rust side is guarded by -// src-tauri/src/tools.rs casing_tests; this guards the TS side. +// src-tauri/src/file_preview.rs tests; this guards the TS side. // // `invoke` is mocked so no Tauri runtime is needed. 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/use-file-panel.ts b/apps/desktop/src/lib/use-file-panel.ts index ae1f7e2..f6d8c09 100644 --- a/apps/desktop/src/lib/use-file-panel.ts +++ b/apps/desktop/src/lib/use-file-panel.ts @@ -4,8 +4,9 @@ // split/inline toggle is owned by App (it shares the chord with the inspector // toggle and resolves contextually). // -// Diff/History come from session snapshots captured on the Rust side for every -// Edit/Write (see src-tauri/src/snapshots.rs). On open() we fetch a file's +// Diff/History come from session snapshots captured by the app-server for every +// Edit/Write. Rust exposes only a read-only projection (src-tauri/src/snapshots.rs). +// On open() we fetch a file's // snapshots and derive: the History timeline, and a Diff of the current file // vs the session baseline (its oldest snapshot). Selecting a History entry // recomputes the Diff against that revision. diff --git a/apps/desktop/src/lib/window-shim.ts b/apps/desktop/src/lib/window-shim.ts index 0d4e206..64e92df 100644 --- a/apps/desktop/src/lib/window-shim.ts +++ b/apps/desktop/src/lib/window-shim.ts @@ -2,25 +2,31 @@ // 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, + getConfigDiagnostics, + 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 +40,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 +49,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 }); @@ -67,6 +60,9 @@ export function installTauriShim(): void { load() { return loadSettingsFile(); }, + diagnostics({ cwd }) { + return getConfigDiagnostics(cwd); + }, }, sessions: { async list() { @@ -79,15 +75,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 +142,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..9721538 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,196 @@ 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, + reviewActions: true, + configDiagnostics: true, + }, + }); + break; + case 'config/diagnostics': + await respond({ + cwd: String(request.params.cwd), + trustStatus: 'untrusted', + layers: [ + { + layer: 'project', + path: `${String(request.params.cwd)}/.deepcode/settings.json`, + present: true, + trusted: false, + }, + ], + provenance: {}, + gated: ['permissions'], + issues: [ + { + severity: 'warning', + code: 'untrusted_setting_gated', + message: 'Ignored project setting /permissions until this directory is trusted', + pointer: '/permissions', + }, + ], + }); + 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 +374,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/About.tsx b/apps/desktop/src/screens/About.tsx index 20d5199..da43975 100644 --- a/apps/desktop/src/screens/About.tsx +++ b/apps/desktop/src/screens/About.tsx @@ -2,6 +2,7 @@ // Brand mark + version + diagnostics + docs links. import { useEffect, useState } from 'react'; +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; import { BrandMark } from '../components/BrandMark.js'; import { Card, Row, Screen, SectionTitle } from '../components/Screen.js'; import { loadProjectPath } from '../lib/project.js'; @@ -12,6 +13,8 @@ interface Diag { hasCreds: boolean; baseURL?: string; projectPath?: string; + config?: ConfigDiagnosticsResult; + configError?: string; } export function AboutScreen(): JSX.Element { @@ -24,11 +27,22 @@ export function AboutScreen(): JSX.Element { window.deepcode.creds.load(), loadProjectPath(), ]); + let config: ConfigDiagnosticsResult | undefined; + let configError: string | undefined; + if (projectPath) { + try { + config = await window.deepcode.settings.diagnostics({ cwd: projectPath }); + } catch (error) { + configError = (error as Error).message ?? String(error); + } + } setDiag({ version, hasCreds: creds.hasKey, baseURL: creds.baseURL, projectPath, + config, + configError, }); })(); }, []); @@ -97,6 +111,39 @@ export function AboutScreen(): JSX.Element { {diag.baseURL ?? 'https://api.deepseek.com/v1'} + + {diag.config ? ( + + {diag.config.trustStatus} + + ) : ( + + {diag.configError ?? 'choose a project to inspect'} + + )} + + {diag.config && ( + <> + + {diag.config.layers.filter((layer) => layer.present).length} loaded ·{' '} + {Object.keys(diag.config.provenance).length} effective keys + + + {diag.config.gated.length ? diag.config.gated.join(', ') : 'none'} + + + + {diag.config.issues.length} + + + + )} Paths diff --git a/apps/desktop/src/screens/Repl.tsx b/apps/desktop/src/screens/Repl.tsx index bf25d70..6015d50 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 @@ -79,6 +81,7 @@ const MAX_RECENT_FILES = 8; type Effort = 'low' | 'medium' | 'high' | 'xhigh' | 'max'; const EFFORTS: Effort[] = ['low', 'medium', 'high', 'xhigh', 'max']; +type AgentMode = 'default' | 'acceptEdits' | 'plan' | 'auto' | 'dontAsk' | 'bypassPermissions'; const EFFORT_OPTIONS: DropdownOption[] = [ // `meta` is the per-turn output-token budget (maxTokens) the effort maps to in @@ -131,9 +134,7 @@ const MODEL_OPTIONS: DropdownOption<'deepseek-chat' | 'deepseek-reasoner'>[] = [ }, ]; -const MODE_OPTIONS: DropdownOption< - 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions' ->[] = [ +const MODE_OPTIONS: DropdownOption[] = [ { value: 'default', label: 'Default', @@ -152,6 +153,12 @@ const MODE_OPTIONS: DropdownOption< meta: '◐', description: 'Read-only — write tools blocked. Use for exploring.', }, + { + value: 'auto', + label: 'Auto', + meta: '◈', + description: 'Classify each tool call and apply the configured automatic policy.', + }, { value: 'dontAsk', label: "Don't ask", @@ -211,6 +218,7 @@ interface PendingQuestion { export function ReplScreen({ projectPath, onTurnComplete, + onSessionStarted, initialMessages, onInspector, onOpenFile, @@ -245,9 +253,7 @@ export function ReplScreen({ // current model (deepseek-reasoner output is ¥16/M vs chat's ¥2/M). const modelRef = useRef(model); modelRef.current = model; - const [mode, setMode] = useState< - 'default' | 'acceptEdits' | 'plan' | 'dontAsk' | 'bypassPermissions' - >('default'); + const [mode, setMode] = useState('default'); const [usage, setUsage] = useState<{ inputTokens: number; outputTokens: number }>({ inputTokens: 0, outputTokens: 0, @@ -293,11 +299,20 @@ export function ReplScreen({ const s = (await loadSettingsFile()) as { effortLevel?: string; model?: string; + permissions?: { defaultMode?: string }; }; if (s.effortLevel && (EFFORTS as string[]).includes(s.effortLevel)) { setEffort(s.effortLevel as Effort); } if (s.model) setModel(s.model); + if ( + s.permissions?.defaultMode && + ['default', 'acceptEdits', 'plan', 'auto', 'dontAsk', 'bypassPermissions'].includes( + s.permissions.defaultMode, + ) + ) { + setMode(s.permissions.defaultMode as AgentMode); + } } catch { /* defaults */ } @@ -585,6 +600,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..4b15ad5 100644 --- a/apps/desktop/src/types/global.d.ts +++ b/apps/desktop/src/types/global.d.ts @@ -1,6 +1,8 @@ // Canonical renderer types. Window.deepcode is installed at runtime by // src/lib/window-shim.ts (which uses Tauri's invoke() under the hood). +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; + export interface UpdateInfo { version: string; releaseNotes?: string; @@ -45,6 +47,7 @@ export interface DeepCodeAPI { }; settings: { load: () => Promise>; + diagnostics: (args: { cwd: string }) => Promise; }; sessions: { list: (args?: { limit?: number }) => Promise; @@ -74,7 +77,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/src/types/screens.ts b/apps/desktop/src/types/screens.ts index ed22fd1..c73e635 100644 --- a/apps/desktop/src/types/screens.ts +++ b/apps/desktop/src/types/screens.ts @@ -4,7 +4,6 @@ export type ScreenName = | 'repl' - | 'chat' // alias for 'repl' — kept for IPC-shim backwards compat | 'sessions' | 'plugins' | 'skills' 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..a612190 100644 --- a/apps/lsp/README.md +++ b/apps/lsp/README.md @@ -1,30 +1,45 @@ # @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[] }` | - -Streamed events are sent as `deepcode/agentEvent` notifications: +| 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[] }` | +| `deepcode.configDiagnostics` | none | value-free config sources, trust gates, and issues | +| `deepcode.workspaceDiff` | none | canonical structured workspace diff | +| `deepcode.applyReviewFinding` | `{ findingId }` | `{ threadId, turnId }` | +| `deepcode.applyReviewFindings` | `{ findingIds }` | `{ threadId, turnId }` | +| `deepcode.revertReviewAction` | `{ actionId }` | `{ threadId, turnId }` | + +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 +113,14 @@ 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, +AskUserQuestion, configuration diagnostics, canonical workspace diff, and single/batch review +actions. 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..c8364bd 100644 --- a/apps/lsp/src/handler.test.ts +++ b/apps/lsp/src/handler.test.ts @@ -1,141 +1,420 @@ -import { describe, expect, it } from 'vitest'; -import { handleMessage, type LspMessage } from './handler.js'; +import type { + ConfigDiagnosticsResult, + InitializeResult, + ProtocolEvent, + ProtocolMethod, + ProtocolRequest, + ThreadSnapshot, + TurnSnapshot, + WorkspaceDiffResult, +} 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, + reviewActions: true, + configDiagnostics: true, + diagnosticExport: true, + workspaceDiff: true, + }, +}; + +const diagnostics: ConfigDiagnosticsResult = { + cwd: '/tmp/x', + trustStatus: 'untrusted', + layers: [], + provenance: {}, + gated: ['permissions'], + issues: [], +}; + +const workspaceDiff: WorkspaceDiffResult = { + repository: true, + base: 'HEAD', + files: [], + truncated: false, +}; + +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': + case 'review/apply': + case 'review/revert': { + // 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; + case 'config/diagnostics': + return diagnostics as T; + case 'workspace/diff': + return workspaceDiff 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', + 'deepcode.configDiagnostics', + 'deepcode.workspaceDiff', + 'deepcode.applyReviewFinding', + 'deepcode.applyReviewFindings', + 'deepcode.revertReviewAction', + ]), + ); }); }); -describe('handleMessage — executeCommand', () => { - it('returns a turnId for deepcode.runAgent and streams events', async () => { +describe('handleMessage — protocol commands', () => { + it('returns app-server configuration diagnostics for the LSP workspace', 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, + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { rootUri: 'file:///tmp/x' } }, + (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 execute(2, 'deepcode.configDiagnostics', {}, (message) => out.push(message)); + + expect(out.find((message) => message.id === 2)?.result).toEqual(diagnostics); + expect(client.requests.at(-1)).toMatchObject({ + method: 'config/diagnostics', + params: { cwd: '/tmp/x' }, + }); + }); + + it('returns the canonical workspace diff through the current thread', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); const out: LspMessage[] = []; - await handleMessage( + await execute(20, 'deepcode.workspaceDiff', {}, (message) => out.push(message)); + expect(out.find((message) => message.id === 20)?.result).toEqual(workspaceDiff); + expect(client.requests.map((request) => request.method)).toEqual([ + 'thread/start', + 'workspace/diff', + ]); + }); + + it('applies a finding through the normal runAgent turn path', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + await execute( + 21, + 'deepcode.applyReviewFinding', { - jsonrpc: '2.0', - id: 3, - method: 'workspace/executeCommand', - params: { command: 'deepcode.runAgent', arguments: [{}] }, + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, }, - (m) => out.push(m), + (message) => out.push(message), ); - expect(out[0]!.error).toBeDefined(); - expect(out[0]!.error!.message).toMatch(/prompt is required/); + expect(out.find((message) => message.id === 21)?.result).toEqual({ + threadId: 'thread-1', + turnId: 'turn-1', + }); + expect(client.requests.at(-1)).toMatchObject({ + method: 'review/apply', + params: { threadId: 'thread-1', findingIds: ['finding-1'] }, + }); }); - it('deepcode.abort returns false for unknown turnId', async () => { + it('applies a bounded finding batch by canonical id', async () => { + const client = new FakeClient(); + __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), + await execute( + 22, + 'deepcode.applyReviewFindings', + { findingIds: ['finding-1', 'finding-2'] }, + (message) => out.push(message), ); - expect((out[0]!.result as { aborted: boolean }).aborted).toBe(false); + expect(client.requests.at(-1)).toMatchObject({ + method: 'review/apply', + params: { threadId: 'thread-1', findingIds: ['finding-1', 'finding-2'] }, + }); }); - it('errors on unknown command', async () => { + it('reverts a review action through the canonical conflict-safe turn', 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), + await execute(23, 'deepcode.revertReviewAction', { actionId: 'turn-apply' }, (message) => + out.push(message), ); - expect(out[0]!.error).toBeDefined(); - expect(out[0]!.error!.message).toMatch(/Unknown command/); + expect(client.requests.at(-1)).toMatchObject({ + method: 'review/revert', + params: { threadId: 'thread-1', actionId: 'turn-apply' }, + }); }); -}); -describe('handleMessage — unknown method', () => { - it('returns -32603 internal error', async () => { + it('starts a canonical thread and emits native protocol events in order', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + + await execute(2, 'deepcode.runAgent', { prompt: 'hi', effort: 'high' }, (message) => + out.push(message), + ); + 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[] = []; + 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('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: 6, method: 'unknown/method' }, (m) => out.push(m)); - expect(out[0]!.error).toBeDefined(); + 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(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('reads and resumes protocol snapshots', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + 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', + ]); + }); + + it('rejects missing prompts and unknown turns', async () => { + const client = new FakeClient(); + __test.setClientFactory(() => client); + const out: LspMessage[] = []; + 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..2991ee4 100644 --- a/apps/lsp/src/handler.ts +++ b/apps/lsp/src/handler.ts @@ -1,5 +1,19 @@ -// 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 ConfigDiagnosticsResult, + type InitializeResult, + type ProtocolEvent, + type ProtocolMethod, + type ReviewFindingPayload, + type ThreadSnapshot, + type TurnSnapshot, + type WorkspaceDiffResult, +} from '@deepcode/protocol'; export interface LspMessage { jsonrpc: '2.0'; @@ -12,17 +26,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 +59,49 @@ const SERVER_INFO = { version: '0.0.0', }; +const COMMANDS = [ + 'deepcode.runAgent', + 'deepcode.abort', + 'deepcode.readThread', + 'deepcode.resumeThread', + 'deepcode.respondApproval', + 'deepcode.respondUserInput', + 'deepcode.listSkills', + 'deepcode.configDiagnostics', + 'deepcode.workspaceDiff', + 'deepcode.applyReviewFinding', + 'deepcode.applyReviewFindings', + 'deepcode.revertReviewAction', +]; + 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 +111,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 +124,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,135 +137,358 @@ 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(); + case 'deepcode.configDiagnostics': + return handleConfigDiagnostics(); + case 'deepcode.workspaceDiff': + return handleWorkspaceDiff(); + case 'deepcode.applyReviewFinding': + return handleReviewApply( + [((params.arguments?.[0] ?? {}) as ReviewFindingPayload).findingId], + send, + ); + case 'deepcode.applyReviewFindings': + return handleReviewApply( + ((params.arguments?.[0] ?? {}) as { findingIds?: string[] }).findingIds ?? [], + send, + ); + case 'deepcode.revertReviewAction': + return handleReviewRevert( + ((params.arguments?.[0] ?? {}) as { actionId?: string }).actionId, + send, + ); default: throw new Error(`Unknown command: ${params.command}`); } } 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 handleReviewApply( + findingIds: Array, + send: SendFn, +): Promise<{ threadId: string; turnId: string }> { + if (findingIds.length === 0 || findingIds.some((findingId) => !findingId)) { + throw new Error('At least one findingId is required'); + } + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const thread = await ensureThread(client); + const turn = await client.request('review/apply', { + threadId: thread.id, + findingIds: findingIds as string[], + }); + state.activeTurns.set(turn.id, thread.id); + state.turnSinks.set(turn.id, send); + flushEvents(turn.id); + return { threadId: thread.id, turnId: turn.id }; +} + +async function handleReviewRevert( + actionId: string | undefined, + send: SendFn, +): Promise<{ threadId: string; turnId: string }> { + if (!actionId) throw new Error('actionId is required'); + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.reviewActions) { + throw new Error('The app-server does not support review actions'); + } + const thread = await ensureThread(client); + const turn = await client.request('review/revert', { + threadId: thread.id, + actionId, + }); + state.activeTurns.set(turn.id, thread.id); + state.turnSinks.set(turn.id, send); + flushEvents(turn.id); + return { threadId: thread.id, turnId: turn.id }; +} + +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 +async function handleConfigDiagnostics(): Promise { + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.configDiagnostics) { + throw new Error('The app-server does not support configuration diagnostics'); + } + return client.request('config/diagnostics', { cwd: workspacePath() }); +} + +async function handleWorkspaceDiff(): Promise { + const client = await getClient(); + const initialized = await client.connect(); + if (!initialized.capabilities.workspaceDiff) { + throw new Error('The app-server does not support workspace diff'); + } + const thread = await ensureThread(client); + return client.request('workspace/diff', { threadId: thread.id }); +} + 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..62b65ac --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,62 @@ +# @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. + +Each turn leases a host composition for its workspace. The backend loads user/project +`DEEPCODE.md`, `AGENTS.md`, rules, memory, skills, output style, hooks, and settings defaults before +calling `RuntimeHost`; clients remain unaware of those files. The lease has an explicit async close +hook. Trusted plugin contributions and MCP servers are composed in that lease: eager/deferred tools +share the host registry, MCP resource references are expanded before the model call, startup/resource +failures become value-free turn diagnostics, and every subprocess/connection closes in `finally`. + +Trusted-directory project/local command hooks still require exact-definition review. The shared +hook trust store disables pending or changed definitions and exposes value-free warnings through +`config/diagnostics`; use `deepcode hooks list` and `deepcode hooks trust ` to review them. + +The host generates one `traceId` per turn and attaches it to durable and transient protocol events. +Bounded NDJSON logs live under `logs/app-server.ndjson`; their schema only permits correlation ids, +event names, status codes, and durations. It never serializes protocol payloads, prompts, commands, +tool arguments/results, or error messages. `diagnostics/export` (also available as +`deepcode diagnostics export`) writes a mode-0600 support bundle under `diagnostics/`. The export +hashes workspace/config paths, omits issue messages and configuration values, re-sanitizes every log +record, and can be removed without affecting threads. Removing `logs/` and `diagnostics/` is the +rollback for this optional observability layer. + +`workspace/diff` is bound to a canonical `threadId`, so clients cannot substitute an unrelated cwd. +The server invokes Git without a shell and returns a bounded file/hunk/line DTO for tracked and +untracked changes. Untracked symlinks and binary contents are never read into the response. Desktop, +VS Code, and LSP consume this same capability; clients do not parse Git output independently. + +The read-only `SubmitReviewFinding` tool turns model findings into durable `review_finding` items +with a workspace-relative path, tight line range, priority, and optional exact replacement. +`review/apply` accepts one bounded list of finding ids, resolves the original payloads from the +canonical thread, builds the verification-first prompt in the host, and records a `review_action` +item tied to the new turn. It is not a filesystem endpoint: every edit still uses the existing +Edit/Write permission, approval, hook, sandbox, and snapshot path. + +Every app-server snapshot is tagged with its canonical turn id. `review/revert` resolves a completed +Apply action and starts a tool-restricted turn; only `RestoreReviewAction` is exposed. That tool +performs an all-files compare-and-swap against the action's post-images before restoring pre-images, +then snapshots the restore itself. It refuses conflicts and never falls back to `git checkout` or an +unscoped write. diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..328615a --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,48 @@ +{ + "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" + }, + "./diagnostics": { + "types": "./dist/diagnostic-export.d.ts", + "import": "./dist/diagnostic-export.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..0eec3be --- /dev/null +++ b/apps/server/scripts/build-sidecar.mjs @@ -0,0 +1,33 @@ +import { mkdir, readFile, 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'); +const settingsSchema = await readFile( + resolve(packageRoot, '..', '..', 'packages', 'core', 'schemas', 'settings.schema.json'), + 'utf8', +); +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', + define: { + __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), + 'import.meta.url': 'undefined', + }, + 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..c5f6363 --- /dev/null +++ b/apps/server/src/client.test.ts @@ -0,0 +1,53 @@ +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, + reviewActions: 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..e727c89 --- /dev/null +++ b/apps/server/src/default-runtime.ts @@ -0,0 +1,81 @@ +import { CredentialsStore, resolveCredentials } from '@deepcode/core/credentials'; +import { + DirectoryTrustStore, + gateUntrustedSettings, + HookTrustStore, + 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 { RuntimeHostExecutor } from './runtime-executor.js'; +import { composeRuntime, resolveComposedMode } from './runtime-composition.js'; + +export function createDefaultTurnExecutor( + home?: string, + options: { forceFileCredentials?: boolean } = {}, +): RuntimeHostExecutor { + const trustStore = new DirectoryTrustStore({ directory: home }); + const hookTrustStore = new HookTrustStore({ directory: home }); + const sessionManager = new SessionManager({ + root: home ? `${home}/sessions` : undefined, + }); + return new RuntimeHostExecutor({ + createHost: async (cwd, mode, context) => { + const loaded = await loadSettings({ cwd, directory: home }); + const trustStatus = await trustStore.statusFor(cwd); + const gate = gateUntrustedSettings(loaded, trustStatus); + const hookReview = await hookTrustStore.review(cwd, loaded, gate.settings.hooks); + const settings = { ...gate.settings, hooks: hookReview.hooks }; + const effectiveMode = resolveComposedMode(mode, context.modeExplicit, settings); + 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.', + ); + } + const provider = new DeepSeekProvider({ + apiKey: credentials.apiKey ?? '', + authToken: credentials.authToken, + baseURL: credentials.baseURL ?? settings.baseURL, + }); + const composition = await composeRuntime({ + cwd, + directory: home, + settings, + mode: effectiveMode, + provider, + requestApproval: context.requestApproval, + signal: context.signal, + includeReviewRestore: context.reviewAction?.kind === 'revert', + }); + return { + host: new RuntimeHost({ + provider, + tools: composition.tools, + cwd, + mode: effectiveMode, + permissions: settings.permissions ?? { allow: [...SAFE_READONLY_TOOLS] }, + hooks: composition.hooks, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + pluginDirs: composition.pluginDirs, + }), + systemPrompt: composition.systemPrompt, + model: composition.model, + effort: composition.effort, + diagnostics: composition.diagnostics, + prepareUserMessage: composition.prepareUserMessage, + close: composition.close, + }; + }, + sessionManager, + }); +} diff --git a/apps/server/src/diagnostic-export.ts b/apps/server/src/diagnostic-export.ts new file mode 100644 index 0000000..a2a56cc --- /dev/null +++ b/apps/server/src/diagnostic-export.ts @@ -0,0 +1,107 @@ +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { VERSION } from '@deepcode/core'; +import { + PROTOCOL_VERSION, + type ConfigDiagnosticsResult, + type DiagnosticExportResult, +} from '@deepcode/protocol'; + +import { sanitizeStructuredLogRecord } from './structured-logger.js'; + +export interface DiagnosticExportOptions { + home: string; + cwd: string; + config: ConfigDiagnosticsResult; + generatedAt?: string; + logPath?: string; + maxLogRecords?: number; +} + +/** Create a support bundle that contains no raw paths, settings values, prompts, or tool payloads. */ +export async function exportDiagnosticBundle( + options: DiagnosticExportOptions, +): Promise { + const generatedAt = options.generatedAt ?? new Date().toISOString(); + const records = await readSafeLogRecords(options.logPath, options.maxLogRecords ?? 1000); + const bundle = { + schemaVersion: 1, + generatedAt, + deepcodeVersion: VERSION, + protocolVersion: PROTOCOL_VERSION, + runtime: { node: process.version, platform: process.platform, arch: process.arch }, + workspace: { id: pathId(resolve(options.cwd)) }, + configuration: sanitizeConfiguration(options.config), + logs: records, + }; + + const directory = join(options.home, 'diagnostics'); + await mkdir(directory, { recursive: true, mode: 0o700 }); + const stamp = generatedAt.replace(/[^0-9]/g, '').slice(0, 14) || 'unknown'; + const path = join(directory, `deepcode-diagnostics-${stamp}-${randomUUID().slice(0, 8)}.json`); + await writeFile(path, `${JSON.stringify(bundle, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }); + return { path, generatedAt, recordCount: records.length }; +} + +function sanitizeConfiguration(config: ConfigDiagnosticsResult) { + return { + trustStatus: config.trustStatus, + layers: config.layers.map((layer) => ({ + layer: layer.layer, + present: layer.present, + trusted: layer.trusted, + sourceId: pathId(layer.path), + })), + provenance: Object.entries(config.provenance).map(([pointer, source]) => ({ + pointer, + layer: source.layer, + sourceId: pathId(source.path), + })), + gated: [...config.gated], + issues: config.issues.map((issue) => ({ + severity: issue.severity, + code: safeToken(issue.code), + pointer: issue.pointer, + sourceLayer: issue.source?.layer, + sourceId: issue.source ? pathId(issue.source.path) : undefined, + })), + }; +} + +async function readSafeLogRecords(path: string | undefined, max: number): Promise { + if (!path || max <= 0) return []; + let contents: string; + try { + contents = await readFile(path, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw error; + } + return contents + .split('\n') + .filter(Boolean) + .slice(-max) + .flatMap((line) => { + try { + const value = JSON.parse(line) as Record; + return [sanitizeStructuredLogRecord(value)]; + } catch { + return []; + } + }); +} + +function pathId(path: string): string { + return createHash('sha256').update(resolve(path)).digest('hex').slice(0, 16); +} + +function safeToken(value: unknown): string { + if (typeof value !== 'string') return 'unknown'; + return value.replace(/[^a-zA-Z0-9._:/-]/g, '_').slice(0, 160) || 'unknown'; +} 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..5c73e70 --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,11 @@ +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'; +export * from './runtime-composition.js'; +export * from './structured-logger.js'; +export * from './diagnostic-export.js'; +export * from './workspace-diff.js'; diff --git a/apps/server/src/run.test.ts b/apps/server/src/run.test.ts new file mode 100644 index 0000000..f3e46b6 --- /dev/null +++ b/apps/server/src/run.test.ts @@ -0,0 +1,70 @@ +import { mkdtemp, readFile, 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` + + `${JSON.stringify({ id: 3, method: 'diagnostics/export', 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'], + }), + }), + ); + const exported = responses[2]?.result as { path: string; recordCount: number }; + expect(exported.recordCount).toBeGreaterThan(0); + const bundle = await readFile(exported.path, 'utf8'); + expect(bundle).toContain('protocol.request.completed'); + expect(bundle).not.toContain(cwd); + }); +}); diff --git a/apps/server/src/run.ts b/apps/server/src/run.ts new file mode 100644 index 0000000..b01a791 --- /dev/null +++ b/apps/server/src/run.ts @@ -0,0 +1,71 @@ +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'; +import { exportDiagnosticBundle } from './diagnostic-export.js'; +import { StructuredLogger } from './structured-logger.js'; +import { collectWorkspaceDiff } from './workspace-diff.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 logger = new StructuredLogger({ directory: join(options.home, 'logs') }); + const diagnosticsFor = async (cwd: string) => + diagnoseSettings({ + cwd, + directory: options.home, + trustStatus: await trustStore.statusFor(cwd), + }); + 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: diagnosticsFor, + diagnosticExport: async (cwd) => { + await logger.flush(); + return exportDiagnosticBundle({ + home: options.home, + cwd, + config: await diagnosticsFor(cwd), + logPath: logger.path, + }); + }, + workspaceDiff: collectWorkspaceDiff, + onTrace: (record) => { + logger.record( + record, + record.status === 'error' || record.status === 'failed' ? 'error' : 'info', + ); + }, + onEvent: (event) => { + logger.recordProtocolEvent(event); + const notification: ProtocolNotification = { method: 'event', params: event }; + void writer.enqueue(notification); + }, + }); + try { + await serveStdio(server, options.input, writer); + } finally { + await logger.flush(); + } +} diff --git a/apps/server/src/runtime-composition.test.ts b/apps/server/src/runtime-composition.test.ts new file mode 100644 index 0000000..6ce6d0c --- /dev/null +++ b/apps/server/src/runtime-composition.test.ts @@ -0,0 +1,194 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { HookDispatcher } from '@deepcode/core/hooks'; +import type { McpClientHandle } from '@deepcode/core/mcp'; +import type { ToolHandler } from '@deepcode/core/tools'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + buildPluginCapabilityBridge, + composeRuntime, + resolveComposedMode, + type RuntimeCompositionServices, +} from './runtime-composition.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +describe('composeRuntime', () => { + it('uses trusted settings mode unless the client explicitly overrides it', () => { + const settings = { permissions: { defaultMode: 'plan' as const } }; + expect(resolveComposedMode('default', false, settings)).toBe('plan'); + expect(resolveComposedMode('auto', true, settings)).toBe('auto'); + }); + + it('registers the internal restore tool only for canonical revert turns', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-composition-revert-')); + roots.push(cwd); + const normal = await composeRuntime({ + cwd, + settings: { plugins: { globalEnabled: false } }, + }); + expect(normal.tools.get('RestoreReviewAction')).toBeUndefined(); + await normal.close(); + + const revert = await composeRuntime({ + cwd, + settings: { plugins: { globalEnabled: false } }, + includeReviewRestore: true, + }); + expect(revert.tools.get('RestoreReviewAction')).toBeDefined(); + await revert.close(); + }); + + it('assembles memory, AGENTS, skills, style, hooks, and model defaults', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dc-composition-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-composition-cwd-')); + roots.push(directory, cwd); + await writeFile(join(directory, 'DEEPCODE.md'), 'User-level instructions.'); + await writeFile(join(cwd, 'AGENTS.md'), 'Project agent instructions.'); + await mkdir(join(directory, 'skills', 'verify'), { recursive: true }); + await writeFile( + join(directory, 'skills', 'verify', 'SKILL.md'), + '---\nname: verify\ndescription: Verify the result.\n---\nRun the relevant tests.', + ); + await mkdir(join(directory, 'output-styles'), { recursive: true }); + await writeFile( + join(directory, 'output-styles', 'focused.md'), + '---\nname: focused\n---\nReport only material findings.', + ); + + const composition = await composeRuntime({ + cwd, + directory, + settings: { + model: 'deepseek-reasoner', + effortLevel: 'high', + outputStyle: 'focused', + hooks: { + UserPromptSubmit: [{ hooks: [{ type: 'prompt', prompt: 'Additional hook context.' }] }], + }, + }, + }); + + expect(composition.systemPrompt).toContain('User-level instructions.'); + expect(composition.systemPrompt).toContain('Project agent instructions.'); + expect(composition.systemPrompt).toContain('verify'); + expect(composition.systemPrompt).toContain('Report only material findings.'); + expect(composition.tools.get('Read')).toBeDefined(); + expect(composition.tools.get('Bash')).toBeDefined(); + expect(composition.tools.get('Skill')).toBeDefined(); + expect(composition.model).toBe('deepseek-reasoner'); + expect(composition.effort).toBe('high'); + await expect( + composition.hooks.dispatch({ + event: 'UserPromptSubmit', + cwd, + triggeredAt: '2026-08-01T00:00:00.000Z', + payload: { prompt: 'test' }, + }), + ).resolves.toEqual( + expect.objectContaining({ stdout: expect.stringContaining('hook context') }), + ); + await composition.close(); + }); + + it('registers eager and deferred MCP tools, expands resources, and closes every lease', async () => { + const directory = await mkdtemp(join(tmpdir(), 'dc-composition-mcp-home-')); + const cwd = await mkdtemp(join(tmpdir(), 'dc-composition-mcp-cwd-')); + roots.push(directory, cwd); + const tool = (name: string): ToolHandler => ({ + name, + definition: { name, description: `${name} description`, inputSchema: { type: 'object' } }, + execute: async () => ({ content: name }), + }); + const eager = tool('mcp__eager__read'); + const deferred = tool('mcp__deferred__search'); + const handle = (serverName: string, tools: ToolHandler[]) => + ({ serverName, tools, resources: [], resourceTemplates: [], prompts: [] }) as McpClientHandle; + const closeMcp = vi.fn(async () => undefined); + const shutdownPlugins = vi.fn(async () => undefined); + const services: Partial = { + collectPluginContributions: async () => ({ + dirs: [join(directory, 'plugins', 'demo')], + mcpServers: {}, + }), + connectAllMcpServers: async () => ({ + handles: [handle('eager', [eager]), handle('deferred', [deferred])], + errors: [{ serverName: 'broken', error: 'offline' }], + }), + closeAllMcpServers: closeMcp, + expandMcpResourceRefs: async () => ({ + text: 'expanded resource', + resolved: [], + errors: [ + { + ref: { raw: '@eager:file://x', server: 'eager', uri: 'file://x' }, + error: 'missing', + }, + ], + }), + wirePlugins: async () => ({ + plugins: [], + hashMismatches: ['demo: hash drift'], + spawnFailures: ['demo'], + shutdown: shutdownPlugins, + }), + }; + + const composition = await composeRuntime({ + cwd, + directory, + settings: { + mcpServers: { + eager: { command: 'eager' }, + deferred: { command: 'deferred', alwaysLoad: false }, + }, + }, + services, + }); + + expect(composition.tools.get(eager.name)).toBe(eager); + expect(composition.tools.get(deferred.name)).toBeUndefined(); + expect(composition.tools.get('ToolSearch')).toBeDefined(); + expect(composition.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'mcp_connect_failed', + 'plugin_hash_mismatch', + 'plugin_start_failed', + ]); + await expect(composition.prepareUserMessage('read it')).resolves.toEqual({ + text: 'expanded resource', + diagnostics: [expect.objectContaining({ code: 'mcp_resource_failed' })], + }); + await composition.close(); + await composition.close(); + expect(closeMcp).toHaveBeenCalledOnce(); + expect(shutdownPlugins).toHaveBeenCalledOnce(); + }); + + it('gates plugin subprocess capabilities through mode and approval policy', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dc-plugin-bridge-cwd-')); + roots.push(cwd); + const target = join(cwd, 'plugin.txt'); + const hooks = new HookDispatcher({}); + const denied = buildPluginCapabilityBridge({ cwd, mode: 'plan', hooks }); + await expect(denied.fs_write(target, 'blocked')).rejects.toThrow(/mode=plan/); + + const requestApproval = vi.fn(async () => 'allow' as const); + const allowed = buildPluginCapabilityBridge({ + cwd, + mode: 'default', + hooks, + requestApproval, + }); + await allowed.fs_write(target, 'allowed'); + expect(await readFile(target, 'utf8')).toBe('allowed'); + expect(requestApproval).toHaveBeenCalledWith('Write', expect.any(String)); + }); +}); diff --git a/apps/server/src/runtime-composition.ts b/apps/server/src/runtime-composition.ts new file mode 100644 index 0000000..7b2db53 --- /dev/null +++ b/apps/server/src/runtime-composition.ts @@ -0,0 +1,369 @@ +import type { Effort, Mode, Provider } from '@deepcode/core'; +import type { + DeepCodeSettings, + McpServerConfig, + PermissionRules, + SandboxConfig, +} from '@deepcode/core/config'; +import { dispatchToolCall } from '@deepcode/core/harness'; +import { HookDispatcher } from '@deepcode/core/hooks'; +import { loadMemory } from '@deepcode/core/memory'; +import { + closeAllMcpServers, + connectAllMcpServers, + expandMcpResourceRefs, + type McpClientHandle, +} from '@deepcode/core/mcp'; +import { applyStyle, findStyle, loadOutputStyles } from '@deepcode/core/output-styles'; +import { + collectPluginContributions, + wirePlugins, + type PluginCapabilityBridge, + type WireResult, +} from '@deepcode/core/plugins'; +import { buildSkillsDescriptionBlock, loadSkills, makeSkillTool } from '@deepcode/core/skills'; +import { + BashTool, + BUILTIN_TOOLS, + installToolSearch, + ReadTool, + RestoreReviewActionTool, + ToolRegistry, + WebFetchTool, + WriteTool, + type ToolHandler, + type ToolResult, +} from '@deepcode/core/tools'; + +export const DEFAULT_APP_SERVER_SYSTEM_PROMPT = + 'You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their ' + + 'codebase using the available tools. Be concise and accurate. When you modify files, briefly ' + + 'explain what you changed and why. For code review, call SubmitReviewFinding once for each ' + + 'actionable issue, using a precise workspace-relative path and line range.'; + +export interface RuntimeCompositionDiagnostic { + source: 'mcp' | 'plugin'; + code: string; + severity: 'warning' | 'error'; + message: string; +} + +export interface RuntimePreparedMessage { + text: string; + diagnostics: RuntimeCompositionDiagnostic[]; +} + +export interface RuntimeCompositionServices { + collectPluginContributions: typeof collectPluginContributions; + connectAllMcpServers: typeof connectAllMcpServers; + closeAllMcpServers: typeof closeAllMcpServers; + expandMcpResourceRefs: typeof expandMcpResourceRefs; + wirePlugins: typeof wirePlugins; +} + +const DEFAULT_SERVICES: RuntimeCompositionServices = { + collectPluginContributions, + connectAllMcpServers, + closeAllMcpServers, + expandMcpResourceRefs, + wirePlugins, +}; + +export interface RuntimeCompositionOptions { + cwd: string; + directory?: string; + settings: DeepCodeSettings; + mode?: Mode; + provider?: Provider; + requestApproval?: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; + signal?: AbortSignal; + services?: Partial; + includeReviewRestore?: boolean; +} + +export interface RuntimeComposition { + tools: ToolRegistry; + hooks: HookDispatcher; + systemPrompt: string; + model: string; + effort: Effort; + pluginDirs: string[]; + diagnostics: RuntimeCompositionDiagnostic[]; + prepareUserMessage: (text: string) => Promise; + close: () => Promise; +} + +export function resolveComposedMode( + requested: Mode, + modeExplicit: boolean, + settings: DeepCodeSettings, +): Mode { + return modeExplicit ? requested : (settings.permissions?.defaultMode ?? requested); +} + +/** Compose trusted filesystem context and turn-scoped external resources. */ +export async function composeRuntime( + options: RuntimeCompositionOptions, +): Promise { + const { cwd, directory, settings } = options; + const services = { ...DEFAULT_SERVICES, ...options.services }; + const diagnostics: RuntimeCompositionDiagnostic[] = []; + const pluginsEnabled = settings.plugins?.globalEnabled !== false; + let pluginDiscoverySucceeded = true; + let pluginDirs: string[] = []; + let pluginMcpServers: Record = {}; + + if (pluginsEnabled) { + try { + const contribution = await services.collectPluginContributions({ + directory, + disabled: settings.disabledPlugins, + }); + pluginDirs = contribution.dirs; + pluginMcpServers = contribution.mcpServers; + } catch (error) { + pluginDiscoverySucceeded = false; + diagnostics.push({ + source: 'plugin', + code: 'plugin_discovery_failed', + severity: 'error', + message: `Plugin discovery failed: ${(error as Error).message}`, + }); + } + } + + const [memory, skills, styles] = await Promise.all([ + loadMemory({ + cwd, + directory, + maxBytes: (settings.memoryLoadCapKB ?? 100) * 1024, + }), + loadSkills({ + cwd, + directory, + pluginDirs, + overrides: settings.skillOverrides, + }), + loadOutputStyles({ cwd, directory }), + ]); + + const tools = new ToolRegistry(BUILTIN_TOOLS); + if (options.includeReviewRestore) tools.register(RestoreReviewActionTool); + if (skills.length > 0) tools.register(makeSkillTool(skills)); + const hooks = new HookDispatcher({ + hooks: settings.hooks, + disableAllHooks: settings.disableAllHooks, + allowedHttpHookUrls: settings.allowedHttpHookUrls, + }); + + const allMcpServers = { ...pluginMcpServers, ...(settings.mcpServers ?? {}) }; + let mcpServers: McpClientHandle[] = []; + if (Object.keys(allMcpServers).length > 0) { + try { + const connected = await services.connectAllMcpServers(allMcpServers, { + enabledOnly: settings.enabledMcpjsonServers, + disabled: settings.disabledMcpjsonServers ?? [], + directory, + }); + mcpServers = connected.handles; + for (const error of connected.errors) { + diagnostics.push({ + source: 'mcp', + code: 'mcp_connect_failed', + severity: 'warning', + message: `MCP server "${error.serverName}" failed: ${error.error}`, + }); + } + const deferred = []; + for (const handle of mcpServers) { + const defer = allMcpServers[handle.serverName]?.alwaysLoad === false; + for (const tool of handle.tools) { + if (defer) { + deferred.push({ + name: tool.name, + description: tool.definition.description, + expand: () => tool, + }); + } else { + tools.register(tool); + } + } + } + installToolSearch(tools, deferred); + } catch (error) { + diagnostics.push({ + source: 'mcp', + code: 'mcp_composition_failed', + severity: 'error', + message: `MCP composition failed: ${(error as Error).message}`, + }); + } + } + + let pluginsWire: WireResult | null = null; + if (pluginsEnabled && pluginDiscoverySucceeded) { + try { + pluginsWire = await services.wirePlugins({ + directory, + disabled: settings.disabledPlugins, + hooks, + capabilities: buildPluginCapabilityBridge({ + cwd, + mode: options.mode ?? 'default', + permissions: settings.permissions, + hooks, + provider: options.provider, + autoMode: settings.autoMode, + sandboxConfig: settings.sandbox, + requestApproval: options.requestApproval, + signal: options.signal, + }), + sandbox: settings.sandbox, + log: () => undefined, + }); + for (const plugin of pluginsWire.plugins) { + for (const tool of plugin.contributedTools) { + if (!tools.get(tool.name)) tools.register(tool); + } + } + for (const mismatch of pluginsWire.hashMismatches) { + diagnostics.push({ + source: 'plugin', + code: 'plugin_hash_mismatch', + severity: 'warning', + message: mismatch, + }); + } + for (const name of pluginsWire.spawnFailures) { + diagnostics.push({ + source: 'plugin', + code: 'plugin_start_failed', + severity: 'warning', + message: `Plugin "${name}" failed to start`, + }); + } + } catch (error) { + diagnostics.push({ + source: 'plugin', + code: 'plugin_wire_failed', + severity: 'error', + message: `Plugin wire-up failed: ${(error as Error).message}`, + }); + } + } + + let systemPrompt = DEFAULT_APP_SERVER_SYSTEM_PROMPT; + if (memory.text) systemPrompt += `\n\n${memory.text}`; + const skillsBlock = buildSkillsDescriptionBlock(skills); + if (skillsBlock) systemPrompt += `\n\n${skillsBlock}`; + systemPrompt = applyStyle(systemPrompt, findStyle(styles, settings.outputStyle ?? 'default')); + + let closed = false; + return { + tools, + hooks, + systemPrompt, + model: settings.model ?? 'deepseek-chat', + effort: settings.effortLevel ?? 'medium', + pluginDirs, + diagnostics, + prepareUserMessage: async (text) => { + if (mcpServers.length === 0) return { text, diagnostics: [] }; + const expanded = await services.expandMcpResourceRefs(text, mcpServers); + return { + text: expanded.text, + diagnostics: expanded.errors.map((error) => ({ + source: 'mcp' as const, + code: 'mcp_resource_failed', + severity: 'warning' as const, + message: `MCP resource @${error.ref.server}:${error.ref.uri} failed: ${error.error}`, + })), + }; + }, + close: async () => { + if (closed) return; + closed = true; + await Promise.allSettled([ + pluginsWire?.shutdown() ?? Promise.resolve(), + services.closeAllMcpServers(mcpServers), + ]); + }, + }; +} + +interface PluginBridgeOptions { + cwd: string; + mode: Mode; + permissions?: PermissionRules; + hooks: HookDispatcher; + provider?: Provider; + autoMode?: DeepCodeSettings['autoMode']; + sandboxConfig?: SandboxConfig; + requestApproval?: RuntimeCompositionOptions['requestApproval']; + signal?: AbortSignal; +} + +/** Route plugin subprocess capabilities through the same policy and hook gates as agent tools. */ +export function buildPluginCapabilityBridge(options: PluginBridgeOptions): PluginCapabilityBridge { + const execute = async ( + handler: ToolHandler, + input: Record, + ): Promise => { + const verdict = await dispatchToolCall({ + tool: handler.name, + input, + mode: options.mode, + rules: options.permissions, + hooks: options.hooks, + cwd: options.cwd, + autoMode: options.autoMode, + autoModeProvider: options.provider, + }); + let allowed = verdict.decision === 'allow'; + if (verdict.decision === 'ask' && options.requestApproval) { + const decision = await options.requestApproval( + handler.name, + `Plugin requested ${handler.name}: ${verdict.reason}`, + ); + allowed = decision === 'allow' || decision === 'always'; + } + if (!allowed) throw new Error(`Plugin capability blocked: ${verdict.reason}`); + + const result = await handler.execute(input, { + cwd: options.cwd, + signal: options.signal, + sandboxConfig: options.sandboxConfig, + }); + await options.hooks.dispatch({ + event: 'PostToolUse', + cwd: options.cwd, + triggeredAt: new Date().toISOString(), + payload: { + tool: handler.name, + input, + result_content: result.content.slice(0, 1000), + is_error: result.isError ?? false, + source: 'plugin', + }, + }); + if (result.isError) throw new Error(result.content); + return result; + }; + + return { + fs_read: async (path) => (await execute(ReadTool, { file_path: path })).content, + fs_write: async (path, content) => { + await execute(WriteTool, { file_path: path, content }); + }, + bash: async (command) => { + const result = await execute(BashTool, { command }); + const data = (result.data ?? {}) as { stderr?: string; exitCode?: number }; + return { + stdout: result.content, + stderr: data.stderr ?? '', + exitCode: data.exitCode ?? 0, + }; + }, + fetch: async (url) => (await execute(WebFetchTool, { url })).content, + }; +} diff --git a/apps/server/src/runtime-executor.test.ts b/apps/server/src/runtime-executor.test.ts new file mode 100644 index 0000000..ec50ead --- /dev/null +++ b/apps/server/src/runtime-executor.test.ts @@ -0,0 +1,494 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + RuntimeHost, + RestoreReviewActionTool, + SessionManager, + ToolRegistry, + type AgentEvent, + type Provider, + type ProviderResult, + type ProviderRunOpts, +} from '@deepcode/core'; +import type { ThreadSnapshot, TurnSnapshot } from '@deepcode/protocol'; +import { describe, expect, it, vi } from 'vitest'; + +import { + RuntimeHostExecutor, + historyFromThread, + reviewFindingsFromEvents, +} 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'] = []; + seenOptions?: ProviderRunOpts; + + async runTurn(options: ProviderRunOpts): Promise { + this.seenOptions = options; + 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('exposes only the restore tool to a canonical revert turn', async () => { + const provider = new StreamingProvider(); + const tools = new ToolRegistry(); + tools.register(RestoreReviewActionTool); + const host = new RuntimeHost({ + provider, + tools, + cwd: '/workspace', + }); + const executor = new RuntimeHostExecutor({ createHost: () => host }); + await executor.execute({ + thread, + turn: { + id: 'turn-revert', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { + text: 'revert', + reviewAction: { kind: 'revert', sourceActionId: 'turn-apply', findingIds: ['finding-1'] }, + }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + + expect(provider.seenOptions?.tools.map((tool) => tool.name)).toEqual(['RestoreReviewAction']); + }); + + it('projects validated review tool results into durable finding items', () => { + const events: AgentEvent[] = [ + { + type: 'tool_use', + id: 'finding-1', + name: 'SubmitReviewFinding', + input: {}, + }, + { + type: 'tool_result', + id: 'finding-1', + result: { + content: 'recorded', + data: { + finding: { + title: 'Null crash', + body: 'This branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + }, + }, + }, + ]; + expect(reviewFindingsFromEvents(events)).toEqual([ + { + type: 'review_finding', + payload: expect.objectContaining({ findingId: 'finding-1', path: 'src/a.ts' }), + }, + ]); + }); + + 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('uses per-turn composition defaults and always releases the host lease', async () => { + const provider = new StreamingProvider(); + const close = vi.fn(); + const prepareUserMessage = vi.fn(async () => ({ + text: 'composed user message', + diagnostics: [ + { + source: 'mcp', + code: 'mcp_resource_failed', + severity: 'warning' as const, + message: 'bad ref', + }, + ], + })); + const executor = new RuntimeHostExecutor({ + createHost: () => ({ + host: new RuntimeHost({ provider, tools: new ToolRegistry(), cwd: '/workspace' }), + systemPrompt: 'composed instructions', + model: 'deepseek-reasoner', + effort: 'low', + diagnostics: [ + { source: 'mcp', code: 'mcp_connect_failed', severity: 'warning', message: 'offline' }, + ], + prepareUserMessage, + close, + }), + }); + + const result = await executor.execute({ + thread: { ...thread, turns: [] }, + turn: { + id: 'turn-lease', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { text: 'use composition' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + + expect(provider.seenOptions).toEqual( + expect.objectContaining({ + systemPrompt: 'composed instructions', + model: 'deepseek-reasoner', + maxTokens: 1_500, + }), + ); + expect(provider.seenMessages.at(-1)).toEqual( + expect.objectContaining({ + role: 'user', + content: [{ type: 'text', text: 'composed user message' }], + }), + ); + expect(result.items.filter((item) => item.type === 'error')).toHaveLength(2); + expect(prepareUserMessage).toHaveBeenCalledWith('use composition'); + expect(close).toHaveBeenCalledOnce(); + }); + + it('does not let an invalid mode suppress the trusted composed default', async () => { + const createHost = vi.fn( + (_cwd: string, _mode: string) => + new RuntimeHost({ + provider: new StreamingProvider(), + tools: new ToolRegistry(), + cwd: '/workspace', + }), + ); + const executor = new RuntimeHostExecutor({ createHost }); + + await executor.execute({ + thread: { ...thread, turns: [] }, + turn: { + id: 'turn-invalid-mode', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { text: 'use defaults', mode: 'invalid' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }); + + expect(createHost).toHaveBeenCalledWith( + '/workspace', + 'default', + expect.objectContaining({ modeExplicit: false }), + ); + }); + + it('releases the host lease when message preparation fails', async () => { + const close = vi.fn(); + const executor = new RuntimeHostExecutor({ + createHost: () => ({ + host: new RuntimeHost({ + provider: new StreamingProvider(), + tools: new ToolRegistry(), + cwd: '/workspace', + }), + prepareUserMessage: async () => { + throw new Error('resource expansion failed'); + }, + close, + }), + }); + + await expect( + executor.execute({ + thread: { ...thread, turns: [] }, + turn: { + id: 'turn-prepare-failure', + threadId: thread.id, + status: 'in_progress', + startedAt: '2026-08-01T00:00:02.000Z', + items: [], + }, + input: { text: 'expand this' }, + signal: new AbortController().signal, + publishDelta: () => undefined, + ...protocolCallbacks(), + }), + ).rejects.toThrow('resource expansion failed'); + expect(close).toHaveBeenCalledOnce(); + }); + + 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(); + const snapshots = await sessions.snapshots('thread-snapshots'); + expect(snapshots).toHaveLength(2); + expect(snapshots.every((snapshot) => snapshot.turnId === 'turn-snapshots')).toBe(true); + } 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..17720e8 --- /dev/null +++ b/apps/server/src/runtime-executor.ts @@ -0,0 +1,285 @@ +import { + type AgentEvent, + type Effort, + type Mode, + type ReviewFinding, + 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, + context: RuntimeHostCreationContext, + ) => Promise | RuntimeHost | RuntimeHostLease; + systemPrompt?: string; + model?: string; + sessionManager?: SessionManager; +} + +export interface RuntimeHostCreationContext { + modeExplicit: boolean; + signal: AbortSignal; + requestApproval: (toolName: string, reason: string) => Promise<'allow' | 'deny' | 'always'>; + reviewAction: { kind: 'apply' | 'revert' } | null; +} + +export interface RuntimeHostLease { + host: RuntimeHost; + systemPrompt?: string; + model?: string; + effort?: Effort; + diagnostics?: Array<{ + source: string; + code: string; + severity: 'warning' | 'error'; + message: string; + }>; + prepareUserMessage?: (text: string) => Promise<{ + text: string; + diagnostics: Array<{ + source: string; + code: string; + severity: 'warning' | 'error'; + message: string; + }>; + }>; + close?: () => Promise | void; +} + +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 requestedMode = args.input.mode; + const modeExplicit = isMode(requestedMode); + const mode: Mode = modeExplicit ? requestedMode : 'default'; + const reviewAction = readReviewAction(args.input.reviewAction); + const interactionItems: TurnExecutionItem[] = []; + const requestApproval = async (toolName: string, reason: string) => { + const decision = await args.requestApproval(toolName, reason); + interactionItems.push({ + type: 'approval', + payload: { toolName, decision, reason }, + }); + return decision; + }; + const created = await this.options.createHost(args.thread.cwd, mode, { + modeExplicit, + signal: args.signal, + requestApproval, + reviewAction, + }); + const lease: RuntimeHostLease = 'host' in created ? created : { host: created }; + try { + const history = historyFromThread(args.thread); + const baselineLength = history.length; + let text = typeof args.input.text === 'string' ? args.input.text : JSON.stringify(args.input); + for (const diagnostic of lease.diagnostics ?? []) { + interactionItems.push({ type: 'error', payload: diagnostic }); + } + if (lease.prepareUserMessage) { + const prepared = await lease.prepareUserMessage(text); + text = prepared.text; + for (const diagnostic of prepared.diagnostics) { + interactionItems.push({ type: 'error', payload: diagnostic }); + } + } + const streamingItemId = `${args.turn.id}-assistant`; + const events: AgentEvent[] = []; + const effort = parseEffort(args.input.effort ?? lease.effort); + const effortParams = EFFORT_PARAMS[effort]; + const result = await lease.host.run({ + cwd: args.thread.cwd, + systemPrompt: lease.systemPrompt ?? this.options.systemPrompt ?? DEFAULT_SYSTEM_PROMPT, + userMessage: text, + history, + model: + typeof args.input.model === 'string' + ? args.input.model + : (lease.model ?? this.options.model ?? 'deepseek-chat'), + maxTokens: effortParams.maxTokens, + temperature: effortParams.temperature, + ...(reviewAction?.kind === 'revert' ? { allowedTools: ['RestoreReviewAction'] } : {}), + signal: args.signal, + session: this.options.sessionManager + ? { manager: this.options.sessionManager, id: args.thread.id, turnId: args.turn.id } + : undefined, + persistSessionMessages: false, + systemReminders: false, + approval: async (toolName, _input, verdict) => { + const decision = await requestApproval( + toolName, + verdict.reason ?? `Approve ${toolName}?`, + ); + 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, + ...reviewFindingsFromEvents(events), + ...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), + }; + } finally { + await lease.close?.(); + } + } +} + +function readReviewAction(value: unknown): { kind: 'apply' | 'revert' } | null { + if (!value || typeof value !== 'object') return null; + const kind = (value as { kind?: unknown }).kind; + return kind === 'apply' || kind === 'revert' ? { kind } : null; +} + +export function reviewFindingsFromEvents(events: AgentEvent[]): TurnExecutionItem[] { + const calls = new Map>(); + const items: TurnExecutionItem[] = []; + for (const event of events) { + if (event.type === 'tool_use' && event.name === 'SubmitReviewFinding') { + calls.set(event.id, event.input); + } else if (event.type === 'tool_result' && calls.has(event.id) && !event.result.isError) { + const finding = event.result.data?.finding; + if (isReviewFinding(finding)) { + items.push({ + type: 'review_finding', + payload: { findingId: event.id, ...finding }, + }); + } + calls.delete(event.id); + } + } + return items; +} + +function isReviewFinding(value: unknown): value is ReviewFinding { + if (!value || typeof value !== 'object') return false; + const finding = value as Partial; + return ( + typeof finding.title === 'string' && + typeof finding.body === 'string' && + typeof finding.path === 'string' && + Number.isInteger(finding.startLine) && + Number.isInteger(finding.endLine) && + Number.isInteger(finding.priority) + ); +} + +const MODES = new Set([ + 'default', + 'acceptEdits', + 'plan', + 'auto', + 'dontAsk', + 'bypassPermissions', +]); +const EFFORTS = new Set(['low', 'medium', 'high', 'xhigh', 'max']); + +function isMode(value: unknown): value is Mode { + return typeof value === 'string' && MODES.has(value as Mode); +} + +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..988b797 --- /dev/null +++ b/apps/server/src/server.test.ts @@ -0,0 +1,498 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ProtocolEvent, ProtocolRequest, ThreadSnapshot } from '@deepcode/protocol'; +import { afterEach, describe, expect, it, vi } 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('resolves review actions from canonical findings and persists their turn association', async () => { + const executor: TurnExecutor = { + execute: vi + .fn() + .mockResolvedValueOnce({ + items: [ + { + type: 'review_finding', + payload: { + findingId: 'finding-1', + title: 'Null crash', + body: 'The branch dereferences null.', + path: 'src/a.ts', + startLine: 4, + endLine: 4, + priority: 1, + }, + }, + ], + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}), + }; + const server = new AppServer({ executor, ...deterministicOptions() }); + const started = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (started.result as { id: string }).id; + await server.handle(request(2, 'turn/start', { threadId, input: { text: 'review' } })); + await server.waitForIdle(); + + const applied = await server.handle( + request(3, 'review/apply', { threadId, findingIds: ['finding-1'] }), + ); + const turnId = (applied.result as { id: string }).id; + await server.waitForIdle(); + const read = await server.handle(request(4, 'thread/read', { threadId })); + const turns = (read.result as ThreadSnapshot).turns; + expect(turns.at(-1)).toEqual( + expect.objectContaining({ + id: turnId, + status: 'completed', + items: expect.arrayContaining([ + expect.objectContaining({ + type: 'user_message', + payload: expect.objectContaining({ + text: expect.stringContaining('normal editing tools'), + reviewAction: { kind: 'apply', findingIds: ['finding-1'] }, + }), + }), + expect.objectContaining({ + type: 'review_action', + payload: { actionId: turnId, kind: 'apply', findingIds: ['finding-1'] }, + }), + ]), + }), + ); + + const reverted = await server.handle( + request(5, 'review/revert', { threadId, actionId: turnId }), + ); + const revertTurnId = (reverted.result as { id: string }).id; + await server.waitForIdle(); + const afterRevert = await server.handle(request(6, 'thread/read', { threadId })); + expect((afterRevert.result as ThreadSnapshot).turns.at(-1)).toEqual( + expect.objectContaining({ + id: revertTurnId, + items: expect.arrayContaining([ + expect.objectContaining({ + type: 'user_message', + payload: expect.objectContaining({ + text: expect.stringContaining('RestoreReviewAction exactly once'), + reviewAction: { + kind: 'revert', + sourceActionId: turnId, + findingIds: ['finding-1'], + }, + }), + }), + expect.objectContaining({ + type: 'review_action', + payload: { + actionId: revertTurnId, + kind: 'revert', + sourceActionId: turnId, + findingIds: ['finding-1'], + }, + }), + ]), + }), + ); + }); + + it('rejects unknown findings, duplicate batches, and direct review metadata injection', async () => { + const server = new AppServer({ executor: { execute: async () => ({}) } }); + const started = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (started.result as { id: string }).id; + + await expect( + server.handle(request(2, 'review/apply', { threadId, findingIds: ['missing'] })), + ).resolves.toEqual({ + id: 2, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + await expect( + server.handle(request(3, 'review/apply', { threadId, findingIds: ['same', 'same'] })), + ).resolves.toEqual({ + id: 3, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + await expect( + server.handle( + request(4, 'turn/start', { + threadId, + input: { text: 'forge', reviewAction: { kind: 'apply', findingIds: ['missing'] } }, + }), + ), + ).resolves.toEqual({ + id: 4, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + await expect( + server.handle(request(5, 'review/revert', { threadId, actionId: 'missing' })), + ).resolves.toEqual({ + id: 5, + error: expect.objectContaining({ code: 'invalid_request' }), + }); + }); + + it('binds workspace diff reads to the canonical thread cwd', async () => { + const workspaceDiff = vi.fn(async () => ({ + repository: true as const, + base: 'HEAD' as const, + files: [], + truncated: false, + })); + const server = new AppServer({ + executor: { execute: async () => ({}) }, + workspaceDiff, + }); + const thread = await server.handle(request(1, 'thread/start', { cwd: '/workspace' })); + const threadId = (thread.result as { id: string }).id; + await expect(server.handle(request(2, 'workspace/diff', { threadId }))).resolves.toEqual({ + id: 2, + result: expect.objectContaining({ repository: true }), + }); + expect(workspaceDiff).toHaveBeenCalledWith('/workspace'); + }); + + it('does not let a tracing sink change protocol behavior', async () => { + const server = new AppServer({ + executor: { execute: async () => ({}) }, + onTrace: () => { + throw new Error('trace sink failed'); + }, + }); + + await expect(server.handle(request(1, 'initialize'))).resolves.toEqual({ + id: 1, + result: expect.objectContaining({ protocolVersion: 1 }), + }); + }); + + 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' } }), + ); + const traceId = (started.result as { traceId: string }).traceId; + expect(traceId).toMatch(/^trace-/); + 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( + events + .filter( + (event) => + ('turnId' in event && event.turnId === 'turn-3') || + ('turn' in event && event.turn.id === 'turn-3'), + ) + .every((event) => event.traceId === traceId), + ).toBe(true); + 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..5dfe261 --- /dev/null +++ b/apps/server/src/server.ts @@ -0,0 +1,677 @@ +import { + isReviewFindingPayload, + MemoryThreadStore, + ProtocolInvariantError, + ProtocolRuntime, + reviewApplyManyPrompt, + reviewRevertPrompt, + type CompletedItemType, + type ConfigDiagnosticsResult, + type DiagnosticExportResult, + type ProtocolEvent, + type ProtocolRequest, + type ProtocolResponse, + type ReviewActionPayload, + type ReviewActionRequest, + type ReviewFindingPayload, + type ThreadSnapshot, + type ThreadStore, + type TurnSnapshot, + type WorkspaceDiffResult, +} 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; + newTraceId?: () => string; + onEvent?: (event: ProtocolEvent) => void; + onTrace?: (record: AppServerTraceRecord) => void; + configDiagnostics?: (cwd: string) => Promise; + diagnosticExport?: (cwd: string) => Promise; + workspaceDiff?: (cwd: string) => Promise; +} + +/** Strictly metadata-only records; no prompt, tool payload, command, or error message. */ +export interface AppServerTraceRecord { + event: string; + traceId: string; + protocolRequestId?: string | number; + method?: string; + threadId?: string; + turnId?: string; + itemId?: string; + status?: string; + code?: string; + durationMs?: number; +} + +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; + private readonly newTraceId: () => string; + + constructor(private readonly options: AppServerOptions) { + this.newTraceId = + options.newTraceId ?? + (() => `trace-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`); + this.lifecycle = new ProtocolRuntime({ + store: options.store ?? new MemoryThreadStore(), + now: options.now, + newId: options.newId, + newTraceId: this.newTraceId, + onEvent: options.onEvent, + configDiagnostics: options.configDiagnostics !== undefined, + diagnosticExport: options.diagnosticExport !== undefined, + workspaceDiff: options.workspaceDiff !== undefined, + reviewActions: true, + }); + } + + async handle(request: ProtocolRequest): Promise { + const traceId = this.newTraceId(); + const startedAt = Date.now(); + this.trace({ + event: 'protocol.request.started', + traceId, + protocolRequestId: request.id, + method: request.method, + }); + try { + const result = await this.dispatch(request, traceId); + this.trace({ + event: 'protocol.request.completed', + traceId, + protocolRequestId: request.id, + method: request.method, + status: 'ok', + durationMs: Date.now() - startedAt, + }); + return { id: request.id, result }; + } catch (error) { + const code = + error instanceof ProtocolInvariantError + ? 'invalid_state' + : error instanceof RequestValidationError + ? 'invalid_request' + : 'internal_error'; + this.trace({ + event: 'protocol.request.failed', + traceId, + protocolRequestId: request.id, + method: request.method, + status: 'error', + code, + durationMs: Date.now() - startedAt, + }); + 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, traceId: string): 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 'diagnostics/export': + if (!this.options.diagnosticExport) { + throw new RequestValidationError('Diagnostic export is not available'); + } + return this.options.diagnosticExport(requiredString(request.params, 'cwd')); + case 'workspace/diff': { + if (!this.options.workspaceDiff) { + throw new RequestValidationError('Workspace diff is not available'); + } + const thread = await this.lifecycle.resumeThread(requiredId(request.params, 'threadId')); + return this.options.workspaceDiff(thread.cwd); + } + case 'review/apply': + return this.applyReviewFindings(request.params, traceId); + case 'review/revert': + return this.revertReviewAction(request.params, traceId); + case 'thread/start': + return this.lifecycle.startThread(requiredString(request.params, 'cwd'), traceId); + 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, traceId); + 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 applyReviewFindings( + params: Record, + traceId: string, + ): Promise { + const threadId = requiredId(params, 'threadId'); + const findingIds = requiredIds(params, 'findingIds', 20); + const thread = await this.lifecycle.resumeThread(threadId); + const findings = new Map(); + for (const turn of thread.turns) { + for (const item of turn.items) { + if (item.type === 'review_finding' && isReviewFindingPayload(item.payload)) { + findings.set(item.payload.findingId, item.payload); + } + } + } + const selected = findingIds.map((findingId) => { + const finding = findings.get(findingId); + if (!finding) throw new RequestValidationError(`Review finding not found: ${findingId}`); + return finding; + }); + const action: ReviewActionRequest = { kind: 'apply', findingIds }; + return this.startTurn( + { + threadId, + input: { text: reviewApplyManyPrompt(selected), reviewAction: action }, + }, + traceId, + action, + ); + } + + private async revertReviewAction( + params: Record, + traceId: string, + ): Promise { + const threadId = requiredId(params, 'threadId'); + const sourceActionId = requiredId(params, 'actionId'); + const thread = await this.lifecycle.resumeThread(threadId); + const sourceTurn = thread.turns.find((turn) => turn.id === sourceActionId); + if (!sourceTurn || sourceTurn.status !== 'completed') { + throw new RequestValidationError(`Completed review action not found: ${sourceActionId}`); + } + const sourceAction = sourceTurn.items + .filter((item) => item.type === 'review_action') + .map((item) => item.payload) + .find(isApplyReviewAction); + if (!sourceAction || sourceAction.actionId !== sourceActionId) { + throw new RequestValidationError(`Applied review action not found: ${sourceActionId}`); + } + const action: ReviewActionRequest = { + kind: 'revert', + sourceActionId, + findingIds: sourceAction.findingIds, + }; + return this.startTurn( + { + threadId, + input: { + text: reviewRevertPrompt(sourceActionId, sourceAction.findingIds), + reviewAction: action, + }, + }, + traceId, + action, + ); + } + + private async startTurn( + params: Record, + traceId: string, + reviewAction?: ReviewActionRequest, + ): Promise { + const threadId = requiredId(params, 'threadId'); + const input = requiredRecord(params, 'input'); + if (!reviewAction && Object.hasOwn(input, 'reviewAction')) { + throw new RequestValidationError('reviewAction is reserved for app-server review methods'); + } + const thread = await this.lifecycle.resumeThread(threadId); + const turn = await this.lifecycle.startTurn(threadId, input, traceId); + if (reviewAction) { + await this.lifecycle.appendCompletedItem(threadId, turn.id, 'review_action', { + actionId: turn.id, + ...reviewAction, + }); + } + 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 { + const traceId = turn.traceId ?? this.newTraceId(); + const startedAt = Date.now(); + this.trace({ + event: 'turn.execution.started', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'in_progress', + }); + try { + const result = await this.options.executor.execute({ + thread, + turn, + input, + signal: controller.signal, + publishDelta: (itemId, delta) => { + this.lifecycle.publishDelta({ + traceId, + threadId: thread.id, + turnId: turn.id, + itemId, + delta, + }); + }, + publishToolStarted: (itemId, name, input) => { + this.options.onEvent?.({ + type: 'tool.started', + traceId, + threadId: thread.id, + turnId: turn.id, + itemId, + name, + input, + }); + }, + publishToolCompleted: (itemId, result) => { + this.options.onEvent?.({ + type: 'tool.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + itemId, + result, + }); + }, + publishUsage: (usage) => { + this.options.onEvent?.({ + type: 'usage.updated', + traceId, + threadId: thread.id, + turnId: turn.id, + usage, + }); + }, + requestApproval: (toolName, reason) => + this.requestApproval(traceId, thread.id, turn.id, toolName, reason), + requestUserInput: (request) => this.requestUserInput(traceId, thread.id, turn.id, request), + }); + if (controller.signal.aborted) { + await this.finishOnce(turn.id, () => this.lifecycle.interruptTurn(thread.id, turn.id)); + this.trace({ + event: 'turn.execution.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'interrupted', + durationMs: Date.now() - startedAt, + }); + 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)); + this.trace({ + event: 'turn.execution.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'failed', + durationMs: Date.now() - startedAt, + }); + } else { + await this.finishOnce(turn.id, () => this.lifecycle.completeTurn(thread.id, turn.id)); + this.trace({ + event: 'turn.execution.completed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: 'completed', + durationMs: Date.now() - startedAt, + }); + } + } 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)); + } + const interrupted = controller.signal.aborted || (error as Error).name === 'AbortError'; + this.trace({ + event: interrupted ? 'turn.execution.completed' : 'turn.execution.failed', + traceId, + threadId: thread.id, + turnId: turn.id, + status: interrupted ? 'interrupted' : 'failed', + code: errorCode(error), + durationMs: Date.now() - startedAt, + }); + } finally { + this.cancelInteractions(turn.id); + this.activeTurns.delete(turn.id); + } + } + + private requestApproval( + traceId: string, + 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', + traceId, + threadId, + turnId, + requestId, + toolName, + reason, + }); + return response; + } + + private requestUserInput( + traceId: string, + 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', + traceId, + 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 trace(record: AppServerTraceRecord): void { + try { + this.options.onTrace?.(record); + } catch { + // Observability must never change protocol or execution behavior. + } + } + + 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 errorCode(error: unknown): string { + if (error instanceof ProtocolInvariantError) return 'invalid_state'; + if (error instanceof RequestValidationError) return 'invalid_request'; + if (error instanceof Error && error.name === 'AbortError') return 'aborted'; + return 'internal_error'; +} + +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 (value.length > 200 || !/^[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; +} + +function requiredIds(params: Record, key: string, maximum: number): string[] { + const value = params[key]; + if (!Array.isArray(value) || value.length === 0 || value.length > maximum) { + throw new RequestValidationError(`${key} must contain between 1 and ${maximum} ids`); + } + const ids = value.map((entry) => { + if (typeof entry !== 'string' || !/^[a-zA-Z0-9._-]{1,200}$/.test(entry)) { + throw new RequestValidationError(`${key} contains an invalid id`); + } + return entry; + }); + if (new Set(ids).size !== ids.length) { + throw new RequestValidationError(`${key} must not contain duplicate ids`); + } + return ids; +} + +function isApplyReviewAction(value: Record): value is ReviewActionPayload & { + kind: 'apply'; +} { + return ( + value.kind === 'apply' && + typeof value.actionId === 'string' && + Array.isArray(value.findingIds) && + value.findingIds.length > 0 && + value.findingIds.length <= 20 && + new Set(value.findingIds).size === value.findingIds.length && + value.findingIds.every( + (findingId) => typeof findingId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(findingId), + ) + ); +} 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/src/structured-logger.test.ts b/apps/server/src/structured-logger.test.ts new file mode 100644 index 0000000..f1b63e6 --- /dev/null +++ b/apps/server/src/structured-logger.test.ts @@ -0,0 +1,127 @@ +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { exportDiagnosticBundle } from './diagnostic-export.js'; +import { StructuredLogger } from './structured-logger.js'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true }))); + roots.length = 0; +}); + +async function temporaryRoot(): Promise { + const root = await mkdtemp(join(tmpdir(), 'deepcode-trace-')); + roots.push(root); + return root; +} + +describe('StructuredLogger', () => { + it('persists only allowlisted metadata even when a caller passes secrets', async () => { + const root = await temporaryRoot(); + const logger = new StructuredLogger({ + directory: join(root, 'logs'), + now: () => '2026-08-01T00:00:00.000Z', + }); + logger.record({ + event: 'protocol.request.failed', + traceId: 'trace-1', + method: 'turn/start', + code: 'internal_error', + prompt: 'SECRET_PROMPT', + command: 'curl -H Authorization:SECRET_HEADER', + message: 'SECRET_ERROR', + } as never); + logger.recordProtocolEvent({ + type: 'tool.started', + traceId: 'trace-1', + threadId: 'thread-1', + turnId: 'turn-1', + itemId: 'item-1', + name: 'Bash', + input: { command: 'SECRET_TOOL_INPUT' }, + }); + await logger.flush(); + + const contents = await readFile(logger.path, 'utf8'); + expect(contents).toContain('trace-1'); + expect(contents).toContain('tool.started'); + expect(contents).not.toMatch(/SECRET_|Authorization|curl/); + expect((await stat(logger.path)).mode & 0o777).toBe(0o600); + }); + + it('rotates bounded log files', async () => { + const root = await temporaryRoot(); + const logger = new StructuredLogger({ + directory: join(root, 'logs'), + maxBytes: 1, + retainedFiles: 2, + }); + logger.record({ event: 'first', traceId: 'trace-1' }); + logger.record({ event: 'second', traceId: 'trace-2' }); + logger.record({ event: 'third', traceId: 'trace-3' }); + await logger.flush(); + + await expect(readFile(logger.path, 'utf8')).resolves.toContain('trace-3'); + await expect(readFile(`${logger.path}.1`, 'utf8')).resolves.toContain('trace-2'); + await expect(readFile(`${logger.path}.2`, 'utf8')).resolves.toContain('trace-1'); + }); +}); + +describe('exportDiagnosticBundle', () => { + it('hashes paths and re-sanitizes stored log records', async () => { + const root = await temporaryRoot(); + const cwd = join(root, 'secret-customer-workspace'); + const sourcePath = join(cwd, '.deepcode', 'settings.json'); + const logPath = join(root, 'malicious.ndjson'); + await writeFile( + logPath, + `${JSON.stringify({ + timestamp: '2026-08-01T00:00:00.000Z', + level: 'info', + event: 'protocol.event', + traceId: 'trace-1', + method: 'SECRET_METHOD', + threadId: 'SECRET_THREAD_ID', + status: 'SECRET_STATUS', + message: 'SECRET_LOG_MESSAGE', + payload: { token: 'SECRET_TOKEN' }, + })}\n`, + ); + const config: ConfigDiagnosticsResult = { + cwd, + trustStatus: 'untrusted', + layers: [{ layer: 'project', path: sourcePath, present: true, trusted: false }], + provenance: { '/model': { layer: 'project', path: sourcePath } }, + gated: ['/permissions'], + issues: [ + { + severity: 'warning', + code: 'secret_setting', + message: 'SECRET_ISSUE_MESSAGE', + source: { layer: 'project', path: sourcePath }, + }, + ], + }; + + const result = await exportDiagnosticBundle({ + home: root, + cwd, + config, + logPath, + generatedAt: '2026-08-01T00:00:00.000Z', + }); + const contents = await readFile(result.path, 'utf8'); + expect(result.recordCount).toBe(1); + expect(contents).toContain('secret_setting'); + expect(contents).not.toMatch( + /secret-customer-workspace|settings\.json|SECRET_|SECRET_TOKEN|SECRET_ISSUE_MESSAGE/, + ); + expect((await stat(result.path)).mode & 0o777).toBe(0o600); + }); +}); diff --git a/apps/server/src/structured-logger.ts b/apps/server/src/structured-logger.ts new file mode 100644 index 0000000..5dab24a --- /dev/null +++ b/apps/server/src/structured-logger.ts @@ -0,0 +1,272 @@ +import { createHash } from 'node:crypto'; +import { appendFile, chmod, mkdir, rename, stat, unlink } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import type { ProtocolEvent } from '@deepcode/protocol'; + +import type { AppServerTraceRecord } from './server.js'; + +export interface StructuredLoggerOptions { + directory: string; + now?: () => string; + maxBytes?: number; + retainedFiles?: number; +} + +export interface StructuredLogRecord extends AppServerTraceRecord { + schemaVersion: 1; + timestamp: string; + level: 'info' | 'warning' | 'error'; +} + +const DEFAULT_MAX_BYTES = 5 * 1024 * 1024; +const DEFAULT_RETAINED_FILES = 3; +const TRACE_EVENTS = new Set([ + 'protocol.request.started', + 'protocol.request.completed', + 'protocol.request.failed', + 'protocol.event', + 'turn.execution.started', + 'turn.execution.completed', + 'turn.execution.failed', +]); +const PROTOCOL_METHODS = new Set([ + 'initialize', + 'config/diagnostics', + 'diagnostics/export', + 'workspace/diff', + 'thread/start', + 'thread/read', + 'thread/resume', + 'turn/start', + 'turn/interrupt', + 'approval/respond', + 'user-input/respond', +]); +const STATUSES = new Set([ + 'ok', + 'error', + 'in_progress', + 'completed', + 'failed', + 'interrupted', + 'thread.started', + 'turn.started', + 'item.completed', + 'turn.completed', + 'turn.interrupted', + 'turn.failed', + 'item.delta', + 'tool.started', + 'tool.completed', + 'usage.updated', + 'approval.requested', + 'user-input.requested', +]); +const ERROR_CODES = new Set(['invalid_state', 'invalid_request', 'aborted', 'internal_error']); + +/** + * Bounded, best-effort NDJSON logging for the app-server trust boundary. + * `record` rebuilds a value from a strict allowlist instead of serializing its + * argument, so prompts, tool payloads, commands, and error messages cannot be + * persisted accidentally. + */ +export class StructuredLogger { + readonly path: string; + private readonly now: () => string; + private readonly maxBytes: number; + private readonly retainedFiles: number; + private tail = Promise.resolve(); + private lastError: Error | undefined; + + constructor(options: StructuredLoggerOptions) { + this.path = join(options.directory, 'app-server.ndjson'); + this.now = options.now ?? (() => new Date().toISOString()); + this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES; + this.retainedFiles = options.retainedFiles ?? DEFAULT_RETAINED_FILES; + } + + record(input: AppServerTraceRecord, level: StructuredLogRecord['level'] = 'info'): void { + const record = normalizeRecord(input, level, this.now()); + const line = `${JSON.stringify(record)}\n`; + this.tail = this.tail + .then(() => this.append(line)) + .catch((error: unknown) => { + this.lastError = error instanceof Error ? error : new Error(String(error)); + }); + } + + recordProtocolEvent(event: ProtocolEvent): void { + const record = traceRecordForProtocolEvent(event); + if (record) this.record(record); + } + + async flush(): Promise { + await this.tail; + } + + error(): Error | undefined { + return this.lastError; + } + + private async append(line: string): Promise { + await mkdir(dirname(this.path), { recursive: true, mode: 0o700 }); + await this.rotateIfNeeded(Buffer.byteLength(line)); + await appendFile(this.path, line, { encoding: 'utf8', mode: 0o600 }); + await chmod(this.path, 0o600); + } + + private async rotateIfNeeded(incomingBytes: number): Promise { + let currentBytes = 0; + try { + currentBytes = (await stat(this.path)).size; + } catch (error) { + if (!isMissing(error)) throw error; + } + if (currentBytes === 0 || currentBytes + incomingBytes <= this.maxBytes) return; + + if (this.retainedFiles > 0) { + await ignoreMissing(() => unlink(`${this.path}.${this.retainedFiles}`)); + for (let index = this.retainedFiles - 1; index >= 1; index--) { + await ignoreMissing(() => rename(`${this.path}.${index}`, `${this.path}.${index + 1}`)); + } + await ignoreMissing(() => rename(this.path, `${this.path}.1`)); + } else { + await ignoreMissing(() => unlink(this.path)); + } + } +} + +function normalizeRecord( + input: AppServerTraceRecord, + level: StructuredLogRecord['level'], + timestamp: string, +): StructuredLogRecord { + const record: StructuredLogRecord = { + schemaVersion: 1, + timestamp, + level, + event: allowedValue(input.event, TRACE_EVENTS), + traceId: safeIdentifier(input.traceId, ['trace-']), + }; + if ( + typeof input.protocolRequestId === 'number' && + Number.isSafeInteger(input.protocolRequestId) + ) { + record.protocolRequestId = input.protocolRequestId; + } else if (typeof input.protocolRequestId === 'string') { + record.protocolRequestId = opaqueIdentifier(input.protocolRequestId); + } + if (input.method) record.method = allowedValue(input.method, PROTOCOL_METHODS); + if (input.threadId) record.threadId = safeIdentifier(input.threadId, ['thread-', 'legacy-']); + if (input.turnId) record.turnId = safeIdentifier(input.turnId, ['turn-', 'legacy-']); + if (input.itemId) { + record.itemId = safeIdentifier(input.itemId, ['item-', 'call_', 'tool-', 'legacy-item-']); + } + if (input.status) record.status = allowedValue(input.status, STATUSES); + if (input.code) record.code = allowedValue(input.code, ERROR_CODES); + if (typeof input.durationMs === 'number' && Number.isFinite(input.durationMs)) { + record.durationMs = Math.max(0, Math.round(input.durationMs)); + } + return record; +} + +/** Rebuild a record read from disk through the same strict schema used on write. */ +export function sanitizeStructuredLogRecord(value: Record): StructuredLogRecord { + const input: AppServerTraceRecord = { + event: typeof value.event === 'string' ? value.event : 'unknown', + traceId: typeof value.traceId === 'string' ? value.traceId : 'unknown', + }; + if ( + typeof value.protocolRequestId === 'string' || + (typeof value.protocolRequestId === 'number' && Number.isSafeInteger(value.protocolRequestId)) + ) { + input.protocolRequestId = value.protocolRequestId; + } + for (const key of ['method', 'threadId', 'turnId', 'itemId', 'status', 'code'] as const) { + if (typeof value[key] === 'string') input[key] = value[key]; + } + if (typeof value.durationMs === 'number' && Number.isFinite(value.durationMs)) { + input.durationMs = value.durationMs; + } + const level = + value.level === 'warning' || value.level === 'error' || value.level === 'info' + ? value.level + : 'error'; + return normalizeRecord(input, level, safeTimestamp(value.timestamp)); +} + +function allowedValue(value: string, choices: ReadonlySet): string { + return choices.has(value) ? value : 'unknown'; +} + +function safeIdentifier(value: string, prefixes: string[]): string { + if ( + value.length <= 160 && + /^[a-zA-Z0-9._-]+$/.test(value) && + prefixes.some((prefix) => value.startsWith(prefix)) + ) { + return value; + } + return opaqueIdentifier(value); +} + +function opaqueIdentifier(value: string): string { + return `hash-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`; +} + +function safeTimestamp(value: unknown): string { + if (typeof value !== 'string') return 'unknown'; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? new Date(parsed).toISOString() : 'unknown'; +} + +function traceRecordForProtocolEvent(event: ProtocolEvent): AppServerTraceRecord | null { + const traceId = event.traceId; + if (!traceId) return null; + switch (event.type) { + case 'thread.started': + return { event: 'protocol.event', traceId, threadId: event.thread.id, status: event.type }; + case 'turn.started': + case 'turn.completed': + case 'turn.interrupted': + case 'turn.failed': + return { + event: 'protocol.event', + traceId, + threadId: event.threadId, + turnId: event.turn.id, + status: event.type, + }; + case 'item.completed': + return { + event: 'protocol.event', + traceId, + threadId: event.threadId, + turnId: event.turnId, + itemId: event.item.id, + status: event.type, + }; + default: + return { + event: 'protocol.event', + traceId, + threadId: event.threadId, + turnId: event.turnId, + itemId: 'itemId' in event ? event.itemId : undefined, + status: event.type, + }; + } +} + +async function ignoreMissing(action: () => Promise): Promise { + try { + await action(); + } catch (error) { + if (!isMissing(error)) throw error; + } +} + +function isMissing(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} diff --git a/apps/server/src/workspace-diff.test.ts b/apps/server/src/workspace-diff.test.ts new file mode 100644 index 0000000..7a450cc --- /dev/null +++ b/apps/server/src/workspace-diff.test.ts @@ -0,0 +1,91 @@ +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { collectWorkspaceDiff } from './workspace-diff.js'; + +const exec = promisify(execFile); +let root: string | undefined; + +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +async function repository(): Promise { + root = await mkdtemp(join(tmpdir(), 'deepcode-workspace-diff-')); + await exec('git', ['init', '-q'], { cwd: root }); + await exec('git', ['config', 'user.email', 'deepcode@example.invalid'], { cwd: root }); + await exec('git', ['config', 'user.name', 'DeepCode Test'], { cwd: root }); + await writeFile(join(root, 'modify me.txt'), 'one\ntwo\nthree\n'); + await writeFile(join(root, 'delete.ts'), 'delete me\n'); + await writeFile(join(root, 'rename-old.ts'), 'rename me\n'); + await exec('git', ['add', '.'], { cwd: root }); + await exec('git', ['commit', '-qm', 'initial'], { cwd: root }); + return root; +} + +describe('collectWorkspaceDiff', () => { + it('returns structured tracked and untracked hunks without shell parsing', async () => { + const cwd = await repository(); + await writeFile(join(cwd, 'modify me.txt'), 'one\nchanged\nthree\n'); + await rm(join(cwd, 'delete.ts')); + await mkdir(join(cwd, 'new dir')); + await writeFile(join(cwd, 'new dir', 'new.ts'), 'export const value = 1;\n'); + await exec('git', ['mv', 'rename-old.ts', 'renamed.ts'], { cwd }); + + const diff = await collectWorkspaceDiff(cwd); + expect(diff).toMatchObject({ repository: true, base: 'HEAD', truncated: false }); + expect(diff.files.map((file) => [file.path, file.status])).toEqual([ + ['delete.ts', 'deleted'], + ['modify me.txt', 'modified'], + ['new dir/new.ts', 'added'], + ['renamed.ts', 'renamed'], + ]); + expect(diff.files.find((file) => file.path === 'renamed.ts')?.previousPath).toBe( + 'rename-old.ts', + ); + expect(diff.files.find((file) => file.path === 'modify me.txt')).toMatchObject({ + additions: 1, + deletions: 1, + binary: false, + hunks: [ + expect.objectContaining({ + lines: expect.arrayContaining([ + { kind: 'deletion', oldLine: 2, text: 'two' }, + { kind: 'addition', newLine: 2, text: 'changed' }, + ]), + }), + ], + }); + }); + + it('does not read untracked symlinks or binary contents', async () => { + const cwd = await repository(); + await writeFile(join(cwd, 'binary.bin'), Buffer.from([0, 1, 2, 3])); + await import('node:fs/promises').then(({ symlink }) => + symlink('/etc/passwd', join(cwd, 'outside-link')), + ); + + const diff = await collectWorkspaceDiff(cwd); + expect(diff.files.find((file) => file.path === 'binary.bin')).toMatchObject({ binary: true }); + expect(diff.files.find((file) => file.path === 'outside-link')).toMatchObject({ + binary: true, + hunks: [], + }); + }); + + it('returns an empty non-repository result outside git', async () => { + root = await mkdtemp(join(tmpdir(), 'deepcode-no-git-')); + await expect(collectWorkspaceDiff(root)).resolves.toEqual({ + repository: false, + base: null, + files: [], + truncated: false, + }); + }); +}); diff --git a/apps/server/src/workspace-diff.ts b/apps/server/src/workspace-diff.ts new file mode 100644 index 0000000..9b878c2 --- /dev/null +++ b/apps/server/src/workspace-diff.ts @@ -0,0 +1,208 @@ +import { execFile } from 'node:child_process'; +import { lstat, readFile } from 'node:fs/promises'; +import { isAbsolute, relative, resolve } from 'node:path'; +import { promisify } from 'node:util'; + +import type { + WorkspaceDiffFile, + WorkspaceDiffHunk, + WorkspaceDiffResult, + WorkspaceFileStatus, +} from '@deepcode/protocol'; +import { gitSpawnEnv } from '@deepcode/core'; + +const execFileAsync = promisify(execFile); +const MAX_FILES = 100; +const MAX_FILE_BYTES = 128 * 1024; +const MAX_PATCH_BYTES = 256 * 1024; +const MAX_GIT_BUFFER = 32 * 1024 * 1024; + +export async function collectWorkspaceDiff(cwd: string): Promise { + const workspace = resolve(cwd); + const status = await git(workspace, [ + 'status', + '--porcelain=v1', + '-z', + '--untracked-files=all', + '--', + ]); + if (!status.ok) return { repository: false, base: null, files: [], truncated: false }; + const hasHead = (await git(workspace, ['rev-parse', '--verify', 'HEAD'])).ok; + const entries = parseStatus(status.stdout); + const selected = entries.slice(0, MAX_FILES); + let remainingBytes = MAX_PATCH_BYTES; + let truncated = entries.length > selected.length; + const files: WorkspaceDiffFile[] = []; + + for (const entry of selected) { + let patch = ''; + let binary = false; + let fileTruncated = false; + if (entry.untracked || !hasHead) { + const captured = await addedFilePatch(workspace, entry.path, remainingBytes); + patch = captured.patch; + binary = captured.binary; + fileTruncated = captured.truncated; + } else { + const result = await git(workspace, [ + '--literal-pathspecs', + 'diff', + '--no-ext-diff', + '--no-textconv', + '--unified=3', + 'HEAD', + '--', + entry.path, + ]); + patch = result.ok ? result.stdout : ''; + binary = /(?:Binary files|GIT binary patch)/.test(patch); + if (Buffer.byteLength(patch) > remainingBytes) { + patch = truncateUtf8(patch, remainingBytes); + fileTruncated = true; + } + } + remainingBytes = Math.max(0, remainingBytes - Buffer.byteLength(patch)); + if (remainingBytes === 0) truncated = true; + const hunks = binary ? [] : parseHunks(patch); + files.push({ + path: entry.path, + previousPath: entry.previousPath, + status: entry.status, + additions: hunks.reduce( + (total, hunk) => total + hunk.lines.filter((line) => line.kind === 'addition').length, + 0, + ), + deletions: hunks.reduce( + (total, hunk) => total + hunk.lines.filter((line) => line.kind === 'deletion').length, + 0, + ), + binary, + truncated: fileTruncated, + hunks, + }); + if (remainingBytes === 0) break; + } + return { repository: true, base: hasHead ? 'HEAD' : 'empty', files, truncated }; +} + +interface StatusEntry { + path: string; + previousPath?: string; + status: WorkspaceFileStatus; + untracked: boolean; +} + +function parseStatus(raw: string): StatusEntry[] { + const fields = raw.split('\0'); + const entries: StatusEntry[] = []; + for (let index = 0; index < fields.length; index++) { + const field = fields[index]; + if (!field || field.length < 4) continue; + const code = field.slice(0, 2); + const path = field.slice(3); + let previousPath: string | undefined; + if (code.includes('R') || code.includes('C')) previousPath = fields[++index] || undefined; + entries.push({ + path, + previousPath, + status: fileStatus(code), + untracked: code === '??', + }); + } + return entries.sort((left, right) => left.path.localeCompare(right.path)); +} + +function fileStatus(code: string): WorkspaceFileStatus { + if (code === '??' || code.includes('A')) return 'added'; + if (code.includes('U') || code === 'AA' || code === 'DD') return 'conflicted'; + if (code.includes('R') || code.includes('C')) return 'renamed'; + if (code.includes('D')) return 'deleted'; + return 'modified'; +} + +async function addedFilePatch( + cwd: string, + path: string, + budget: number, +): Promise<{ patch: string; binary: boolean; truncated: boolean }> { + const absolute = resolve(cwd, path); + const relativePath = relative(cwd, absolute); + if (relativePath.startsWith('..') || isAbsolute(relativePath)) { + return { patch: '', binary: true, truncated: true }; + } + try { + const metadata = await lstat(absolute); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + return { patch: '', binary: true, truncated: false }; + } + const limit = Math.min(MAX_FILE_BYTES, budget); + const contents = await readFile(absolute); + if (contents.includes(0)) return { patch: '', binary: true, truncated: false }; + const truncated = contents.length > limit; + const text = contents.subarray(0, limit).toString('utf8'); + const lines = text.length === 0 ? [] : text.replace(/\n$/, '').split('\n'); + return { + patch: `@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join('\n')}\n`, + binary: false, + truncated, + }; + } catch { + return { patch: '', binary: true, truncated: false }; + } +} + +function parseHunks(patch: string): WorkspaceDiffHunk[] { + const hunks: WorkspaceDiffHunk[] = []; + let current: WorkspaceDiffHunk | undefined; + let oldLine = 0; + let newLine = 0; + for (const rawLine of patch.split('\n')) { + const header = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(rawLine); + if (header) { + oldLine = Number(header[1]); + newLine = Number(header[3]); + current = { + oldStart: oldLine, + oldLines: Number(header[2] ?? 1), + newStart: newLine, + newLines: Number(header[4] ?? 1), + lines: [], + }; + hunks.push(current); + continue; + } + if (!current || rawLine === '\\ No newline at end of file') continue; + if (rawLine.startsWith('+')) { + current.lines.push({ kind: 'addition', newLine: newLine++, text: rawLine.slice(1) }); + } else if (rawLine.startsWith('-')) { + current.lines.push({ kind: 'deletion', oldLine: oldLine++, text: rawLine.slice(1) }); + } else if (rawLine.startsWith(' ')) { + current.lines.push({ + kind: 'context', + oldLine: oldLine++, + newLine: newLine++, + text: rawLine.slice(1), + }); + } + } + return hunks; +} + +async function git(cwd: string, args: string[]): Promise<{ ok: boolean; stdout: string }> { + try { + const result = await execFileAsync('git', args, { + cwd, + encoding: 'utf8', + timeout: 10_000, + maxBuffer: MAX_GIT_BUFFER, + env: { ...gitSpawnEnv(), GIT_OPTIONAL_LOCKS: '0', GIT_PAGER: 'cat', LC_ALL: 'C' }, + }); + return { ok: true, stdout: result.stdout }; + } catch { + return { ok: false, stdout: '' }; + } +} + +function truncateUtf8(value: string, bytes: number): string { + return Buffer.from(value).subarray(0, Math.max(0, bytes)).toString('utf8'); +} 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..af4c913 --- /dev/null +++ b/apps/vscode/.vscodeignore @@ -0,0 +1,11 @@ +src/** +scripts/** +node_modules/** +dist/*.map +dist/release-gate-report.json +dist/*.vsix +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..0792f13 100644 --- a/apps/vscode/README.md +++ b/apps/vscode/README.md @@ -1,59 +1,68 @@ -# @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. +- Seven 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 -| ID | Default keybinding | What it does | -| -------------------- | ------------------ | --------------------------------------- | -| `deepcode.openPanel` | `Cmd/Ctrl+Shift+D` | Reveal the DeepCode chat view | -| `deepcode.run` | (palette) | Run agent on the selected text | -| `deepcode.review` | (palette) | Run `code-review` skill on current diff | +| ID | Default keybinding | What it does | +| --------------------------------- | ------------------ | -------------------------------------------------------- | +| `deepcode.openPanel` | `Cmd/Ctrl+Shift+D` | Reveal the DeepCode chat view | +| `deepcode.run` | (palette) | Run agent on the selected text | +| `deepcode.review` | (palette) | Run `code-review` skill on current diff | +| `deepcode.applyReviewFinding` | (API/context) | Apply one canonical finding through a normal agent turn | +| `deepcode.applyAllReviewFindings` | (palette) | Apply the latest review batch through one canonical turn | +| `deepcode.revertReviewAction` | (palette) | Conflict-safely revert the latest applied review action | +| `deepcode.showDiagnostics` | (palette) | Show value-free config sources, trust gates, and issues | ## 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"` | Explicit VS Code value overrides shared settings | +| `deepcode.effort` | enum | `"medium"` | Explicit value overrides shared settings | + +Credentials stay in the shared DeepCode credential store and are resolved only by the child. +Manifest defaults are not sent as turn overrides; without a user/workspace value, the app-server's +trusted `settings.json` model and effort remain authoritative. ## Roadmap -- Real `runAgent` invocation in `deepcode.run` (instead of the info popup) -- Real diff fetch via `vscode.git` API for `deepcode.review` +- Inline review comments and editor-context actions - 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..af847fe 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" @@ -19,6 +23,7 @@ "activationEvents": [ "onCommand:deepcode.openPanel", "onCommand:deepcode.run", + "onCommand:deepcode.showDiagnostics", "onStartupFinished" ], "contributes": { @@ -34,17 +39,27 @@ { "command": "deepcode.review", "title": "DeepCode: Review current diff" + }, + { + "command": "deepcode.applyReviewFinding", + "title": "DeepCode: Apply Review Finding" + }, + { + "command": "deepcode.applyAllReviewFindings", + "title": "DeepCode: Apply All Review Findings" + }, + { + "command": "deepcode.revertReviewAction", + "title": "DeepCode: Revert Latest Review Action" + }, + { + "command": "deepcode.showDiagnostics", + "title": "DeepCode: Show Configuration Diagnostics" } ], "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 +111,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..d75ccdf --- /dev/null +++ b/apps/vscode/scripts/build.mjs @@ -0,0 +1,58 @@ +import { mkdir, readFile, 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'); +const settingsSchema = await readFile( + resolve(packageRoot, '..', '..', 'packages', 'core', 'schemas', 'settings.schema.json'), + 'utf8', +); +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: { + __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), + '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', + define: { + __DEEPCODE_SETTINGS_SCHEMA__: JSON.stringify(settingsSchema), + 'import.meta.url': 'undefined', + }, + }), +]); + +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/diagnostics.test.ts b/apps/vscode/src/diagnostics.test.ts new file mode 100644 index 0000000..b1df6cd --- /dev/null +++ b/apps/vscode/src/diagnostics.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; + +import { formatConfigDiagnostics } from './diagnostics.js'; + +describe('formatConfigDiagnostics', () => { + it('renders trust, source layers, gates, and issues without configuration values', () => { + const output = formatConfigDiagnostics({ + cwd: '/workspace', + trustStatus: 'untrusted', + layers: [ + { + layer: 'project', + path: '/workspace/.deepcode/settings.json', + present: true, + trusted: false, + }, + ], + provenance: { '/env/API_TOKEN': { layer: 'project', path: '/settings.json' } }, + gated: ['env'], + issues: [ + { + severity: 'warning', + code: 'untrusted_setting_gated', + message: 'Ignored project setting /env', + }, + ], + }).join('\n'); + + expect(output).toContain('Trust: untrusted'); + expect(output).toContain('project untrusted'); + expect(output).toContain('Gated fields: env'); + expect(output).toContain('WARNING untrusted_setting_gated'); + expect(output).not.toContain('secret-value'); + }); +}); diff --git a/apps/vscode/src/diagnostics.ts b/apps/vscode/src/diagnostics.ts new file mode 100644 index 0000000..7d17681 --- /dev/null +++ b/apps/vscode/src/diagnostics.ts @@ -0,0 +1,16 @@ +import type { ConfigDiagnosticsResult } from '@deepcode/protocol'; + +export function formatConfigDiagnostics(report: ConfigDiagnosticsResult): string[] { + const lines = [`DeepCode configuration · ${report.cwd}`, `Trust: ${report.trustStatus}`, '']; + lines.push('Layers:'); + for (const layer of report.layers) { + const state = layer.present ? (layer.trusted ? 'active' : 'untrusted') : 'missing'; + lines.push(` ${layer.layer.padEnd(8)} ${state.padEnd(9)} ${layer.path}`); + } + lines.push('', `Gated fields: ${report.gated.length ? report.gated.join(', ') : 'none'}`); + lines.push(`Issues: ${report.issues.length}`); + for (const issue of report.issues) { + lines.push(` ${issue.severity.toUpperCase()} ${issue.code}: ${issue.message}`); + } + return lines; +} diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 9fc5f16..ac5b883 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,17 +1,38 @@ -// 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 { + isReviewFindingPayload, + ProtocolClient, + type ProtocolEvent, + type ReviewActionPayload, + type ReviewFindingPayload, +} from '@deepcode/protocol'; +import { SpawnedAppServerConnection } from '@deepcode/app-server/client'; + +import { EditorProtocolRuntime } from './protocol-runtime.js'; +import { formatConfigDiagnostics } from './diagnostics.js'; +import { explicitConfigValue } from './settings.js'; +import { formatWorkspaceDiffForReview } from './workspace-diff.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; +const latestReviewFindings = new Map(); +let latestAppliedActionId: string | 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 +53,328 @@ 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 = - 'Review the current uncommitted diff. Cite file:line for each finding. ' + - 'Categorize as BUG / LATENT / SUGGESTION.'; - await runAgent(prompt, vscodeMod, root); + try { + latestReviewFindings.clear(); + const diff = await runtime.diff(); + if (!diff.repository || diff.files.length === 0) { + void window.showInformationMessage('DeepCode: no uncommitted changes to review.'); + return; + } + await runInOutput( + 'Review the canonical workspace diff below. Cite file:line for each finding. ' + + 'Categorize as BUG / LATENT / SUGGESTION.\n\n' + + formatWorkspaceDiffForReview(diff), + vscodeMod, + runtime, + ); + } catch (error) { + void window.showErrorMessage( + `DeepCode review failed: ${(error as Error).message ?? String(error)}`, + ); + } + }), + commands.registerCommand( + 'deepcode.applyReviewFinding', + async (finding: ReviewFindingPayload | undefined) => { + if (!finding?.findingId) { + void window.showErrorMessage('DeepCode: a review finding is required.'); + return; + } + await runFindingsInOutput([finding], vscodeMod, runtime); + }, + ), + commands.registerCommand('deepcode.applyAllReviewFindings', async () => { + const findings = [...latestReviewFindings.values()]; + if (findings.length === 0) { + void window.showInformationMessage('DeepCode: no review findings to apply.'); + return; + } + await runFindingsInOutput(findings, vscodeMod, runtime); }), + commands.registerCommand( + 'deepcode.revertReviewAction', + async (action: Pick | undefined) => { + const actionId = action?.actionId ?? latestAppliedActionId; + if (!actionId) { + void window.showInformationMessage('DeepCode: no applied review action to revert.'); + return; + } + await runRevertInOutput(actionId, vscodeMod, runtime); + }, + ), + commands.registerCommand('deepcode.showDiagnostics', async () => { + const out = window.createOutputChannel('DeepCode Diagnostics'); + out.show(true); + try { + const report = await runtime.diagnostics(); + for (const line of formatConfigDiagnostics(report)) out.appendLine(line); + } catch (error) { + out.appendLine(`✕ ${(error as Error).message ?? String(error)}`); + } + }), + window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod, runtime)), ); +} - // ── Chat view provider ────────────────────────────────────────────── - context.subscriptions.push( - window.registerWebviewViewProvider('deepcode.chat', new ChatViewProvider(vscodeMod)), +async function runFindingsInOutput( + findings: ReviewFindingPayload[], + vscodeMod: V, + runtime: EditorProtocolRuntime, +): Promise { + const out = vscodeMod.window.createOutputChannel('DeepCode'); + out.show(true); + out.appendLine( + findings.length === 1 + ? `Applying review finding: ${findings[0]!.title}` + : `Applying ${findings.length} review findings`, ); + try { + await runtime.applyFindings(findings, (event) => { + projectOutputEvent(event, out); + void respondToInteraction(event, vscodeMod, runtime); + }); + } catch (error) { + out.appendLine(`\n✕ ${(error as Error).message ?? String(error)}`); + } } -export function deactivate(): void { - /* no-op */ +async function runRevertInOutput( + actionId: string, + vscodeMod: V, + runtime: EditorProtocolRuntime, +): Promise { + const out = vscodeMod.window.createOutputChannel('DeepCode'); + out.show(true); + out.appendLine(`Reverting review action: ${actionId}`); + try { + await runtime.revertAction(actionId, (event) => { + projectOutputEvent(event, out); + void respondToInteraction(event, vscodeMod, runtime); + }); + } catch (error) { + out.appendLine(`\n✕ ${(error as Error).message ?? String(error)}`); + } } -// ────────────────────────────────────────────────────────────────────────── -// Real runAgent invocation — same @deepcode/core code drives CLI / Mac / LSP -// ────────────────────────────────────────────────────────────────────────── +export async function deactivate(): Promise { + const runtime = activeRuntime; + activeRuntime = undefined; + await runtime?.close(); +} -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'); + const model = explicitConfigValue(config.inspect('model')); + const effort = explicitConfigValue(config.inspect('effort')); + return { + text, + ...(model ? { model } : {}), + ...(effort ? { effort } : {}), + }; +} + +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; + case 'item.completed': + if (event.item.type === 'review_finding') { + const finding = event.item.payload; + if (isReviewFindingPayload(finding)) { + latestReviewFindings.set(finding.findingId, finding); + } + out.appendLine( + `\n[P${String(finding.priority)}] ${String(finding.title)} — ${String(finding.path)}:${String(finding.startLine)}`, + ); + out.appendLine(` ${String(finding.body)}`); + } else if (event.item.type === 'review_action') { + if ( + event.item.payload.kind === 'apply' && + typeof event.item.payload.actionId === 'string' + ) { + latestAppliedActionId = event.item.payload.actionId; + } + out.appendLine( + `\n[review action] ${String(event.item.payload.kind)} ${String( + (event.item.payload.findingIds as unknown[] | undefined)?.length ?? 0, + )} finding(s)`, + ); + } + 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 `