diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index ca79c5df..ffcf6c74 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -5,7 +5,7 @@ Guidance for Claude Code (claude.ai/code) when working in this repository. ## Project identity CodeDeck+ is a community-maintained continuation of CodeDeck Next: run coding -agents (Claude Code, OpenCode, …) on a laptop/VPS ("the bridge") and drive them +agents (Claude Code, OpenCode, DeepSeek Harness) on a laptop/VPS ("the bridge") and drive them from an Android phone over end-to-end-encrypted Nostr. It started as a merge of the two upstream projects (`codedeck-next-bridge`, `codedeck-next-mobile`) and has since been rebuilt: the protocol, the bridge and the phone's core are Rust; @@ -218,8 +218,8 @@ crates/protocol the phone wire: messages, total codec, kinds, ranges, there by default — keep them consistent. Neither ships the agents' own binaries: the agent host installs them on demand at the version and sha512 pnpm-lock.yaml pins (the image into the `/data` volume); only an image built - with `BUNDLE_AGENTS=1` bakes them in. Never add a global Claude Code or - OpenCode install — the version must follow the lockfile. + with `BUNDLE_AGENTS=1` bakes them in. Never add a global Claude Code, + OpenCode or DeepSeek Harness install — the version must follow the lockfile. ## History note diff --git a/.env.example b/.env.example index 68f1ec6f..fcab563f 100644 --- a/.env.example +++ b/.env.example @@ -1,20 +1,63 @@ -# Docker Compose reads this file: the container's secrets and Git setup, and -# optionally the bridge's most common settings. Every bridge setting can also -# live in ./data/config.json (created on first start; config.example.json -# shows every key). A variable set here wins over config.json; leave it -# unset to use the file's value. +# CodeDeck+ container settings (docker compose reads this file). +# +# Almost nothing here is required: the bridge writes its own configuration on +# first start (./data/config.json, and config.example.json in the repository +# shows every key it knows). What you set here wins over that file, so this is +# the place for the few things you want in the environment — the secrets, your +# git identity, and whichever agent you configure beyond its defaults. -# Compose exposes these two to the container as secrets. +# --------------------------------- secrets ---------------------------------- +# Compose mounts these into the container as files under /run/secrets, so they +# never show up in `docker inspect`; the entrypoint reads them into the +# variables the agents expect. Every one of them is optional — leave a line +# empty and set that credential on the phone instead. + +# Claude Code: a long-lived token from `claude setup-token`. CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat... + +# GitHub: for git and the `gh` CLI inside sessions. GITHUB_TOKEN=ghp_... -# Git identity for commits made in sessions. +# The DeepSeek Harness. +DEEPSEEK_API_KEY= + +# ----------------------------------- git ------------------------------------ +# Identity for the commits sessions make, and repositories cloned at start +# (each into /data/workspaces/). GIT_USER=your_username GIT_EMAIL=your_email@example.com -# Comma-separated repos, each cloned under /data/workspaces/. GIT_REPO=https://github.com/your-username/your-repo.git -# --- Bridge settings (optional; or set them in data/config.json) --- +# ---------------------------------- agents ---------------------------------- +# Claude Code, OpenCode and the DeepSeek Harness all run out of the box; these +# configure them further. docs/OPENCODE.md and docs/DEEPSEEK.md have the long +# version. + +# Claude Code through a gateway or router (e.g. claude-code-router) instead of +# Anthropic directly: +# ANTHROPIC_BASE_URL=http://your-router-host:port +# For a router that exposes several providers behind one /v1/models: +# CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 + +# OpenCode: an external `opencode serve`... +# CODEDECK_OPENCODE_SERVER_URL=http://127.0.0.1:4096 +# ...or one the bridge starts and manages, optionally on a fixed port. +# CODEDECK_OPENCODE_AUTO_START=1 +# CODEDECK_OPENCODE_PORT=4096 + +# DeepSeek Harness: a DeepSeek-compatible relay or gateway, for sessions that +# are not bound to a provider profile of their own: +# DEEPSEEK_BASE_URL=http://your-router-host:port +# A harness CLI of your own, instead of the one installed on first use (its +# lib/bin.js, or an executable): +# CODEDECK_DEEPSEEK_PATH= + +# Agent runtimes are installed into /data/agents on first use, at the version +# the lockfile pins. Set this to bake them into the image instead, for a host +# without internet access (it applies at the next build). +# CODEDECK_BUNDLE_AGENTS=1 + +# -------------------------------- the bridge -------------------------------- # The name the phone shows for this machine. # CODEDECK_MACHINE_NAME=workstation # Comma-separated relay URLs. @@ -22,37 +65,15 @@ GIT_REPO=https://github.com/your-username/your-repo.git # SOCKS5 proxy for all relay connections: your own Tor daemon, or the bundled # `codedeck-tor` service (docker-compose.yml, `tor` profile). # CODEDECK_TOR_PROXY_URL=socks5h://codedeck-tor:9050 -# OpenCode, a second agent (docs/OPENCODE.md): an external `opencode serve`... -# CODEDECK_OPENCODE_SERVER_URL=http://127.0.0.1:4096 -# ...or one the bridge starts and manages, optionally on a fixed port. -# CODEDECK_OPENCODE_AUTO_START=1 -# CODEDECK_OPENCODE_PORT=4096 -# The direct link (on by default in config.json): where it listens, and the -# host's addresses phones dial (inside the container the bridge cannot see -# them; they can also be added on the phone). +# The direct link (on by default): where it listens, and the addresses phones +# dial. Inside the container the bridge cannot see the host's addresses, so +# set them here (or add them on the phone). # CODEDECK_DIRECT_LISTEN=0.0.0.0:7447 # CODEDECK_DIRECT_ENDPOINTS=wss://192.168.1.20:7447 -# --- LLM gateway / router (optional — leave unset to talk to Anthropic directly) --- -# Point the Claude Code CLI at a router (e.g. claude-code-router) instead of Anthropic. -# ANTHROPIC_BASE_URL=http://your-router-host:port - -# Needed for a router that exposes multiple providers behind one /v1/models -# (ids like "/") — works around a confirmed SDK gateway- -# discovery bug on cold bridge start; see ENABLE_GATEWAY_MODEL_DISCOVERY's doc -# comment in packages/agent-host/src/drivers/claude/facade.ts. -# CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1 - -# --- Agent binaries --- -# Claude Code (and OpenCode, with auto-start) are installed into /data/agents -# on first use, pinned to the lockfile. Set to 1 to bake them into the image -# instead, for a host without internet access (applies at the next build). -# CODEDECK_BUNDLE_AGENTS=1 - -# --- GSD (github.com/open-gsd/gsd-core, optional planning workflow) --- -# Off by default: installing gsd-core is a plain npm-registry call the -# entrypoint would otherwise make on every container boot, and outside the -# bridge's Tor proxy. Set to 1 to have the container install it globally for -# Claude on startup. A missing GSD install never blocks a session, it just -# leaves the phone's GSD stage strip empty. +# ---------------------------------- extras ---------------------------------- +# Install gsd-core for Claude at container start (github.com/open-gsd/gsd-core). +# Off by default: it is an npm-registry call on every boot, outside the Tor +# proxy. A missing GSD never blocks a session — it only leaves the phone's GSD +# strip empty. # CODEDECK_GSD_AUTO_INSTALL=1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..eabc8b9b --- /dev/null +++ b/.gitattributes @@ -0,0 +1,7 @@ +# Line endings. The repository stores LF and every checkout gets LF, whatever +# the platform that made the change: a Windows checkout would otherwise write +# CRLF into a file it edits, and the tree would carry a whole-file diff that +# says nothing. `text=auto` leaves binaries alone (git detects them), and +# Gradle's wrapper script is the one text file that has to keep its CRLF. +* text=auto eol=lf +apps/android/gradlew.bat text eol=crlf diff --git a/.gitignore b/.gitignore index 5b64b076..22448b9e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,32 +1,32 @@ -.env* -!.env.example - -# Node / pnpm workspace -node_modules/ -**/node_modules/ -pnpm-debug.log* -.pnpm-store/ - -# Build outputs -apps/mobile/dist/ -packages/agent-host/dist/ -**/target/ -**/*.tsbuildinfo - -# NOTE: apps/mobile/src-tauri/gen/ is intentionally NOT ignored — upstream -# commits the generated Android project scaffold (Gradle needs it present; -# `**/target/` above already covers Rust's own build output; the Gradle -# `build/` dir under gen/android is already covered by that project's own -# gen/android/.gitignore). - -# APKs built via apps/mobile/docker/build-apk.sh land here — never commit a -# built binary. -/dist/ -# Cargo workspace build output -/target -/crates/**/target - -# scripts/install-toolchain.sh's local Android SDK/NDK (Linux host builds, -# apps/android/scripts/build-apk-local.sh) — project-scoped on purpose, never -# a system path, so it never collides with another project's SDK/NDK version. -/toolchain/ +.env* +!.env.example + +# Node / pnpm workspace +node_modules/ +**/node_modules/ +pnpm-debug.log* +.pnpm-store/ + +# Build outputs +apps/mobile/dist/ +packages/agent-host/dist/ +**/target/ +**/*.tsbuildinfo + +# NOTE: apps/mobile/src-tauri/gen/ is intentionally NOT ignored — upstream +# commits the generated Android project scaffold (Gradle needs it present; +# `**/target/` above already covers Rust's own build output; the Gradle +# `build/` dir under gen/android is already covered by that project's own +# gen/android/.gitignore). + +# APKs built via apps/mobile/docker/build-apk.sh land here — never commit a +# built binary. +/dist/ +# Cargo workspace build output +/target +/crates/**/target + +# scripts/install-toolchain.sh's local Android SDK/NDK (Linux host builds, +# apps/android/scripts/build-apk-local.sh) — project-scoped on purpose, never +# a system path, so it never collides with another project's SDK/NDK version. +/toolchain/ diff --git a/README.md b/README.md index 639d7960..88be1db4 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # CodeDeck+ -**Control coding agents (Claude Code, OpenCode) running on your laptop or VPS +**Control coding agents (Claude Code, OpenCode, DeepSeek Harness) running on your laptop or VPS from your Android phone, over end-to-end encrypted Nostr.** No accounts and no CodeDeck server: the phone and the bridge pair by scanning a QR code and talk through ordinary Nostr relays — public ones, or your own. @@ -38,8 +38,9 @@ Pairing is a one-time QR scan; it survives restarts on both ends. - Transcripts that survive restarts and offline gaps (ranged sync) - Per-session mode, model and effort, plus custom AI provider profiles (Kimi K3, OpenRouter, any Anthropic-compatible endpoint) -- Claude Code and [OpenCode](https://opencode.ai), chosen per session — the - protocol is agent-neutral, so another agent is one driver away +- Claude Code, [OpenCode](https://opencode.ai) and the + [DeepSeek Harness](https://www.deepseek.com/en/harness/), chosen per session + — the protocol is agent-neutral, so another agent is one driver away ([`docs/PROTOCOL.md`](docs/PROTOCOL.md#adding-an-agent)) - File attachments (photos or any file), and project/folder management on every paired bridge - NIP-42 `AUTH` relays, and Tor on both ends (see below) @@ -223,6 +224,7 @@ the full runbook. - [`docs/CLIENT.md`](docs/CLIENT.md) — the phone: the Rust client core, the Android app, transport rules, building the APK - [`docs/PROTOCOL.md`](docs/PROTOCOL.md) — the v11 wire contract, the driver protocol, adding an agent - [`docs/OPENCODE.md`](docs/OPENCODE.md) — the optional OpenCode session backend: external server vs. bridge-managed, config, Docker setup +- [`docs/DEEPSEEK.md`](docs/DEEPSEEK.md) — the DeepSeek Harness backend: API key, models and reasoning, gateways, MCP servers and plugins - [`.claude/skills/cut-release/SKILL.md`](.claude/skills/cut-release/SKILL.md) — the release runbook ## Upstream diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt index 70e5dc52..719280d9 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt @@ -227,6 +227,9 @@ sealed class DisplayEntry { override val seq: Long, val requestId: String, val options: List = emptyList(), + /** The option the user's feedback goes with, when the agent takes + * feedback on its plan. */ + val revise: String? = null, /** The outcome, once the bridge resolved it. */ val answered: String? = null, ) : DisplayEntry() diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/PlanApprovalCard.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/PlanApprovalCard.kt index dee4fb5e..5ac65345 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/PlanApprovalCard.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/PlanApprovalCard.kt @@ -2,15 +2,26 @@ package com.codedeck.plus.ui.transcript.rows import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import com.codedeck.plus.ui.theme.Tokens import com.codedeck.plus.ui.transcript.DisplayEntry @@ -22,6 +33,12 @@ import uniffi.client_ffi.UniffiIntent * alongside the answer so the card can name it before the bridge resolves * the request. The first option is the agent's own go-ahead, so it is the * one shown as the primary choice. + * + * The option that sends the plan back (`revise`, when the agent has one) + * asks what should change first: the feedback goes to the agent with the + * choice, in one answer, rather than as a message after it — by then the + * agent may already be revising without it. Sending it empty keeps planning + * with no feedback. */ @Composable fun PlanApprovalCard( @@ -40,19 +57,49 @@ fun PlanApprovalCard( return } + var writingFeedback by remember(item.requestId) { mutableStateOf(false) } + var feedback by remember(item.requestId) { mutableStateOf("") } + fun respond(optionId: String, withFeedback: String?) { + actions(UniffiIntent.SetPlanApprovalChoice(cardId = item.requestId, key = optionId)) + actions( + UniffiIntent.RespondPlan( + machine = machine, + sessionId = sessionId, + requestId = item.requestId, + optionId = optionId, + feedback = withFeedback?.trim()?.takeIf { it.isNotEmpty() }, + ), + ) + } + Column(Modifier.interactionCard(waiting = true)) { Text("Approve this plan?", color = Tokens.Text, fontSize = Tokens.TextMd) + val revise = item.revise + if (writingFeedback && revise != null) { + Row( + Modifier.fillMaxWidth().padding(top = Tokens.Space2), + horizontalArrangement = Arrangement.spacedBy(Tokens.Space2), + ) { + OutlinedTextField( + value = feedback, + onValueChange = { feedback = it }, + placeholder = { Text("What should change? (optional)") }, + modifier = Modifier.weight(1f), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), + keyboardActions = KeyboardActions(onSend = { respond(revise, feedback) }), + ) + ActionChip("Send", Tokens.Text) { respond(revise, feedback) } + } + // Choosing to revise is not a commitment until it is sent: the + // other choices stay one tap away, and the draft is kept. + TextButton(onClick = { writingFeedback = false }) { + Text("Back to options", color = Tokens.TextMuted, fontSize = Tokens.TextXs) + } + return@Column + } item.options.forEachIndexed { i, option -> PlanOption(option.label, option.description, primary = i == 0) { - actions(UniffiIntent.SetPlanApprovalChoice(cardId = item.requestId, key = option.id)) - actions( - UniffiIntent.RespondPlan( - machine = machine, - sessionId = sessionId, - requestId = item.requestId, - optionId = option.id, - ), - ) + if (option.id == revise) writingFeedback = true else respond(option.id, null) } } } diff --git a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt index 9e438cc3..740cc1b5 100644 --- a/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt +++ b/apps/android/app/src/main/java/uniffi/client_ffi/client_ffi.kt @@ -8424,13 +8424,15 @@ sealed class UniffiIntent { } /** - * Answer a plan approval with one of its options' ids. + * Answer a plan approval with one of its options' ids; `feedback` is + * what the user wants changed, with the card's `revise` option. */ data class RespondPlan( val `machine`: kotlin.String, val `sessionId`: kotlin.String, val `requestId`: kotlin.String, - val `optionId`: kotlin.String) : UniffiIntent() + val `optionId`: kotlin.String, + val `feedback`: kotlin.String?) : UniffiIntent() { @@ -8935,6 +8937,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer UniffiIntent.SetOption( FfiConverterString.read(buf), @@ -9262,6 +9265,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { @@ -9681,6 +9685,7 @@ public object FfiConverterTypeUniffiIntent : FfiConverterRustBuffer { diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt index fc40afcd..9bae6011 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignFixtures.kt @@ -226,6 +226,7 @@ internal object DesignFixtures { OptionChoice("default", "Yes, and ask before each edit"), OptionChoice("plan", "No, keep planning", "Stay in plan mode and send feedback"), ), + revise = "plan", ), DisplayEntry.Question( seq = 3, diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/DisplayEntriesFixtureTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/DisplayEntriesFixtureTest.kt index 2fcc3154..b7c0026e 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/DisplayEntriesFixtureTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/DisplayEntriesFixtureTest.kt @@ -93,6 +93,7 @@ class DisplayEntriesFixtureTest { assertEquals("tu-plan", planApproval.requestId) assertEquals(3, planApproval.options.size) assertEquals("Stay in plan mode and send feedback", planApproval.options[2].description) + assertEquals("revise", planApproval.revise) val question = entries[15] as DisplayEntry.Question assertEquals(1, question.questions.size) diff --git a/apps/android/app/src/test/resources/display_entries_corpus.json b/apps/android/app/src/test/resources/display_entries_corpus.json index 91f8f9e1..fb8c9ed9 100644 --- a/apps/android/app/src/test/resources/display_entries_corpus.json +++ b/apps/android/app/src/test/resources/display_entries_corpus.json @@ -432,6 +432,7 @@ } ], "requestId": "tu-plan", + "revise": "revise", "seq": 32 }, { diff --git a/config.example.json b/config.example.json index 6486b4a8..9640c337 100644 --- a/config.example.json +++ b/config.example.json @@ -13,6 +13,7 @@ "openCodePort": 4096, "openCodeServerUrl": "http://127.0.0.1:4096", "openCodePath": "/usr/local/bin/opencode", + "deepseekPath": "/usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js", "relayRegisterEndpoint": "https://relay.example.com/admin/allow", "relayRegisterToken": "admin-token", "blossomRegisterEndpoint": "https://blossom.example.com/admin/allow", diff --git a/crates/agent-protocol/src/lib.rs b/crates/agent-protocol/src/lib.rs index 90dd92ea..28040e52 100644 --- a/crates/agent-protocol/src/lib.rs +++ b/crates/agent-protocol/src/lib.rs @@ -82,6 +82,8 @@ mod tests { bridge_rt(json!({"v":1,"id":"10","kind":"check-credential","payload":{"agent":"claude-code","credential":"anthropic_api_key","value":"sk"}})); bridge_rt(json!({"v":1,"id":"h1","kind":"permission-outcome","payload":{"outcome":"selected","optionId":"allow"}})); bridge_rt(json!({"v":1,"id":"h2","kind":"plan-outcome","payload":{"outcome":"cancelled","reason":"Timed out"}})); + bridge_rt(json!({"v":1,"id":"h4","kind":"plan-outcome","payload":{"outcome":"selected","optionId":"revise","feedback":"Fewer steps."}})); + bridge_rt(json!({"v":1,"id":"h5","kind":"plan-outcome","payload":{"outcome":"selected","optionId":"default"}})); bridge_rt(json!({"v":1,"id":"h3","kind":"question-outcome","payload":{"outcome":"answered","answers":["Red","a, b"]}})); } @@ -140,13 +142,14 @@ mod tests { {"question":"Why?","options":[]} ]}})); host_rt(json!({"v":1,"id":"h3","kind":"request-plan-approval","payload":{"sessionId":"s","requestId":"p","options":[{"id":"default","label":"Approve"}]}})); + host_rt(json!({"v":1,"id":"h4","kind":"request-plan-approval","payload":{"sessionId":"s","requestId":"p","options":[{"id":"default","label":"Approve"},{"id":"revise","label":"Keep planning"}],"revise":"revise"}})); } #[test] fn replies_are_told_apart_from_requests() { assert!(HostMessage::Ack.is_reply()); assert!(!HostMessage::SessionEvent { session_id: "s".into(), event: SessionEvent::Ready {} }.is_reply()); - assert!(BridgeMessage::PlanOutcome(SelectOutcome::Cancelled { reason: "x".into() }).is_reply()); + assert!(BridgeMessage::PlanOutcome(PlanOutcome::Cancelled { reason: "x".into() }).is_reply()); assert!(!BridgeMessage::Interrupt { session_id: "s".into() }.is_reply()); } diff --git a/crates/agent-protocol/src/messages.rs b/crates/agent-protocol/src/messages.rs index 6bd1c516..ae95de46 100644 --- a/crates/agent-protocol/src/messages.rs +++ b/crates/agent-protocol/src/messages.rs @@ -263,9 +263,13 @@ pub struct PlanApprovalRequest { pub session_id: String, pub request_id: String, pub options: Vec, + /// The option that sends the plan back to the agent to revise, when one + /// does: the user may send their feedback with it (`plan-outcome`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub revise: Option, } -/// The answer to a permission request or a plan approval. +/// The answer to a permission request. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(tag = "outcome", rename_all = "snake_case", rename_all_fields = "camelCase")] pub enum SelectOutcome { @@ -275,6 +279,21 @@ pub enum SelectOutcome { Cancelled { reason: String }, } +/// The answer to a plan approval. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(tag = "outcome", rename_all = "snake_case", rename_all_fields = "camelCase")] +pub enum PlanOutcome { + /// One of the request's options. `feedback` is what the user wants + /// changed, only ever with the request's `revise` option. + Selected { + option_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + feedback: Option, + }, + /// Nobody chose: timed out, interrupted, the session is ending. + Cancelled { reason: String }, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] #[serde(tag = "outcome", rename_all = "snake_case", rename_all_fields = "camelCase")] pub enum QuestionOutcome { @@ -366,7 +385,7 @@ pub enum BridgeMessage { /// Reply to `request-permission`. PermissionOutcome(SelectOutcome), /// Reply to `request-plan-approval`. - PlanOutcome(SelectOutcome), + PlanOutcome(PlanOutcome), /// Reply to `ask-question`. QuestionOutcome(QuestionOutcome), } diff --git a/crates/agent-protocol/tests/agent_host.rs b/crates/agent-protocol/tests/agent_host.rs index 992f5aa6..b5d10ea1 100644 --- a/crates/agent-protocol/tests/agent_host.rs +++ b/crates/agent-protocol/tests/agent_host.rs @@ -17,7 +17,7 @@ use std::thread; use std::time::{Duration, Instant}; use agent_protocol::{ - decode_host_frame, encode_frame, BridgeMessage, Frame, HostFrame, HostMessage, QuestionOutcome, + decode_host_frame, encode_frame, BridgeMessage, Frame, HostFrame, HostMessage, PlanOutcome, QuestionOutcome, SelectOutcome, SessionEvent, StartSession, }; use protocol::common::EntryBody; @@ -215,7 +215,8 @@ fn the_agent_host_speaks_the_driver_protocol() { let (req, message) = host.host_request("plan approval", |m| matches!(m, HostMessage::RequestPlanApproval(_))); let HostMessage::RequestPlanApproval(plan) = message else { unreachable!() }; assert!(plan.options.iter().any(|o| o.id == "revise")); - host.send(Some(req), BridgeMessage::PlanOutcome(selected("default"))); + assert_eq!(plan.revise.as_deref(), Some("revise")); + host.send(Some(req), BridgeMessage::PlanOutcome(PlanOutcome::Selected { option_id: "default".into(), feedback: None })); host.text("c1", "Plan approved"); host.event("the mode switch", "c1", |e| matches!(e, SessionEvent::Info { mode: Some(m), .. } if m == "default")); diff --git a/crates/bridge-core/src/engine/host.rs b/crates/bridge-core/src/engine/host.rs index e62fb52c..2d08262b 100644 --- a/crates/bridge-core/src/engine/host.rs +++ b/crates/bridge-core/src/engine/host.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; use agent_protocol::{ - BridgeMessage, HostFrame, HostMessage, QuestionOutcome, SelectOutcome, SessionEvent, StartSession, + BridgeMessage, HostFrame, HostMessage, PlanOutcome, QuestionOutcome, SelectOutcome, SessionEvent, StartSession, }; use protocol::common::{EntryBody, NoticeKind, OutputEntry, Role, SessionOption, ToolKind}; use protocol::common::{McpAction, PluginAction}; @@ -61,7 +61,7 @@ fn cancelled(kind: &CardKind, reason: &str) -> BridgeMessage { let reason = reason.to_string(); match kind { CardKind::Permission { .. } => BridgeMessage::PermissionOutcome(SelectOutcome::Cancelled { reason }), - CardKind::Plan { .. } => BridgeMessage::PlanOutcome(SelectOutcome::Cancelled { reason }), + CardKind::Plan { .. } => BridgeMessage::PlanOutcome(PlanOutcome::Cancelled { reason }), CardKind::Question { .. } => BridgeMessage::QuestionOutcome(QuestionOutcome::Cancelled { reason }), } } @@ -820,11 +820,18 @@ impl Engine { } HostMessage::RequestPlanApproval(req) => { if !self.is_running(&req.session_id) { - return self.reply(host_id, cancelled(&CardKind::Plan { options: vec![] }, "the session is not running")); + return self.reply(host_id, cancelled(&CardKind::Plan { options: vec![], revise: None }, "the session is not running")); } log::info!("[Engine] Waiting on plan approval: {} in {}", req.request_id, req.session_id); - let entry = self.entry(EntryBody::PlanApproval { request_id: req.request_id.clone(), options: req.options.clone() }); - let kind = CardKind::Plan { options: req.options }; + // A `revise` that names none of the options would offer a + // feedback box no choice can carry: the card has none then. + let revise = req.revise.filter(|id| req.options.iter().any(|o| &o.id == id)); + let entry = self.entry(EntryBody::PlanApproval { + request_id: req.request_id.clone(), + options: req.options.clone(), + revise: revise.clone(), + }); + let kind = CardKind::Plan { options: req.options, revise }; self.open_card(&req.session_id, &req.request_id, host_id, kind, vec![entry]); } _ => log::warn!("[Engine] The agent host sent a reply kind as a request — dropped"), diff --git a/crates/bridge-core/src/engine/phone.rs b/crates/bridge-core/src/engine/phone.rs index 3368b032..7e2b45d4 100644 --- a/crates/bridge-core/src/engine/phone.rs +++ b/crates/bridge-core/src/engine/phone.rs @@ -1,11 +1,11 @@ //! Commands from phones: ingest, then one handler per message type. -use agent_protocol::{BridgeMessage, SelectOutcome}; +use agent_protocol::{BridgeMessage, PlanOutcome, SelectOutcome}; use protocol::commands::{ CreateFolderMsg, CreateSessionMsg, InputMsg, PermissionResponseMsg, PhoneToBridge, PlanResponseMsg, PluginActionMsg, QuestionAnswer, QuestionResponseMsg, SetOptionMsg, UploadFileMsg, }; -use protocol::common::{is_valid_provider_base_url, SessionOption, PROVIDER_BASE_URL_ERROR}; +use protocol::common::{is_valid_provider_base_url, EntryBody, Role, SessionOption, PROVIDER_BASE_URL_ERROR}; use protocol::events::{ BridgeToPhone, CloseSessionAckMsg, FolderAckMsg, InputAckMsg, InputFailedMsg, CommandsMsg, InputFailedReason, ModelsMsg, PluginAckMsg, SessionFailedMsg, SessionPendingMsg, @@ -177,8 +177,8 @@ impl Engine { fn on_plan_response(&mut self, m: PlanResponseMsg) { let card = self.run_ref(&m.session_id).and_then(|r| r.cards.get(&m.request_id)); - let option = match card.map(|c| &c.kind) { - Some(CardKind::Plan { options }) => options.iter().find(|o| o.id == m.option_id).cloned(), + let (option, revise) = match card.map(|c| &c.kind) { + Some(CardKind::Plan { options, revise }) => (options.iter().find(|o| o.id == m.option_id).cloned(), revise.clone()), _ => { log::info!("[Engine] plan-response for {} in {} matched nothing pending", m.request_id, m.session_id); return; @@ -188,10 +188,22 @@ impl Engine { log::info!("[Engine] Plan option '{}' is not offered for {}", m.option_id, m.request_id); return; }; + // Feedback goes with the option that sends the plan back, and with + // nothing else: an approval is not a request for changes. + let feedback = m + .feedback + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty() && revise.as_deref() == Some(option.id.as_str())); // An approving option's mode change comes back from the agent as // session info. - let answer = BridgeMessage::PlanOutcome(SelectOutcome::Selected { option_id: option.id }); + let answer = BridgeMessage::PlanOutcome(PlanOutcome::Selected { option_id: option.id, feedback: feedback.clone() }); self.close_card(&m.session_id, &m.request_id, answer, &option.label); + // The feedback is the user's own words to the agent, so the transcript + // keeps them the way it keeps a typed message. + if let Some(text) = feedback { + let entry = self.entry(EntryBody::Text { role: Role::User, text }); + self.append(&m.session_id, vec![entry]); + } } fn on_question_response(&mut self, m: QuestionResponseMsg) { diff --git a/crates/bridge-core/src/session.rs b/crates/bridge-core/src/session.rs index e338fb9f..5a788127 100644 --- a/crates/bridge-core/src/session.rs +++ b/crates/bridge-core/src/session.rs @@ -160,7 +160,8 @@ pub(crate) struct Card { pub(crate) enum CardKind { Permission { options: Vec }, - Plan { options: Vec }, + /// `revise` is the option the user's feedback may travel with. + Plan { options: Vec, revise: Option }, Question { questions: Vec, answers: BTreeMap }, } diff --git a/crates/bridge-core/tests/cards.rs b/crates/bridge-core/tests/cards.rs index 6db95b69..e6836f9a 100644 --- a/crates/bridge-core/tests/cards.rs +++ b/crates/bridge-core/tests/cards.rs @@ -4,7 +4,7 @@ mod support; use agent_protocol::{ - BridgeMessage, HostMessage, PermissionRequest, PlanApprovalRequest, QuestionOutcome, QuestionRequest, + BridgeMessage, HostMessage, PermissionRequest, PlanApprovalRequest, PlanOutcome, QuestionOutcome, QuestionRequest, QuestionSpec, SelectOutcome, SessionEvent, }; use bridge_core::{Effect, Input}; @@ -164,10 +164,18 @@ fn a_plan_answer_and_the_agents_mode_switch_reach_the_phone() { session_id: s.clone(), request_id: "p1".into(), options: vec![choice("yolo"), choice("revise")], + revise: Some("revise".into()), })); - assert!(matches!(&outputs(&rig.messages())[0].1.body, EntryBody::PlanApproval { options, .. } if options.len() == 2)); - rig.send(json!({"type":"plan-response","sessionId":s,"requestId":"p1","optionId":"yolo"})); - assert_eq!(reply_to(&mut rig, &h), BridgeMessage::PlanOutcome(SelectOutcome::Selected { option_id: "yolo".into() })); + assert!(matches!( + &outputs(&rig.messages())[0].1.body, + EntryBody::PlanApproval { options, revise: Some(revise), .. } if options.len() == 2 && revise == "revise" + )); + // Feedback is what changes are wanted: an approval carries none. + rig.send(json!({"type":"plan-response","sessionId":s,"requestId":"p1","optionId":"yolo","feedback":"ignored"})); + assert_eq!( + reply_to(&mut rig, &h), + BridgeMessage::PlanOutcome(PlanOutcome::Selected { option_id: "yolo".into(), feedback: None }) + ); assert_eq!(resolved(&rig.messages()), ["YOLO"]); rig.host_event(&s, SessionEvent::Info { native_session_id: None, model: None, mode: Some("yolo".into()), context_window: None, context_percentage: None }); @@ -176,6 +184,41 @@ fn a_plan_answer_and_the_agents_mode_switch_reach_the_phone() { assert_eq!(last_heartbeat(&msgs).sessions[0].mode.as_deref(), Some("yolo")); } +#[test] +fn feedback_on_a_plan_goes_to_the_agent_with_the_revise_choice_and_into_the_transcript() { + let mut rig = Rig::new(); + let s = ready(&mut rig); + let h = rig.host_ask(HostMessage::RequestPlanApproval(PlanApprovalRequest { + session_id: s.clone(), + request_id: "p1".into(), + options: vec![choice("yolo"), choice("revise")], + revise: Some("revise".into()), + })); + rig.messages(); + rig.send(json!({"type":"plan-response","sessionId":s,"requestId":"p1","optionId":"revise","feedback":" Fewer steps. "})); + assert_eq!( + reply_to(&mut rig, &h), + BridgeMessage::PlanOutcome(PlanOutcome::Selected { option_id: "revise".into(), feedback: Some("Fewer steps.".into()) }) + ); + let msgs = rig.messages(); + assert_eq!(resolved(&msgs), ["REVISE"]); + // The user's words, after the card's resolution, as a typed message is. + assert!(outputs(&msgs).iter().any(|(_, e)| matches!(&e.body, EntryBody::Text { text, .. } if text == "Fewer steps."))); +} + +#[test] +fn a_revise_option_the_card_does_not_offer_is_no_revise_option() { + let mut rig = Rig::new(); + let s = ready(&mut rig); + rig.host_ask(HostMessage::RequestPlanApproval(PlanApprovalRequest { + session_id: s, + request_id: "p1".into(), + options: vec![choice("yolo")], + revise: Some("missing".into()), + })); + assert!(matches!(&outputs(&rig.messages())[0].1.body, EntryBody::PlanApproval { revise: None, .. })); +} + #[test] fn interrupt_stops_the_turn_and_cancels_what_waits() { let mut rig = Rig::new(); diff --git a/crates/bridge-runtime/src/config.rs b/crates/bridge-runtime/src/config.rs index f8694634..3a24977a 100644 --- a/crates/bridge-runtime/src/config.rs +++ b/crates/bridge-runtime/src/config.rs @@ -23,6 +23,7 @@ pub struct Flags { pub opencode_server_url: Option, pub opencode_auto_start: bool, pub opencode_path: Option, + pub deepseek_path: Option, pub agent_host: Option, pub service: bool, pub test_mode: bool, @@ -48,6 +49,7 @@ struct FileConfig { open_code_server_url: Option, open_code_auto_start: Option, open_code_path: Option, + deepseek_path: Option, open_code_port: Option, agent_host_path: Option, node_path: Option, @@ -373,11 +375,15 @@ pub fn load(flags: &Flags) -> Result { put("CODEDECK_OPENCODE_AUTO_START", auto_start.then(|| "1".into())); put("CODEDECK_OPENCODE_PATH", flags.opencode_path.clone().or_else(|| env("CODEDECK_OPENCODE_PATH")).or(file.open_code_path)); put("CODEDECK_OPENCODE_PORT", env("CODEDECK_OPENCODE_PORT").or(file.open_code_port.map(|p| p.to_string()))); + put("CODEDECK_DEEPSEEK_PATH", flags.deepseek_path.clone().or_else(|| env("CODEDECK_DEEPSEEK_PATH")).or(file.deepseek_path)); let test_mode = flags.test_mode || env_bool("CODEDECK_TEST_MODE").unwrap_or(false); put("CODEDECK_TEST_MODE", test_mode.then(|| "1".into())); put("CODEDECK_AGENT_HOST_DRIVERS", env("CODEDECK_AGENT_HOST_DRIVERS")); - // Agent binaries the host installs on demand live under the bridge's home. + // Agent binaries the host installs on demand live under the bridge's home, + // and so does the state of an agent that keeps its own (the harness's + // profiles, sessions and credentials). put("CODEDECK_AGENT_CACHE", Some(home.join("agents").to_string_lossy().into_owned())); + put("CODEDECK_DEEPSEEK_HOME", Some(home.join("dsh").to_string_lossy().into_owned())); let direct = direct_config(flags, file.direct)?; @@ -450,6 +456,7 @@ mod tests { open_code_server_url, open_code_auto_start, open_code_path, + deepseek_path, open_code_port, agent_host_path, node_path, @@ -470,6 +477,7 @@ mod tests { ("openCodeServerUrl", open_code_server_url.is_some()), ("openCodeAutoStart", open_code_auto_start.is_some()), ("openCodePath", open_code_path.is_some()), + ("deepseekPath", deepseek_path.is_some()), ("openCodePort", open_code_port.is_some()), ("agentHostPath", agent_host_path.is_some()), ("nodePath", node_path.is_some()), diff --git a/crates/bridge-runtime/src/main.rs b/crates/bridge-runtime/src/main.rs index e3cc12d4..384684a5 100644 --- a/crates/bridge-runtime/src/main.rs +++ b/crates/bridge-runtime/src/main.rs @@ -52,6 +52,9 @@ struct Cli { /// Path to the opencode executable, for auto-start [env: CODEDECK_OPENCODE_PATH] #[arg(long, global = true)] opencode_path: Option, + /// Path to the DeepSeek Harness CLI — its `lib/bin.js`, or an executable of your own — instead of the runtime this build installs [env: CODEDECK_DEEPSEEK_PATH] + #[arg(long, global = true)] + deepseek_path: Option, /// Path to the agent host bundle (main.js) [env: CODEDECK_AGENT_HOST] #[arg(long, global = true)] agent_host: Option, @@ -115,6 +118,7 @@ fn main() -> ExitCode { opencode_server_url: cli.opencode_server_url.clone(), opencode_auto_start: cli.opencode_auto_start, opencode_path: cli.opencode_path.clone(), + deepseek_path: cli.deepseek_path.clone(), agent_host: cli.agent_host.clone(), service: cli.service, test_mode: cli.test_mode, diff --git a/crates/client-core/src/presentation/display_entries.rs b/crates/client-core/src/presentation/display_entries.rs index 10681225..214c6b96 100644 --- a/crates/client-core/src/presentation/display_entries.rs +++ b/crates/client-core/src/presentation/display_entries.rs @@ -276,6 +276,9 @@ pub enum DisplayEntry { seq: u64, request_id: String, options: Vec, + /// The option the user's feedback goes with, when the agent takes + /// feedback on its plan. + revise: Option, /// The outcome, once a `resolved` entry answered it. answered: Option, }, @@ -617,13 +620,14 @@ pub fn build_display_entries(source: &[SeqEntry]) -> Vec { agent_label: subagent_label(entry), }); } - EntryBody::PlanApproval { request_id, options } => { + EntryBody::PlanApproval { request_id, options, revise } => { b.flush_all(); b.display.push(DisplayEntry::PlanApproval { seq, answered: b.resolved.get(request_id).cloned(), request_id: request_id.clone(), options: options.clone(), + revise: revise.clone(), }); } EntryBody::Notice { kind, text } => { @@ -989,11 +993,12 @@ mod tests { #[test] fn plan_approval_carries_its_options_and_outcome() { let d = build_display_entries(&seq(&[ - json!({"entryType":"plan_approval","requestId":"p","options":[{"id":"approve","label":"Approve"}]}), + json!({"entryType":"plan_approval","requestId":"p","options":[{"id":"approve","label":"Approve"},{"id":"revise","label":"Keep planning"}],"revise":"revise"}), json!({"entryType":"resolved","requestId":"p","summary":"Plan approved"}), ])); - let DisplayEntry::PlanApproval { options, answered, .. } = &d[0] else { panic!() }; + let DisplayEntry::PlanApproval { options, answered, revise, .. } = &d[0] else { panic!() }; assert_eq!(options[0].id, "approve"); + assert_eq!(revise.as_deref(), Some("revise")); assert_eq!(answered.as_deref(), Some("Plan approved")); } diff --git a/crates/client-ffi/fixtures/display_entries_corpus.json b/crates/client-ffi/fixtures/display_entries_corpus.json index 91f8f9e1..fb8c9ed9 100644 --- a/crates/client-ffi/fixtures/display_entries_corpus.json +++ b/crates/client-ffi/fixtures/display_entries_corpus.json @@ -432,6 +432,7 @@ } ], "requestId": "tu-plan", + "revise": "revise", "seq": 32 }, { diff --git a/crates/client-ffi/src/intent.rs b/crates/client-ffi/src/intent.rs index 6ac21931..17fef077 100644 --- a/crates/client-ffi/src/intent.rs +++ b/crates/client-ffi/src/intent.rs @@ -261,12 +261,14 @@ pub enum UniffiIntent { selected: Vec, text: Option, }, - /// Answer a plan approval with one of its options' ids. + /// Answer a plan approval with one of its options' ids; `feedback` is + /// what the user wants changed, with the card's `revise` option. RespondPlan { machine: String, session_id: String, request_id: String, option_id: String, + feedback: Option, }, /// Change a session option: `option` is `"mode"` / `"effort"` / /// `"model"`, `value` an id the session's agent advertises (or a model id). @@ -539,8 +541,8 @@ impl TryFrom for Intent { }, } } - UniffiIntent::RespondPlan { machine, session_id, request_id, option_id } => { - Intent::RespondPlan { machine, session_id, request_id, option_id } + UniffiIntent::RespondPlan { machine, session_id, request_id, option_id, feedback } => { + Intent::RespondPlan { machine, session_id, request_id, option_id, feedback } } UniffiIntent::SetOption { machine, session_id, option, value } => Intent::SetOption { machine, diff --git a/crates/client-ffi/tests/display_entries_corpus.rs b/crates/client-ffi/tests/display_entries_corpus.rs index 1294b3aa..39abd143 100644 --- a/crates/client-ffi/tests/display_entries_corpus.rs +++ b/crates/client-ffi/tests/display_entries_corpus.rs @@ -85,7 +85,7 @@ fn corpus() -> Vec { {"id":"acceptEdits","label":"Approve, auto-accept edits"}, {"id":"default","label":"Approve"}, {"id":"revise","label":"Keep planning","description":"Stay in plan mode and send feedback"} - ]}), + ],"revise":"revise"}), json!({"entryType":"question","requestId":"tu-solo-question","index":0,"count":1,"header":"Direction","question":"Which approach?", "options":[{"label":"Extract first"},{"label":"Rewrite in one pass"}]}), json!({"entryType":"question","requestId":"tu-question-group","index":0,"count":2,"header":"Scope","question":"How wide?", diff --git a/crates/client-runtime/src/intent.rs b/crates/client-runtime/src/intent.rs index 73755a4e..533c9302 100644 --- a/crates/client-runtime/src/intent.rs +++ b/crates/client-runtime/src/intent.rs @@ -236,12 +236,15 @@ pub enum Intent { index: u32, answer: QuestionAnswer, }, - /// Answer a plan approval with one of its advertised options. + /// Answer a plan approval with one of its advertised options; `feedback` + /// is what the user wants changed, with the card's `revise` option. RespondPlan { machine: String, session_id: String, request_id: String, option_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + feedback: Option, }, /// Change a session's mode / effort / model. SetOption { @@ -618,6 +621,7 @@ pub fn apply( session_id, request_id, option_id, + feedback, } => { stores .ui @@ -630,6 +634,7 @@ pub fn apply( session_id, request_id, option_id, + feedback: feedback.filter(|text| !text.trim().is_empty()), }), ); } diff --git a/crates/protocol/fixtures/corpus.json b/crates/protocol/fixtures/corpus.json index 868325ee..1f92e7c2 100644 --- a/crates/protocol/fixtures/corpus.json +++ b/crates/protocol/fixtures/corpus.json @@ -9,6 +9,7 @@ { "type": "question-response", "sessionId": "s", "requestId": "q", "index": 2, "answer": { "kind": "options", "selected": [0, 2] } }, { "type": "question-response", "sessionId": "s", "requestId": "q", "index": 1, "answer": { "kind": "text", "text": "something else" } }, { "type": "plan-response", "sessionId": "s", "requestId": "p", "optionId": "approve" }, + { "type": "plan-response", "sessionId": "s", "requestId": "p", "optionId": "revise", "feedback": "Split the migration into its own step." }, { "type": "set-option", "sessionId": "s", "option": "mode", "value": "acceptEdits" }, { "type": "set-option", "sessionId": "s", "option": "effort", "value": "xhigh" }, { "type": "set-option", "sessionId": "s", "option": "model", "value": "anthropic/claude-x" }, @@ -99,7 +100,7 @@ { "type": "output", "sessionId": "s", "seq": 8, "entries": [{ "timestamp": "t", "entryType": "permission_request", "requestId": "r", "toolName": "Bash", "kind": "execute", "title": "rm -rf build", "description": "Delete the build folder", "locations": ["/w/build"], "rawInput": { "command": "rm -rf build" }, "options": [{ "id": "allow", "label": "Allow", "kind": "allow_once" }, { "id": "allow_always", "label": "Always allow", "kind": "allow_always" }, { "id": "deny", "label": "Deny", "kind": "reject_once" }] }] }, { "type": "output", "sessionId": "s", "seq": 9, "entries": [{ "timestamp": "t", "entryType": "permission_request", "requestId": "r", "toolName": "Bash", "kind": "execute", "title": "rm -rf build", "rawInput": { "command": "rm -rf build" }, "options": [{ "id": "allow", "label": "Allow", "kind": "allow_once" }, { "id": "deny", "label": "Deny", "kind": "reject_once" }], "reason": "Deleting needs a human", "hook": "PreToolUse:Bash", "hookPlugin": "guard-rails" }] }, { "type": "output", "sessionId": "s", "seq": 9, "entries": [{ "timestamp": "t", "entryType": "question", "requestId": "q", "index": 0, "count": 2, "header": "Color", "question": "Which color?", "options": [{ "label": "Red", "description": "warm" }, { "label": "Blue" }], "multiSelect": true }] }, - { "type": "output", "sessionId": "s", "seq": 10, "entries": [{ "timestamp": "t", "entryType": "plan_approval", "requestId": "p", "options": [{ "id": "approve", "label": "Approve" }, { "id": "revise", "label": "Keep planning" }] }] }, + { "type": "output", "sessionId": "s", "seq": 10, "entries": [{ "timestamp": "t", "entryType": "plan_approval", "requestId": "p", "options": [{ "id": "approve", "label": "Approve" }, { "id": "revise", "label": "Keep planning" }], "revise": "revise" }] }, { "type": "output", "sessionId": "s", "seq": 11, "entries": [{ "timestamp": "t", "entryType": "resolved", "requestId": "r", "summary": "Allowed" }] }, { "type": "output", "sessionId": "s", "seq": 12, "entries": [{ "timestamp": "t", "entryType": "notice", "kind": "session_restart", "text": "The agent restarted." }] }, { "type": "output", "sessionId": "s", "seq": 13, "entries": [{ "timestamp": "t", "entryType": "status", "text": "Compacting conversation" }] }, diff --git a/crates/protocol/src/commands.rs b/crates/protocol/src/commands.rs index 914946fb..5bca2155 100644 --- a/crates/protocol/src/commands.rs +++ b/crates/protocol/src/commands.rs @@ -77,6 +77,10 @@ pub struct PlanResponseMsg { pub session_id: String, pub request_id: String, pub option_id: String, + /// What the user wants changed, sent with the entry's `revise` option + /// (with any other option it is ignored). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub feedback: Option, } /// Change a session option. `value` must be one the session's agent diff --git a/crates/protocol/src/common.rs b/crates/protocol/src/common.rs index c4a4ecd5..f9b3f99c 100644 --- a/crates/protocol/src/common.rs +++ b/crates/protocol/src/common.rs @@ -684,6 +684,11 @@ pub enum EntryBody { PlanApproval { request_id: String, options: Vec, + /// The option that sends the plan back to the agent to revise, when + /// one does: a `plan-response` choosing it may carry the user's + /// `feedback`, which the agent revises the plan with. + #[serde(default, skip_serializing_if = "Option::is_none")] + revise: Option, }, /// A permission request, question or plan approval was answered (or /// cancelled); `summary` is a short human description of the outcome. diff --git a/data/.gitignore b/data/.gitignore index a3a0c8b5..c96a04f0 100644 --- a/data/.gitignore +++ b/data/.gitignore @@ -1,2 +1,2 @@ -* +* !.gitignore \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 359249a9..b72f43df 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,10 @@ services: # Claude Code's own settings, for an LLM gateway / router. - ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-} - CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=${CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY:-} + # The DeepSeek Harness's endpoint — a DeepSeek-compatible relay or + # gateway, unless a session is bound to a provider profile. Its key + # travels as a secret, below. + - DEEPSEEK_BASE_URL=${DEEPSEEK_BASE_URL:-} # The bridge's own settings live in ./data/config.json (created on # first start; config.example.json shows every key). The common ones # can also be set in .env: a variable set there wins over the file, @@ -35,6 +39,7 @@ services: - CODEDECK_OPENCODE_SERVER_URL=${CODEDECK_OPENCODE_SERVER_URL:-} - CODEDECK_OPENCODE_AUTO_START=${CODEDECK_OPENCODE_AUTO_START:-} - CODEDECK_OPENCODE_PORT=${CODEDECK_OPENCODE_PORT:-} + - CODEDECK_DEEPSEEK_PATH=${CODEDECK_DEEPSEEK_PATH:-} - CODEDECK_DIRECT_LISTEN=${CODEDECK_DIRECT_LISTEN:-} - CODEDECK_DIRECT_ENDPOINTS=${CODEDECK_DIRECT_ENDPOINTS:-} # The direct link: phones on your network (or VPN) reach the bridge here @@ -52,8 +57,21 @@ services: uid: "1000" gid: "1000" mode: 0440 + - source: deepseek_api_key + target: deepseek_api_key + uid: "1000" + gid: "1000" + mode: 0440 volumes: - ./data:/data + # The agents' runtimes, a cache of what the host installs on demand (or + # an image built with BUNDLE_AGENTS=1 carries), in a volume of Docker's + # own rather than in ./data. A host directory is never filled from the + # image, so a bundle would not reach it; and on Docker Desktop it is a + # file share slow enough to dominate a runtime of hundreds of packages — + # the DeepSeek Harness boots in about a second from a volume and in + # about 25 from ./data. + - agents:/data/agents codedeck-tor: image: lncm/tor:0.4.7.13@sha256:5a3cfb478d978feb1426ce6dca8e10906bbdd72cff94d7c0c589339f717b2503 @@ -72,6 +90,11 @@ secrets: environment: CLAUDE_CODE_OAUTH_TOKEN github_token: environment: GITHUB_TOKEN + deepseek_api_key: + environment: DEEPSEEK_API_KEY + +volumes: + agents: networks: codedeck-net: diff --git a/docker/Dockerfile b/docker/Dockerfile index cb6d673d..846dcd58 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -23,7 +23,9 @@ # # BUNDLE_AGENTS=1 builds a complete image for hosts without internet access: # the Agent SDK's platform package stays in (so /usr/local/bin/claude is a -# symlink to its binary, version-locked to the SDK) and OpenCode is copied in. +# symlink to its binary, version-locked to the SDK), OpenCode is copied in, +# and the DeepSeek Harness's runtime — a package tree, not one binary, so the +# host's own installer fetches it — is laid down in the cache it installs into. ARG BUNDLE_AGENTS=0 ARG NODE_IMAGE=node:24.20.0-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e @@ -75,6 +77,17 @@ RUN --mount=type=cache,id=codedeck-pnpm-store,target=/root/.local/share/pnpm/sto else \ rm -rf /deploy/node_modules/.pnpm/@anthropic-ai+claude-agent-sdk-*@*; \ fi +# The DeepSeek Harness runs from a whole package tree rather than one binary, +# so it cannot be copied in the way OpenCode is: the host's own installer — +# the code that would fetch it on a machine with internet — lays it down here, +# into the cache layout the host looks in, at the version and sha512 +# pnpm-lock.yaml pins. An image built without BUNDLE_AGENTS ships nothing of +# it and installs it on first use. +RUN mkdir -p /agents \ + && if [ "$BUNDLE_AGENTS" = 1 ]; then \ + CODEDECK_AGENT_HOST_WARM=1 CODEDECK_AGENT_HOST_DRIVERS=deepseek-harness \ + CODEDECK_AGENT_CACHE=/agents node /deploy/dist/main.js; \ + fi # --------------------------------------------------------------------------- FROM ${NODE_IMAGE} AS opencode @@ -147,6 +160,13 @@ RUN if getent group ${GROUP_ID}; then groupmod -n codedeck $(getent group ${GROU # The binary finds the agent host at agent-host/ beside it. COPY --from=opencode /out/ /usr/local/bin/ COPY --from=agenthost /deploy /app/agent-host +# Agents bundled at build time land in the cache the host installs into, +# /data/agents. Compose keeps that cache in a volume of Docker's own, which +# Docker fills from the image when the volume is FIRST created — so a bundle +# reaches a new deployment, and a volume that already exists keeps what it has +# (the agents already installed in it). Owned by the runtime user: the host +# installs into it, and a volume takes the ownership the image gives it. +COPY --chown=codedeck:codedeck --from=agenthost /agents/ /data/agents/ COPY --from=bridge /codedeck-bridge /app/codedeck-bridge COPY --chmod=0755 docker/entrypoint.sh /app/entrypoint.sh COPY --chmod=0755 docker/git-credential-codedeck-secret /usr/local/bin/git-credential-codedeck-secret diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 91aacced..192f9add 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -24,7 +24,8 @@ read_secret() { CLAUDE_CODE_OAUTH_TOKEN=$(read_secret claude_code_oauth_token CLAUDE_CODE_OAUTH_TOKEN) GITHUB_TOKEN=$(read_secret github_token GITHUB_TOKEN) -export CLAUDE_CODE_OAUTH_TOKEN +DEEPSEEK_API_KEY=$(read_secret deepseek_api_key DEEPSEEK_API_KEY) +export CLAUDE_CODE_OAUTH_TOKEN DEEPSEEK_API_KEY # /data is the only volume this image persists — the Dockerfile points # XDG_CONFIG_HOME/XDG_DATA_HOME there so OpenCode's own config/auth (e.g. a diff --git a/docs/BRIDGE.md b/docs/BRIDGE.md index 6d5bfa4e..71de07c1 100644 --- a/docs/BRIDGE.md +++ b/docs/BRIDGE.md @@ -1,8 +1,8 @@ # The bridge -The bridge runs coding agents (Claude Code, and optionally OpenCode) on your -laptop or VPS and serves them to the CodeDeck+ Android app over end-to-end -encrypted Nostr (NIP-44). No accounts and no central server: the phone and the +The bridge runs coding agents (Claude Code, OpenCode and the DeepSeek +Harness) on your laptop or VPS and serves them to the CodeDeck+ Android app +over end-to-end encrypted Nostr (NIP-44). No accounts and no central server: the phone and the bridge pair by scanning a QR code. It is one binary, `codedeck-bridge` (Rust), plus its **agent host** @@ -47,7 +47,9 @@ Upgrading is extracting the new archive over the old one. Task Scheduler. Under WSL2, the Linux archive works as on Linux. Claude Code authenticates with `ANTHROPIC_API_KEY`, `CLAUDE_CODE_OAUTH_TOKEN`, -an existing `claude` login, or a key set from the phone. +an existing `claude` login, or a key set from the phone. The DeepSeek Harness +uses `DEEPSEEK_API_KEY` — or a session's provider profile, for a gateway — and +keeps its own state under `/dsh`; see [`DEEPSEEK.md`](DEEPSEEK.md). ### Agent binaries @@ -60,29 +62,46 @@ installs it on first use: the SDK is locked to. - **OpenCode**: when auto-start is on (`CODEDECK_OPENCODE_AUTO_START=1`) and no `opencode` is found; a server URL instead needs nothing installed. +- **The DeepSeek Harness**: always, unless `CODEDECK_DEEPSEEK_PATH` names a + CLI to use instead. It is not one binary but a package tree, so this is the + bigger download by far — some 600 packages, a few hundred MB. The download starts in the background as the bridge starts, so the agents are listed at once and a first session waits for it (about 100 MB for Claude -Code, 60 MB for OpenCode). It comes from the npm registry and is checked -against the sha512 in this build's `pnpm-lock.yaml`; a mismatch is refused. -Binaries live in `/agents/`, one version per package; an upgrade that -moves the pin installs the new one and removes the old. A failed download -(no network) is retried by the next session. +Code, 60 MB for OpenCode, and a couple of minutes for the harness on a normal +connection). It comes from the npm registry and is checked against the sha512 +in this build's `pnpm-lock.yaml`; a mismatch is refused. Binaries live in +`/agents/`, one version per package, and the harness's tree in +`/agents/@deepseek-ai+dsh@/`; an upgrade that moves the pin +installs the new one and removes the old. A failed download (no network) is +retried by the next session. + +`CODEDECK_AGENT_HOST_WARM=1` installs everything the enabled agents need and +exits, without serving: a first-run warm-up, and what the image's bundled +build runs at build time. - Offline machines: install `claude` (or `opencode`) yourself and it is used - as is, or copy an `agents/` directory from another machine. + as is, point `CODEDECK_DEEPSEEK_PATH` at a harness you installed, or copy an + `agents/` directory from another machine. - A mirror: `CODEDECK_NPM_REGISTRY=https://…` (the sha512 still applies). - An HTTP(S) proxy for the download: Node reads `HTTPS_PROXY` only with `NODE_USE_ENV_PROXY=1` set. The Tor proxy is for relay traffic only and is not used here. -The container image works the same way, with the binaries in the `/data` -volume (`/data/agents`), so a container recreated from a newer image reuses -them until the pin moves. `/data/agents/bin` holds each one under a stable -name and is on the container's `PATH`, so `docker compose exec codedeck-bridge -claude …` (or `opencode …`) works once it is installed. For a host without -internet access, build with `CODEDECK_BUNDLE_AGENTS=1` in `.env`: the image -then carries both agents, as before. +The container image works the same way, with the binaries in +`/data/agents` — which the compose file keeps in a volume of Docker's own +(`agents`), not in `./data` — so a container recreated from a newer image +reuses them until the pin moves. A volume rather than the host directory for +two reasons: Docker fills a volume from the image when it is first created +(a host directory it never fills), and on Docker Desktop a host directory is +a file share slow enough to dominate a runtime of hundreds of packages (the +DeepSeek Harness boots in about a second from the volume and in about 25 from +`./data`). `/data/agents/bin` holds each one under a stable name and is on +the container's `PATH`, so `docker compose exec codedeck-bridge claude …` (or +`opencode …`) works once it is installed. For a host without internet access, +build with `CODEDECK_BUNDLE_AGENTS=1` in `.env`: the image then carries all +three agents, and they reach the volume when it is first created; a volume +that already exists keeps the agents already in it. The links in `/agents/bin` are for people. The agent host never uses them to find an agent, because after an upgrade they may still point at the @@ -122,6 +141,7 @@ you need. | `torProxyUrl` | `CODEDECK_TOR_PROXY_URL` / `--tor-proxy` | SOCKS5 proxy (e.g. `socks5h://127.0.0.1:9050`) for the relay connections | | `claudePath` | `CODEDECK_CLAUDE_PATH` / `--claude-path` | A specific `claude` binary instead of the bundled one | | `openCodeServerUrl`, `openCodeAutoStart`, `openCodePath`, `openCodePort` | `CODEDECK_OPENCODE_*` | The optional OpenCode agent — see [`OPENCODE.md`](OPENCODE.md) | +| `deepseekPath` | `CODEDECK_DEEPSEEK_PATH` / `--deepseek-path` | A DeepSeek Harness CLI to run instead of the one this build installs — see [`DEEPSEEK.md`](DEEPSEEK.md) | | `relayRegisterEndpoint` / `relayRegisterToken` | `CODEDECK_RELAY_REGISTER_ENDPOINT` / `..._TOKEN` | Register paired phones on a write-restricted relay (https only — the token is an admin secret) | | `blossomRegisterEndpoint` / `blossomRegisterToken` | `CODEDECK_BLOSSOM_REGISTER_ENDPOINT` / `..._TOKEN` | The same for a Blossom server, so a phone's attachments do not fall back to relay chunking | | `transcriptKeepLast` | `CODEDECK_TRANSCRIPT_KEEP_LAST` | Entries kept per session transcript (default 5000; 0 keeps all) | diff --git a/docs/DEEPSEEK.md b/docs/DEEPSEEK.md new file mode 100644 index 00000000..19a8d3f1 --- /dev/null +++ b/docs/DEEPSEEK.md @@ -0,0 +1,271 @@ +# DeepSeek Harness backend + +CodeDeck+ runs sessions on any agent its agent host has a driver for: Claude +Code, [OpenCode](https://opencode.ai), and the +[DeepSeek Harness](https://www.deepseek.com/en/harness/) (`dsh`). A phone picks +the agent per session from the "New session" sheet, which lists what the +bridge advertises in its heartbeat. The DeepSeek Harness is on by default and +needs one thing: a DeepSeek API key (set from the phone, or exported on the +bridge). + +The driver (`packages/agent-host/src/drivers/deepseek/`) drives the harness's +`acp` profile — `dsh --profile acp` — which is the only interface it exposes +that has everything a remote client needs: prompts, cancellation, one-shot +permission asks, and a model/reasoning choice that can change mid-session. +Its `sdk` profile has none of the last three and `headless` is one-shot. + +## Getting it running + +Nothing to install by hand. The harness is a Node program — a few hundred MB +of npm packages, with no single binary to ship — so the agent host installs +it on demand, exactly like the other agents' binaries: at the version and +sha512 this build pins in `pnpm-lock.yaml`, into `/agents/`, from the +npm registry (or `CODEDECK_NPM_REGISTRY`). The download starts as the bridge +starts; a first session waits for it. An operator who already has a harness +can point the bridge at it instead: + +```bash +codedeck-bridge run --deepseek-path /usr/local/lib/node_modules/@deepseek-ai/dsh/lib/bin.js +# or: CODEDECK_DEEPSEEK_PATH=… codedeck-bridge run +# or in config.json: { "deepseekPath": "…" } +``` + +The path is the harness's CLI entry point (`…/@deepseek-ai/dsh/lib/bin.js`) or +an executable of your own; the host runs a `.js` entry point with its own +`node`. + +The key is a **DeepSeek API key**: set it from the phone (Agents → DeepSeek +Harness → credential), or export `DEEPSEEK_API_KEY` on the bridge — an +exported key wins over a stored one and the phone cannot clear it. The key is +verified against `https://api.deepseek.com/models` when you save it (a +refusal means the key is not accepted; a network error means nothing was +claimed either way). An exported `DEEPSEEK_*` variable is the harness's alone: +the agent host keeps it out of the other agents' processes, where it would +mean something else (OpenCode would offer a DeepSeek provider of its own). + +## Its own home + +The harness keeps everything under `$DSH_HOME`: its profiles (the `acp` +profile's configuration), the sessions a bridge can resume, and its +credentials. CodeDeck+ sets it to `/dsh` (`/data/dsh` in the container +image), following the bridge's home so a bridge that moves its data directory +does not leave an agent's conversations behind. `CODEDECK_DEEPSEEK_HOME` +overrides it. It is shared by every session, whatever provider profile a +session is bound to. + +## Models and reasoning + +The model list and the reasoning levels come from the harness itself, read +from a short-lived probe session when the phone asks for models; nothing is +hardcoded here, so a deployment that replaces the harness's model catalog +sees its own models in the phone. Two things follow from the harness's own +shape: + +- A model's value is the harness's own opaque selector (it names a provider + and a model together), not a friendly id. The phone shows the model's + label; what it stores and sends back is that value. A session started with + a model the running catalog does not have is refused, with the reason. +- The reasoning levels are `off`, `low`, `high` and `max` (the DeepSeek + route's own), and a model without reasoning support has none — a session + started with a level such a model does not have reports that rather than + refusing to start. + +The session header shows the model the harness actually resolved, and how +full the context is (the harness reports its own usage). + +## Gateways and other providers + +Two ways to run sessions somewhere other than DeepSeek's own API, and both end +up at the same place: the harness's DeepSeek route pointed at that endpoint, +with `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`. + +**The bridge's own endpoint** (every session that does not name a profile of +its own). Set both in the bridge's environment — for the container, `.env`: + +```bash +DEEPSEEK_BASE_URL=http://gateway.example:3458 # a DeepSeek-compatible relay or router +DEEPSEEK_API_KEY=… # travelled as a compose secret, not an env var +``` + +Then the endpoint's *own* model list becomes the harness's catalog: as the +host starts, CodeDeck asks it for its models (`/v1/models`, on the same +root the harness itself posts `/messages` to) and writes that list into the +harness's profile, where the harness documents its catalog as replaceable. So +the phone offers exactly what the gateway serves — for a router whose ids name +their channel (`Z.ai (Global) - Coding Plan/glm-5.3-flash`), the channel is +shown as the model's provider, the way Claude Code's gateway models are. The +model a session starts on moves with the list, since the harness always +offers the model it is on: a DeepSeek default the gateway does not serve would +otherwise appear there as a model nobody can run. + +The key is checked against that same list when you save it (a refusal is shown +as rejected; an endpoint that cannot be reached claims nothing). An endpoint +that does not answer leaves the harness's own catalog alone — its three +DeepSeek models — and nothing is guessed; clearing `DEEPSEEK_BASE_URL` takes +CodeDeck's row back out again. + +**A provider profile on the phone** (one session). Same idea, per session: the +profile's endpoint and token reach that session's harness instead of the +operator's, and the profile's models are what the phone offers. Pick this when +one bridge should run, say, a native session and a gateway session side by +side — a session's provider binding applies to its whole process, so the two +do not mix. + +Both go through the same rules as the other agents: the base URL must be +`https://` (plain `http://` only to localhost, 127.0.0.1 or `[::1]`), a token +is required, and the harness's whole `DEEPSEEK_*` namespace is dropped from +the environment of a bound session — an operator's native key must not be +billed for a session bound somewhere else. `DSH_HOME` survives: where the +harness keeps its state is not routing. + +One limit of the harness's own design: a session bound to an endpoint is +reached through its DeepSeek route, so that endpoint has to speak that API. +A gateway of another shape (OpenAI-completions, Anthropic messages) is +configured in the harness's own profile — dsh's configuration to write, not +this driver's. + +## Commands and questions + +Two things the harness does in this profile that its automation surface does +not carry, and that CodeDeck brings to the phone with one small plugin of its +own (`codedeck-dsh-bridge`, mounted by a row in the profile's patch layer): + +**Slash commands.** The harness has `/compact`, `/goal`, `/feedback`, `/plan`, +`/permission`, and whatever its plugins add. ACP carries no command list and +no way to invoke one, and the harness keeps commands for its own UI modules, so +a typed `/name` would otherwise be prompt text that reaches the model. The +plugin holds the command registry and answers two questions over a local +socket: what this session can run, and "run this line". The phone lists what it +says, and a typed `/name` runs there instead of being sent to the model — the +command's own words come back as the agent's answer (`/plan` answers "Plan mode +on", `/goal set X` answers with the goal it now holds). + +**The question tool.** The harness's shared core registers the question +*service* and the plan-mode tool that presents a plan through it, but the tool +the model asks with — `ask_user_question` — is mounted by the web app's agent +presets, not by the core. An automation profile therefore runs a model that +plan mode's own instructions tell to ask with a tool it does not have, and a +model asked about its tools says exactly that. The driver mounts it in the +profile's patch layer, bare, the way the harness's own presets do; the row +names the harness's own package, so nothing is installed into the profile for +it. `present` — the web app's deliverables tool — is deliberately left out: +nothing on the phone would show what it declares. + +**Questions.** The harness's model can put a decision to the user — its +`ask_user_question` tool, that tool's timed form, and the plan review +`exit_plan_mode` presents all ask through one service — and that service's +answerer is a panel in the harness's own apps. Without one the ask fails with +"no user-questions answerer configured", which is what a plain setup does. The +plugin composes an answerer instead: the ask is pushed to the host as a marker +line on the harness's stderr (the one stream that is not ACP's), the phone +shows it as the very card the other agents' ask uses, and the answer goes back +the way the harness takes one — the labels of the chosen options, or the text +the user typed. Each question carries its own id, so a batch of them comes +back matched to what was asked. + +A plan review arrives as the same exchange Claude Code's is, because it is the +same one: the plan as a plan of its own, and the choice as the approval card, +wearing the harness's own labels (approve, or keep planning). The labels are +the verdict — the harness's tool looks for the one its intent declared — so a +plan the user did not approve goes back to the model to revise. What the user +wants changed goes with that choice, in the same answer: the card asks for it +when they choose to keep planning, and the harness's tool hands it to the +model with the request to revise (the tool asks for a revision at once, so +feedback sent as a later message would arrive after the model had already +started one). + +**Messages during a turn.** ACP takes one prompt at a time, so a message sent +while the agent works would otherwise wait for the turn to end. The plugin +hands it to the running turn instead — the harness's own steering, which its +apps use for exactly this — and the model reads it at its next step. A slash +command, or a message that arrives as the turn ends, waits for the next turn +as before (as do the ones sent after it, so nothing overtakes it). A message +while a question card is open still answers that question, as with every +agent. + +Each harness process has a socket of its own — a file under the harness's home +(`/data/dsh/codedeck/dsh-bridge-.sock`, a named pipe on Windows), named +in that process's environment rather than in the shared profile, since one +bridge can run several processes at once (a session bound to a provider +profile gets its own). A harness started any other way — by hand, or by the +plugin CLI — gets no socket, so its plugin stays out of the way and its +questions fail the harness's own way. Nothing else about the profile +changes, and the plugin is harmless if the harness moves under it: a list that +cannot be read means the phone offers no commands, an unanswerable question +reads as one the user did not answer, and a slash line is ordinary text again — +a session never fails over either. Two things it does not do: a command that +takes attachments gets none (this bridge sends the line alone), and the rows of +the calls that ask the user — the question tool's and the plan review's — are +hidden in the transcript, since the card is the exchange (the plan review's row +would be the plan again, as raw arguments). + +## MCP servers + +The harness reads its MCP servers from the `acp` profile's own patch layer, +`/dsh/profiles/acp/cordis.patch.yml` — the file the harness documents as +the user's. This driver owns one delimited block of it (the MCP screen writes +there); everything around the block, comments included, is preserved. A +server is a `dsh-mcp-client` row reaching it over **stdio** or **streamable +HTTP** (no SSE), and switching one off leaves its row in place with a disable +row after it, so it can be switched back on. + +The servers are attached when the harness starts, which is why a session +shows them but cannot switch one: change the list on the MCP screen and the +next harness process uses it (the session screen says `pending` for a server +the running harness has not loaded). Failures are the harness's own — it logs +them on stderr, which is where the session screen's `failed` status and its +message come from; a server that cannot start does not stop the session. + +## Plugins + +A plugin is an npm package installed into the profile and mounted as a patch +layer. The harness's own CLI does the work — `dsh plugin --profile acp add +`, which runs pnpm in the profile directory — so its pnpm, its lock +and its version-compatibility gate are the ones that apply, and a refusal is +whatever it printed. That means **pnpm must be on the agent host's `PATH`** +for install, uninstall and update to work; listing and switching need nothing. + +- Installing a package that declares a `dsh.bundle` adds it to the profile's + layer list: that is what "enabled" means here. A package that declares none + is installed as a plain dependency (the harness says so itself) and stays + switched off. +- Switching a plugin off takes it out of the layer list without uninstalling + it. The profile's own composition — the shared core and the ACP application + — is not switchable. +- There are no marketplaces: a plugin is an npm package name, optionally with + a version. + +## What this does not have + +The automation profile carries none of these, and the driver says so in the +catalog the phone reads: + +- **No modes.** The harness has none, so `ask` and `YOLO` are this driver's + own: send every permission to the phone, or allow everything. Asked + permissions offer Allow and Deny only — the harness has no "always allow" + to choose. +- **No background tasks**, and no attachments on a command or a question. +- **No subscription usage** to ask for (the context meter still works). +- **No diff blocks.** The harness reports a tool result as text, so a file + change is reconstructed from the call's own arguments — what an `edit` + replaced, what a `write` wrote. A change made by a shell command shows as + its output, not as a card. +- **No image prompts from the phone** (the harness advertises them per model; + CodeDeck+ sends text). + +## Offline and container notes + +- The container image installs the harness into `/data/agents` on first use, + like the other agents. Build with `CODEDECK_BUNDLE_AGENTS=1` (see + [`BRIDGE.md`](BRIDGE.md)) and the image carries it instead, from the same + installer and so at the same pinned version. +- `CODEDECK_AGENT_HOST_WARM=1` runs that install and exits, for a host that + should fetch everything before it serves anything. +- The harness's runtime is large (some 600 packages): a minute or two on a + normal connection. The compose file keeps it in the `agents` volume rather + than in `./data`: a harness process reads the whole tree every time it + boots, and from a Docker Desktop host directory that is some 25 seconds + against about one from a volume. (A host directory can also refuse a + rename for a moment, which the installer waits out.) +- A container recreated with an existing `agents` volume keeps whatever + agents that volume already has; the bundled copy reaches a *new* volume. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 24408519..5f7b1fbe 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -124,7 +124,7 @@ tagged by `entryType`: | `background_task` | work left running beside the turn: `taskId`, `kind: shell|agent|other`, `title`, `status: running|completed|failed|stopped`, `callId?` (the call that started it), `summary?`; the latest entry for a `taskId` is where it stands | | `permission_request` | a card: `requestId`, the tool, and `options[] {id, label, kind: allow_once|allow_always|reject_once|reject_always}`; optional `reason` (why the agent asks, in its words) `hook` (the hook that asked, e.g. `PreToolUse:Bash` — it asks every time, so no "always" option) and `hookPlugin` (the plugin that hook comes from, when exactly one loaded plugin and no settings file declares a matching hook) | | `question` | one question of an ask: `requestId`, `index`/`count`, `options`, `multiSelect` | -| `plan_approval` | a card: `requestId`, `options[]` | +| `plan_approval` | a card: `requestId`, `options[]`; optional `revise` (the option that sends the plan back to revise, which the user's feedback may go with) | | `resolved` | a card was answered or cancelled: `requestId`, `summary` | | `notice` | `session_restart`, `session_died`, `session_failed`, `auth_error` | | `status`, `error`, `turn_complete` | one-line status, an error, the end of a turn | @@ -135,7 +135,8 @@ Clients branch on `entryType` and `kind`, never on tool names. closed by exactly one `resolved` entry with the same `requestId` — answered, timed out (after an hour), interrupted, or cancelled because the agent died. Answers: `permission-response {requestId, optionId}`, -`plan-response {requestId, optionId}`, +`plan-response {requestId, optionId, feedback?}` (`feedback` only with the +card's `revise` option; the bridge records it as the user's message), `question-response {requestId, index, answer: {kind: options, selected} | {kind: text, text}}`. Plain `input` while a question is pending answers its first unanswered question. @@ -303,14 +304,16 @@ Changing a secret means adding the server again. - `mcp-request {agent}` → `mcp-servers {agent, servers[], toggles, error?}`. `toggles`: a server can be switched off without removing it (OpenCode; - Claude Code has no such switch). A list that could not be read comes + Claude Code and the DeepSeek Harness have no such switch). A list that could + not be read comes empty, with `error`. - `mcp-action {agent, action, servers?, names?}` → `mcp-ack {agent, action, names, success, error?}`, then (when done) the new `mcp-servers` to every phone. `add` takes up to 50 `servers` (a name that exists is replaced); `remove`, `enable`, `disable` take `names`. One bad server refuses the whole action. Running sessions pick the change up (Claude Code reloads in - place; OpenCode reloads, restarting them). + place; OpenCode reloads, restarting them; the harness attaches its servers + as it starts, so its next process uses the new list). - `session-mcp-request {sessionId}` and `session-mcp-toggle {sessionId, name, enabled}` → `session-mcp {sessionId, servers[], toggles, projectWide, error?}`, each server `{name, status, error?, tools?}` with `status` one of @@ -507,7 +510,7 @@ Requests the host makes (the bridge answers each exactly once): |---|---| | `request-permission {sessionId, requestId, toolName, kind, title, …, options, reason?, hook?, hookPlugin?}` | `permission-outcome {outcome: selected {optionId} \| cancelled {reason}}` | | `ask-question {sessionId, requestId, questions}` | `question-outcome {outcome: answered {answers} \| cancelled {reason}}` | -| `request-plan-approval {sessionId, requestId, options}` | `plan-outcome` (as permission) | +| `request-plan-approval {sessionId, requestId, options, revise?}` | `plan-outcome {outcome: selected {optionId, feedback?} \| cancelled {reason}}` (`feedback` only with `revise`) | A `cancelled` outcome means nobody chose: the card timed out, the user interrupted, or the session is ending. diff --git a/packages/agent-host/package.json b/packages/agent-host/package.json index 7fbedb91..a89275dc 100644 --- a/packages/agent-host/package.json +++ b/packages/agent-host/package.json @@ -3,7 +3,7 @@ "version": "1.2.0", "private": true, "type": "module", - "description": "CodeDeck+ agent host — the Node sidecar the bridge runs agent SDKs in (Claude Code, OpenCode), spoken to over the driver protocol on stdio.", + "description": "CodeDeck+ agent host — the Node sidecar the bridge runs agent SDKs in (Claude Code, OpenCode, DeepSeek Harness), spoken to over the driver protocol on stdio.", "scripts": { "build": "node build.mjs", "typecheck": "tsc --noEmit", @@ -13,9 +13,13 @@ "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.283", "@opencode-ai/sdk": "^1.18.32", + "js-yaml": "^4.2.0", "zod": "^4.6.5" }, "devDependencies": { + "@agentclientprotocol/sdk": "^1.7.0", + "@deepseek-ai/dsh": "0.2.0-rc.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^22.10.0", "esbuild": "^0.25.0", "opencode-ai": "1.18.32", diff --git a/packages/agent-host/src/__tests__/agentInstall.test.ts b/packages/agent-host/src/__tests__/agentInstall.test.ts index 1e5d79d9..7839e38b 100644 --- a/packages/agent-host/src/__tests__/agentInstall.test.ts +++ b/packages/agent-host/src/__tests__/agentInstall.test.ts @@ -4,14 +4,23 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { gzipSync } from 'node:zlib'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { extractFile, installBinary, type PackagedBinary, withoutAgentBin } from '../agentInstall'; +import { + extractFile, + extractTree, + installBinary, + installPackageTree, + renameIntoPlace, + type PackagedBinary, + withoutAgentBin, +} from '../agentInstall'; +import type { TreePackageEntry } from '../lockfilePins'; /** One ustar header + body, padded to 512-byte blocks. */ -function tarEntry(name: string, body: Buffer | string, type = '0'): Buffer { +function tarEntry(name: string, body: Buffer | string, type = '0', mode = 0o755): Buffer { const data = typeof body === 'string' ? Buffer.from(body) : body; const header = Buffer.alloc(512); header.write(name.slice(0, 100), 0); - header.write('0000755\0', 100); + header.write(`${mode.toString(8).padStart(7, '0')}\0`, 100); header.write('0000000\0', 108); header.write('0000000\0', 116); header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124); @@ -164,6 +173,194 @@ describe.skipIf(process.platform === 'win32')('the stable links in /bin', }); }); +describe('extractTree', () => { + let dir: string; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'extract-tree-')); + }); + afterEach(() => fs.rmSync(dir, { recursive: true, force: true })); + + it('writes every file under dest, stripping the package/ prefix, whatever the chunking', async () => { + const tar = Buffer.concat([ + tarEntry('package/', '', '5'), + tarEntry('package/package.json', '{"version":"1.0.0"}', '0', 0o644), + tarEntry('package/lib/', '', '5'), + tarEntry('package/lib/bin.js', 'console.log(1)'), + tarEntry('package/LICENSE', 'mit', '0', 0o644), + Buffer.alloc(1024), + ]); + for (const size of [1, 300, 512, 4096, tar.length]) { + const dest = path.join(dir, `tree-${size}`); + await extractTree(chunks(tar, size), dest); + expect(fs.readFileSync(path.join(dest, 'package.json'), 'utf8')).toBe('{"version":"1.0.0"}'); + expect(fs.readFileSync(path.join(dest, 'lib', 'bin.js'), 'utf8')).toBe('console.log(1)'); + expect(fs.readFileSync(path.join(dest, 'LICENSE'), 'utf8')).toBe('mit'); + } + }); + + it('skips an entry that would leave the package, and keeps going', async () => { + const tar = Buffer.concat([ + tarEntry('package/../evil', 'no'), + tarEntry('package/ok', 'yes'), + Buffer.alloc(1024), + ]); + await extractTree(chunks(tar, 512), dir); + expect(fs.existsSync(path.join(dir, 'evil'))).toBe(false); + expect(fs.readFileSync(path.join(dir, 'ok'), 'utf8')).toBe('yes'); + }); + + it('keeps a file executable only when its tar mode is', async () => { + const tar = Buffer.concat([ + tarEntry('package/run.sh', '#!/bin/sh\n', '0', 0o755), + tarEntry('package/plain.txt', 'x', '0', 0o644), + Buffer.alloc(1024), + ]); + await extractTree(chunks(tar, 512), dir); + if (process.platform !== 'win32') { + expect(fs.statSync(path.join(dir, 'run.sh')).mode & 0o111).not.toBe(0); + expect(fs.statSync(path.join(dir, 'plain.txt')).mode & 0o111).toBe(0); + } + }); + + it('follows a pax long name', async () => { + const long = `package/${'d/'.repeat(60)}tool`; + const tar = Buffer.concat([paxEntry(long), tarEntry('package/truncated', 'x'.repeat(10)), Buffer.alloc(1024)]); + await extractTree(chunks(tar, 300), dir); + expect(fs.readFileSync(path.join(dir, ...long.slice('package/'.length).split('/')), 'utf8')).toBe('x'.repeat(10)); + }); +}); + +describe('renameIntoPlace', () => { + it('retries a rename a filesystem refuses for a moment, and then takes it', async () => { + const codes: Array = ['EACCES', 'EPERM', 'EBUSY', undefined]; + let calls = 0; + const rename = (): void => { + const code = codes[calls++]; + if (code !== undefined) throw Object.assign(new Error(`${code}: permission denied`), { code }); + }; + const logs: string[] = []; + await renameIntoPlace('/staging/a', '/tree/a', { rename, log: (line) => logs.push(line) }); + expect(calls).toBe(4); + // Said once, so a slow install on such a filesystem is explicable. + expect(logs).toEqual(['[install] /tree/a was busy (EPERM); retrying']); + }); + + it('fails at once for a rename that is a real error', async () => { + const failing = (): void => { + throw Object.assign(new Error('ENOTEMPTY: directory not empty'), { code: 'ENOTEMPTY' }); + }; + await expect(renameIntoPlace('/a', '/b', { rename: failing })).rejects.toThrow(/ENOTEMPTY/); + }); + + it('gives up after enough attempts on one that never takes', async () => { + let attempts = 0; + const never = (): void => { + attempts++; + throw Object.assign(new Error('EACCES: still busy'), { code: 'EACCES' }); + }; + await expect(renameIntoPlace('/a', '/b', { rename: never })).rejects.toThrow(/still busy/); + expect(attempts).toBe(8); + }); +}); + +describe('installPackageTree', () => { + let cache: string; + const log = vi.fn(); + + /** A minimal npm package tarball: package.json + lib/bin.js for the root. */ + const packageTarball = (name: string, version: string): Buffer => + tarball( + tarEntry('package/package.json', JSON.stringify({ name, version }), '0', 0o644), + ...(name === '@deepseek-ai/dsh' ? [tarEntry('package/lib/bin.js', 'bin!')] : [tarEntry('package/index.js', 'ok', '0', 0o644)]), + ); + + const tgzOf = new Map([ + ['@deepseek-ai/dsh', packageTarball('@deepseek-ai/dsh', '1.0.0')], + ['commander', packageTarball('commander', '15.0.0')], + ['shared-old', packageTarball('shared-old', '0.6.4')], + ]); + const entries: TreePackageEntry[] = [ + { name: '@deepseek-ai/dsh', version: '1.0.0', integrity: sha512(tgzOf.get('@deepseek-ai/dsh')!), dest: 'node_modules/@deepseek-ai/dsh' }, + { name: 'commander', version: '15.0.0', integrity: sha512(tgzOf.get('commander')!), dest: 'node_modules/commander' }, + // A second version of a name, nested under its consumer like pnpm laid it out. + { name: 'shared-old', version: '0.6.4', integrity: sha512(tgzOf.get('shared-old')!), dest: 'node_modules/commander/node_modules/shared-old' }, + { name: 'sunos-only', version: '1.0.0', integrity: 'sha512-whatever', dest: 'node_modules/sunos-only', optional: true, os: ['sunos'] }, + ]; + const registryOf = (bodies: Map) => + vi.fn(async (url: string | URL | Request) => { + const name = [...bodies.keys()].find((n) => String(url).includes(`/${n}/-`)); + const body = name !== undefined ? bodies.get(name) : undefined; + if (typeof body === 'number') return new Response('nope', { status: body }); + return body !== undefined ? new Response(body) : new Response('nope', { status: 404 }); + }) as unknown as typeof fetch; + + beforeEach(() => { + cache = fs.mkdtempSync(path.join(os.tmpdir(), 'install-tree-')); + log.mockClear(); + }); + afterEach(() => fs.rmSync(cache, { recursive: true, force: true })); + + it('lays every entry out at its dest, serving a repeat call from the cache', async () => { + const fetchFn = registryOf(tgzOf); + const root = await installPackageTree('@deepseek-ai/dsh', entries, { cacheDir: cache, registry: 'https://registry.test', log, fetchFn, label: 'DSH' }); + expect(root).toBe(path.join(cache, '@deepseek-ai+dsh@1.0.0', 'node_modules', '@deepseek-ai', 'dsh')); + expect(JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'))).toEqual({ name: '@deepseek-ai/dsh', version: '1.0.0' }); + expect(fs.readFileSync(path.join(root, 'lib', 'bin.js'), 'utf8')).toBe('bin!'); + expect(fs.readFileSync(path.join(root, '..', '..', 'commander', 'index.js'), 'utf8')).toBe('ok'); + expect( + JSON.parse(fs.readFileSync(path.join(root, '..', '..', 'commander', 'node_modules', 'shared-old', 'package.json'), 'utf8')).version, + ).toBe('0.6.4'); + // The sunos-only optional is gated away on any machine CI runs; it must + // be neither fetched nor laid down. + expect(fs.existsSync(path.join(cache, '@deepseek-ai+dsh@1.0.0', 'node_modules', 'sunos-only'))).toBe(false); + expect(fetchFn).toHaveBeenCalledTimes(3); + + await installPackageTree('@deepseek-ai/dsh', entries, { cacheDir: cache, log, fetchFn, label: 'DSH' }); + expect(fetchFn).toHaveBeenCalledTimes(3); + expect(fs.readdirSync(path.join(cache, '@deepseek-ai+dsh@1.0.0'))).toEqual(['node_modules']); // no staging left + }); + + it('replaces a package left at the wrong version', async () => { + const stale = path.join(cache, '@deepseek-ai+dsh@1.0.0', 'node_modules', 'commander'); + fs.mkdirSync(stale, { recursive: true }); + fs.writeFileSync(path.join(stale, 'package.json'), '{"version":"14.0.0"}'); + const fetchFn = registryOf(tgzOf); + await installPackageTree('@deepseek-ai/dsh', entries, { cacheDir: cache, log, fetchFn, label: 'DSH' }); + expect(JSON.parse(fs.readFileSync(path.join(stale, 'package.json'), 'utf8')).version).toBe('15.0.0'); + }); + + it('skips an optional package the registry will not serve', async () => { + const withOptional = [ + ...entries.slice(0, 3), + { name: 'maybe-here', version: '1.0.0', integrity: 'sha512-x', dest: 'node_modules/maybe-here', optional: true } as TreePackageEntry, + ]; + await installPackageTree('@deepseek-ai/dsh', withOptional, { cacheDir: cache, log, fetchFn: registryOf(tgzOf), label: 'DSH' }); + expect(log).toHaveBeenCalledWith(expect.stringMatching(/skipping optional maybe-here@1.0.0/)); + expect(fs.existsSync(path.join(cache, '@deepseek-ai+dsh@1.0.0', 'node_modules', 'maybe-here'))).toBe(false); + }); + + it('fails the install when a required tarball does not match its pin', async () => { + const tampered = new Map([['@deepseek-ai/dsh', packageTarball('@deepseek-ai/dsh', '9.9.9')], ['commander', tgzOf.get('commander')!], ['shared-old', tgzOf.get('shared-old')!]]); + await expect( + installPackageTree('@deepseek-ai/dsh', entries, { cacheDir: cache, log, fetchFn: registryOf(tampered), label: 'DSH' }), + ).rejects.toThrow(/@deepseek-ai\/dsh could not be installed: the tarball does not match its pinned sha512/); + }); + + it('refuses a root this build does not pin', async () => { + await expect(installPackageTree('unpinned', entries, { cacheDir: cache, log })).rejects.toThrow(/unpinned is not pinned/); + }); + + it('prunes older versions of the tree once the new one is in', async () => { + const old = path.join(cache, '@deepseek-ai+dsh@0.9.0'); + const unrelated = path.join(cache, 'opencode-linux-x64@1.0.0'); + fs.mkdirSync(old); + fs.mkdirSync(unrelated); + await installPackageTree('@deepseek-ai/dsh', entries, { cacheDir: cache, log, fetchFn: registryOf(tgzOf), label: 'DSH' }); + expect(fs.existsSync(old)).toBe(false); + expect(fs.existsSync(unrelated)).toBe(true); + }); +}); + describe('withoutAgentBin', () => { it("drops only /bin from PATH, keeping the variable's own name", () => { const cache = path.resolve('/srv/agents'); diff --git a/packages/agent-host/src/__tests__/context.ts b/packages/agent-host/src/__tests__/context.ts index f32a4d4d..52497b8b 100644 --- a/packages/agent-host/src/__tests__/context.ts +++ b/packages/agent-host/src/__tests__/context.ts @@ -7,6 +7,7 @@ import type { OptionChoice, OutputEntry, PermissionRequest, + PlanOutcome, QuestionOutcome, QuestionSpec, SelectOutcome, @@ -16,14 +17,14 @@ import type { export interface Handlers { permission?: (request: Omit) => SelectOutcome | Promise; question?: (requestId: string, questions: QuestionSpec[]) => QuestionOutcome | Promise; - plan?: (requestId: string, options: OptionChoice[]) => SelectOutcome | Promise; + plan?: (requestId: string, options: OptionChoice[], revise?: string) => PlanOutcome | Promise; } export interface RecordingContext extends SessionContext { events: SessionEvent[]; permissions: Array>; questions: Array<{ requestId: string; questions: QuestionSpec[] }>; - plans: Array<{ requestId: string; options: OptionChoice[] }>; + plans: Array<{ requestId: string; options: OptionChoice[]; revise?: string }>; logs: string[]; /** Every entry reported so far, in order. */ entries(): OutputEntry[]; @@ -61,9 +62,9 @@ export function recordingContext(handlers: Handlers = {}, sessionId = 's1'): Rec ctx.questions.push({ requestId, questions }); return handlers.question ? handlers.question(requestId, questions) : { outcome: 'cancelled', reason: 'no handler' }; }, - async requestPlanApproval(requestId, options) { - ctx.plans.push({ requestId, options }); - return handlers.plan ? handlers.plan(requestId, options) : { outcome: 'cancelled', reason: 'no handler' }; + async requestPlanApproval(requestId, options, revise) { + ctx.plans.push({ requestId, options, ...(revise !== undefined ? { revise } : {}) }); + return handlers.plan ? handlers.plan(requestId, options, revise) : { outcome: 'cancelled', reason: 'no handler' }; }, log(message) { ctx.logs.push(message); diff --git a/packages/agent-host/src/__tests__/lockfilePins.test.ts b/packages/agent-host/src/__tests__/lockfilePins.test.ts index af57961d..e8d6e5cf 100644 --- a/packages/agent-host/src/__tests__/lockfilePins.test.ts +++ b/packages/agent-host/src/__tests__/lockfilePins.test.ts @@ -1,7 +1,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { platformPackages, renderPlatformPackages } from '../lockfilePins'; +import { packageTree, platformPackages, renderDshPackages, renderPlatformPackages } from '../lockfilePins'; const LOCKFILE = `lockfileVersion: '9.0' @@ -65,3 +65,111 @@ describe('src/generated/platformPackages.ts', () => { await expect(renderPlatformPackages(platformPackages(lockfile))).toMatchFileSnapshot('../generated/platformPackages.ts'); }); }); + +// A format-9 lockfile in miniature: metadata in `packages:`, resolved graphs +// in `snapshots:` with pnpm's `(peer…)`/`(hash)` id suffixes, the root pinned +// for an importer, and a name the closure needs at two versions (negotiator) +// — the walker must read through all of that and place every entry where +// Node would resolve it. +const TREE_LOCKFILE = `lockfileVersion: '9.0' + +importers: + + packages/agent-host: + dependencies: + kept: + specifier: ^1.0.0 + version: 1.0.0 + devDependencies: + '@deepseek-ai/dsh': + specifier: 1.0.0 + version: 1.0.0(ab12cd34) + +packages: + + '@deepseek-ai/dsh@1.0.0': + resolution: {integrity: sha512-dsh} + hasBin: true + + '@deepseek-ai/dsh-base@1.0.0': + resolution: {integrity: sha512-base} + + commander@15.0.0: + resolution: {integrity: sha512-commander} + version: 15.0.0 + + negotiator@0.6.4: + resolution: {integrity: sha512-neg-old} + + negotiator@1.1.0: + resolution: {integrity: sha512-neg-new} + + wasm-fallback@1.0.0: + resolution: {integrity: sha512-wasm} + + darwin-only@1.0.0: + resolution: {integrity: sha512-darwin} + os: [darwin] + + unrelated@2.0.0: + resolution: {integrity: sha512-unrelated} + +snapshots: + + '@deepseek-ai/dsh@1.0.0(ab12cd34)': + dependencies: + '@deepseek-ai/dsh-base': 1.0.0(cd34ef56) + commander: 15.0.0 + negotiator: 1.1.0 + wasm-fallback: 1.0.0 + + '@deepseek-ai/dsh-base@1.0.0(cd34ef56)': + dependencies: + commander: 15.0.0 + negotiator: 0.6.4 + optionalDependencies: + darwin-only: 1.0.0 +`; + +describe('packageTree', () => { + it('places the closure, nesting a second version under its consumer', () => { + expect(packageTree(TREE_LOCKFILE, 'packages/agent-host', '@deepseek-ai/dsh')).toEqual([ + { name: '@deepseek-ai/dsh', version: '1.0.0', integrity: 'sha512-dsh', dest: 'node_modules/@deepseek-ai/dsh' }, + { name: '@deepseek-ai/dsh-base', version: '1.0.0', integrity: 'sha512-base', dest: 'node_modules/@deepseek-ai/dsh-base' }, + { name: 'negotiator', version: '1.1.0', integrity: 'sha512-neg-new', dest: 'node_modules/@deepseek-ai/dsh/node_modules/negotiator' }, + { name: 'commander', version: '15.0.0', integrity: 'sha512-commander', dest: 'node_modules/commander' }, + { name: 'darwin-only', version: '1.0.0', integrity: 'sha512-darwin', dest: 'node_modules/darwin-only', optional: true, os: ['darwin'] }, + { name: 'negotiator', version: '0.6.4', integrity: 'sha512-neg-old', dest: 'node_modules/negotiator' }, + { name: 'wasm-fallback', version: '1.0.0', integrity: 'sha512-wasm', dest: 'node_modules/wasm-fallback' }, + ]); + }); + + it('refuses ambiguous peer resolutions of one package', () => { + const ambiguous = TREE_LOCKFILE.replace( + 'snapshots:', + "snapshots:\n\n '@deepseek-ai/dsh-base@1.0.0(other)':\n dependencies:\n commander: 15.0.0", + ); + expect(() => packageTree(ambiguous, 'packages/agent-host', '@deepseek-ai/dsh')).toThrow(/peer resolutions of @deepseek-ai\/dsh-base@1\.0\.0/); + }); + + it('refuses a dependency with no pinned entry', () => { + const dangling = TREE_LOCKFILE.replace( + " '@deepseek-ai/dsh@1.0.0(ab12cd34)':\n dependencies:", + " '@deepseek-ai/dsh@1.0.0(ab12cd34)':\n dependencies:\n ghost: 1.0.0", + ); + expect(() => packageTree(dangling, 'packages/agent-host', '@deepseek-ai/dsh')).toThrow(/no pinned entry for ghost@1\.0\.0/); + }); + + it('refuses a root the importer does not pin', () => { + expect(() => packageTree(TREE_LOCKFILE, 'packages/agent-host', 'not-a-dep')).toThrow(/does not pin not-a-dep/); + }); +}); + +describe('src/generated/dshPackages.ts', () => { + it('matches pnpm-lock.yaml', async () => { + const lockfile = fs.readFileSync(path.resolve(__dirname, '../../../../pnpm-lock.yaml'), 'utf8'); + await expect(renderDshPackages(packageTree(lockfile, 'packages/agent-host', '@deepseek-ai/dsh'))).toMatchFileSnapshot( + '../generated/dshPackages.ts', + ); + }); +}); diff --git a/packages/agent-host/src/agentInstall.ts b/packages/agent-host/src/agentInstall.ts index 151fdef0..57241509 100644 --- a/packages/agent-host/src/agentInstall.ts +++ b/packages/agent-host/src/agentInstall.ts @@ -19,10 +19,10 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { Readable, Transform } from 'node:stream'; import { pipeline } from 'node:stream/promises'; -import { createGunzip } from 'node:zlib'; -import { isFile } from './executable'; +import { createGunzip, gunzipSync } from 'node:zlib'; +import { isFile, isMusl } from './executable'; import { PLATFORM_PACKAGES } from './generated/platformPackages'; -import type { PackagePin } from './lockfilePins'; +import type { PackagePin, TreePackageEntry } from './lockfilePins'; const DEFAULT_REGISTRY = 'https://registry.npmjs.org'; @@ -143,7 +143,9 @@ export async function installBinary(binary: PackagedBinary, options: InstallOpti } if (!found) throw new Error(`${binary.pkg}@${pin.version} has no ${binary.file}`); fs.chmodSync(partial, 0o755); - fs.renameSync(partial, target); + // A file's rename is refused by the same filesystem in the same way (see + // renameIntoPlace), so it waits the same way. + await renameIntoPlace(partial, target, { log: options.log }); } catch (err) { throw new Error(`${binary.label} could not be installed: ${err instanceof Error ? err.message : String(err)}`); } finally { @@ -292,3 +294,323 @@ function paxPath(records: string): string | undefined { } return undefined; } + +// --- Whole dependency trees --- +// +// A pure-JS agent CLI (the DeepSeek Harness runtime) is not one platform +// binary but a ~90-package npm closure: nothing to run unless every package +// sits in one node_modules. The pins for that come from pnpm-lock.yaml the +// same way the single-binary pins do (src/lockfilePins.ts), and the same +// rules hold — every tarball at the exact pinned version, rejected unless +// its sha512 matches, from the configured registry or mirror. + +/** Whether a tree package's platform gates admit this machine. */ +export function packageMatchesPlatform( + pin: { os?: string[]; cpu?: string[]; libc?: string[] }, + platform: NodeJS.Platform = process.platform, + arch: string = process.arch, + musl: boolean = isMusl(), +): boolean { + if (pin.os && !pin.os.includes(platform)) return false; + if (pin.cpu && !pin.cpu.includes(arch)) return false; + if (pin.libc && !pin.libc.includes(musl ? 'musl' : 'glibc')) return false; + return true; +} + +/** The `version` of an installed package's package.json, or undefined for a + * missing or unreadable package. */ +function installedVersion(dir: string): string | undefined { + try { + const parsed = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')) as { version?: unknown }; + return typeof parsed.version === 'string' ? parsed.version : undefined; + } catch { + return undefined; + } +} + +/** Download one tarball and verify it against its sha512 pin. */ +async function fetchVerifiedTarball( + url: string, + integrity: string, + fetchFn: typeof fetch, +): Promise { + const res = await fetchFn(url); + if (!res.ok) throw new Error(`HTTP ${res.status} from ${url}`); + const body = Buffer.from(await res.arrayBuffer()); + const actual = `sha512-${createHash('sha512').update(body).digest('base64')}`; + if (actual !== integrity) throw new Error('the tarball does not match its pinned sha512; the download was rejected'); + return body; +} + +async function* oneBuffer(data: Buffer): AsyncGenerator { + yield data; +} + +/** + * Extract every regular file of a package tarball under `dest` (its + * `package/` prefix stripped). Understands the same ustar/pax quirks as + * extractFile; non-file entries other than directories (symlinks, devices) + * do not occur in npm tarballs and are skipped. The executable bit of a + * file's tar mode survives as 0o755. + */ +export async function extractTree(tar: AsyncIterable, dest: string): Promise { + let pending: Buffer = Buffer.alloc(0); + let inBody = false; + let bodyLeft = 0; + let padLeft = 0; + let sink: 'skip' | 'file' | 'dir' | 'pax' | 'longname' = 'skip'; + let meta: Buffer[] = []; + let nextName: string | undefined; + let out: fs.WriteStream | null = null; + let outPath = ''; + let mode = 0; + + /** npm packs under `package/`; anything else is not an npm tarball, and a + * `..` or empty segment would write outside `dest` — refuse both. */ + const target = (name: string): string | null => { + const rel = name.startsWith('package/') ? name.slice('package/'.length) : name; + if (rel.length === 0) return null; + const parts = rel.split('/'); + if (parts.some((p) => p === '' || p === '.' || p === '..')) return null; + return path.join(dest, ...parts); + }; + + const endBody = async (): Promise => { + inBody = false; + if (sink === 'file' && out) { + out.end(); + await once(out, 'finish'); + out = null; + // A set executable bit is the only mode that matters: bin/ scripts. + if (mode & 0o111) { + try { + fs.chmodSync(outPath, 0o755); + } catch { + /* best effort — the bit is not worth failing an install */ + } + } + } else if (sink === 'dir') { + fs.mkdirSync(outPath, { recursive: true }); + } + if (sink === 'longname') nextName = cString(Buffer.concat(meta), 0, Infinity); + if (sink === 'pax') nextName = paxPath(Buffer.concat(meta).toString('utf8')) ?? nextName; + }; + + try { + for await (const chunk of tar) { + pending = pending.length > 0 ? Buffer.concat([pending, chunk]) : chunk; + let offset = 0; + for (;;) { + if (!inBody) { + const skip = Math.min(padLeft, pending.length - offset); + offset += skip; + padLeft -= skip; + if (padLeft > 0 || pending.length - offset < 512) break; + const header = pending.subarray(offset, offset + 512); + offset += 512; + if (header.every((b) => b === 0)) continue; // end-of-archive blocks + const type = String.fromCharCode(header[156] || 0x30); + const name = nextName ?? entryName(header); + nextName = undefined; + mode = parseInt(cString(header, 100, 8).trim() || '0', 8); + bodyLeft = parseInt(cString(header, 124, 12).trim() || '0', 8); + padLeft = (512 - (bodyLeft % 512)) % 512; + meta = []; + const file = type === '0' || type === ' ' || type === '\0'; + sink = type === 'x' ? 'pax' : type === 'L' ? 'longname' : file ? 'file' : type === '5' ? 'dir' : 'skip'; + const where = sink === 'file' || sink === 'dir' ? target(name) : null; + if (sink === 'file' && where === null) sink = 'skip'; // not an npm path: skip, keep parsing + if (sink === 'dir' && where === null) sink = 'skip'; + if (sink === 'file') { + outPath = where!; + fs.mkdirSync(path.dirname(outPath), { recursive: true }); + out = fs.createWriteStream(outPath); + } else if (sink === 'dir') { + outPath = where!; + } + inBody = true; + if (bodyLeft === 0) await endBody(); + continue; + } + const n = Math.min(bodyLeft, pending.length - offset); + if (n === 0) break; + const piece = pending.subarray(offset, offset + n); + offset += n; + bodyLeft -= n; + if (sink === 'file' && out && !out.write(piece)) await once(out, 'drain'); + else if (sink === 'pax' || sink === 'longname') meta.push(Buffer.from(piece)); + if (bodyLeft === 0) await endBody(); + } + pending = pending.subarray(offset); + } + } finally { + if (out && !out.writableFinished) out.destroy(); + } +} + +/** How many packages the installer downloads at once. */ +const TREE_CONCURRENCY = 4; + +/** How many times a directory rename is retried before it is a real failure, + * and how long the longest wait between two attempts is. Long enough to sit + * out a foreign filesystem's moment of busyness (~2.5s in all), short enough + * that a filesystem that is simply not going to allow it fails the install + * rather than hanging it. */ +const RENAME_ATTEMPTS = 8; +const RENAME_BACKOFF_MS = 500; + +/** + * Move a staged package into place. + * + * Retried, because one filesystem this runs on refuses a directory rename + * that the same filesystem accepts a moment later: a bind mount from a + * Windows host (the container's `/data`, Docker Desktop) answers EACCES while + * a file just written inside the directory is still being let go of by the + * host side. It is not a race this code could remove — the rename is + * serialised, the destination is recreated first, and the same tree of a few + * hundred packages installs on the first attempt on an ordinary filesystem — + * and it is not a failure the caller can fix, since which package it lands on + * differs from run to run. Waiting is the whole of the answer; a rename that + * keeps failing, or fails for any other reason, is still a failure. + */ +export async function renameIntoPlace( + from: string, + to: string, + options: { log?: (message: string) => void; rename?: (from: string, to: string) => void } = {}, +): Promise { + const rename = options.rename ?? fs.renameSync; + for (let attempt = 1; ; attempt++) { + try { + rename(from, to); + return; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code ?? ''; + if (attempt >= RENAME_ATTEMPTS || !['EACCES', 'EPERM', 'EBUSY'].includes(code)) throw error; + if (attempt === 2) options.log?.(`[install] ${to} was busy (${code}); retrying`); + await delay(Math.min(attempt * 100, RENAME_BACKOFF_MS)); + } + } +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Install one runtime's pinned dependency closure — `entries` come from a + * generated pins module (src/generated/dshPackages.ts today; any pure-JS + * agent CLI installed this way gets its own) — into + * `/@/node_modules/`, laid out as each + * entry's `dest` says (flat at the root, nested under a consumer when the + * closure needs two versions of one name), and return the root package's + * directory. Destinations already present at their pinned version are + * skipped, so an interrupted install resumes; an optional package that + * cannot be fetched is skipped (npm's own semantics), everything else fails + * the install. + */ +export async function installPackageTree( + root: string, + entries: readonly TreePackageEntry[], + options: InstallOptions & { label?: string }, +): Promise { + const rootEntry = entries.find((entry) => entry.name === root); + if (!rootEntry) throw new Error(`${options.label ?? root}: ${root} is not pinned in this build, so it cannot be downloaded`); + + const label = options.label ?? root; + const dir = path.join(options.cacheDir, `${root.replace('/', '+')}@${rootEntry.version}`); + const nodeModules = path.join(dir, 'node_modules'); + const target = (dest: string): string => path.join(dir, ...dest.split('/')); + + // A package whose platform gates exclude this machine: optional ones are + // simply not for us (the wasm or other-OS variant), while a required one + // would leave the tree unbootable — that is a pins problem to surface, not + // a download to attempt. + const wanted: TreePackageEntry[] = []; + for (const entry of entries) { + if (!packageMatchesPlatform(entry)) { + if (!entry.optional) throw new Error(`${label}: ${entry.name}@${entry.version} is required but gated to other platforms`); + continue; + } + wanted.push(entry); + } + + const atVersion = (entry: TreePackageEntry): boolean => installedVersion(target(entry.dest)) === entry.version; + if (wanted.every(atVersion)) return target(rootEntry.dest); + + // The same tarball serves every spot its package occupies (a nested + // variant may sit under several consumers): fetch once, extract per dest. + const byTarball = new Map(); + for (const entry of wanted) { + const key = `${entry.name}@${entry.version}`; + byTarball.set(key, [...(byTarball.get(key) ?? []), entry]); + } + + const staging = path.join(dir, `.staging-${process.pid}`); + const registry = options.registry ?? DEFAULT_REGISTRY; + const fetchFn = options.fetchFn ?? fetch; + const started = Date.now(); + options.log(`[install] downloading ${label} (${byTarball.size} packages) from ${registry}`); + try { + fs.mkdirSync(staging, { recursive: true }); + + // Phase 1: fetch every distinct tarball once, in parallel — this is the + // network-bound part. An optional package that cannot be fetched is + // dropped here (npm's own semantics); anything else fails the install. + // The tarballs are kept as they came, gzipped: a whole runtime inflated + // at once is the better part of a gigabyte held in memory, on a machine + // that may be a small VPS. + const tarballs = new Map(); + const jobs = [...byTarball.keys()]; + let next = 0; + const download = async (): Promise => { + for (;;) { + const index = next++; + if (index >= jobs.length) return; + const group = byTarball.get(jobs[index]!)!; + const first = group[0]!; + if (group.every(atVersion)) { + tarballs.set(jobs[index]!, null); // already on disk + continue; + } + try { + const url = `${registry}/${first.name}/-/${first.name.split('/').pop()}-${first.version}.tgz`; + tarballs.set(jobs[index]!, await fetchVerifiedTarball(url, first.integrity, fetchFn)); + } catch (err) { + const reason = err instanceof Error ? err.message : String(err); + if (first.optional) { + options.log(`[install] skipping optional ${first.name}@${first.version}: ${reason}`); + tarballs.set(jobs[index]!, null); + continue; + } + throw new Error(`${first.name} could not be installed: ${reason}`); + } + } + }; + await Promise.all(Array.from({ length: TREE_CONCURRENCY }, () => download())); + + // Phase 2: lay the tarballs down serially, shallow dest first. A nested + // entry installs inside its parent's directory, so extracting in + // parallel would race a parent's replace against its children. + const depth = (dest: string): number => dest.split('/').length; + const ordered = wanted + .filter((entry) => tarballs.get(`${entry.name}@${entry.version}`)) + .sort((a, b) => depth(a.dest) - depth(b.dest)); + for (const entry of ordered) { + if (atVersion(entry)) continue; + const tar = gunzipSync(tarballs.get(`${entry.name}@${entry.version}`)!); + const pkgDir = target(entry.dest); + const stage = path.join(staging, entry.dest); + fs.rmSync(stage, { recursive: true, force: true }); + await extractTree(oneBuffer(tar), stage); + fs.rmSync(pkgDir, { recursive: true, force: true }); + fs.mkdirSync(path.dirname(pkgDir), { recursive: true }); + await renameIntoPlace(stage, pkgDir, { log: options.log }); + } + + pruneOtherVersions(options.cacheDir, `${root.replace('/', '+')}@`, path.basename(dir)); + options.log(`[install] ${label} installed at ${dir} (${((Date.now() - started) / 1000) | 0}s)`); + return target(rootEntry.dest); + } finally { + removeQuietly(staging); + } +} diff --git a/packages/agent-host/src/driver.ts b/packages/agent-host/src/driver.ts index cef91155..b50012be 100644 --- a/packages/agent-host/src/driver.ts +++ b/packages/agent-host/src/driver.ts @@ -20,6 +20,7 @@ import type { ModelEntry, OptionChoice, PermissionRequest, + PlanOutcome, PluginAction, PluginMarketplace, QuestionOutcome, @@ -43,8 +44,10 @@ export interface SessionContext { requestPermission(request: Omit): Promise; /** Ask the user one or more questions. */ askQuestion(requestId: string, questions: QuestionSpec[]): Promise; - /** Ask the user how to proceed with a finished plan. */ - requestPlanApproval(requestId: string, options: OptionChoice[]): Promise; + /** Ask the user how to proceed with a finished plan. `revise` is the + * option that sends the plan back, when the agent has one: the user may + * choose it with their feedback, which arrives in the outcome. */ + requestPlanApproval(requestId: string, options: OptionChoice[], revise?: string): Promise; /** A diagnostic line for the bridge log (stderr). Never pass secrets. */ log(message: string): void; } diff --git a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts index 5ec011a3..6488c6ef 100644 --- a/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts +++ b/packages/agent-host/src/drivers/claude/__tests__/claudeDriver.test.ts @@ -410,6 +410,8 @@ describe('Claude permission policy', () => { const approve = start({ mode: 'plan' }, { plan: () => ({ outcome: 'selected', optionId: 'acceptEdits' }) }); expect(await ask(approve.canUseTool, 'ExitPlanMode', { plan: '1. x' })).toMatchObject({ behavior: 'allow' }); expect(approve.ctx.plans[0]?.options).toEqual(PLAN_APPROVAL_OPTIONS); + // `revise` is the choice the user's feedback goes with. + expect(approve.ctx.plans[0]?.revise).toBe('revise'); await approve.ctx.waitFor((e) => e.type === 'info' && e.mode === 'acceptEdits'); expect(approve.handle.modes).toEqual(['acceptEdits']); @@ -417,6 +419,16 @@ describe('Claude permission policy', () => { expect(await ask(revise.canUseTool, 'ExitPlanMode', { plan: '1. x' })).toMatchObject({ behavior: 'deny', message: expect.stringMatching(/keep planning/) }); expect(revise.handle.modes).toEqual([]); }); + + it('plan approval: feedback sent with revise is what the agent is told to revise with', async () => { + const revise = start( + { mode: 'plan' }, + { plan: () => ({ outcome: 'selected', optionId: 'revise', feedback: 'Split the migration out.' }) }, + ); + const answer = await ask(revise.canUseTool, 'ExitPlanMode', { plan: '1. x' }); + expect(answer).toMatchObject({ behavior: 'deny', message: expect.stringMatching(/keep planning[\s\S]*Split the migration out\.$/) }); + expect(revise.handle.modes).toEqual([]); + }); }); describe('Claude options and setup', () => { diff --git a/packages/agent-host/src/drivers/claude/driver.ts b/packages/agent-host/src/drivers/claude/driver.ts index 4c493bdf..8e340d25 100644 --- a/packages/agent-host/src/drivers/claude/driver.ts +++ b/packages/agent-host/src/drivers/claude/driver.ts @@ -95,6 +95,7 @@ const PLAIN_CONTEXT_WINDOW = 200_000; * denied call explains itself to the model instead. */ const USER_DENIED = 'User denied'; const KEEP_PLANNING = 'The user wants to keep planning — revise the plan with their feedback.'; +const KEEP_PLANNING_WITH_FEEDBACK = 'The user wants to keep planning. Revise the plan with their feedback:'; /** A model id compared the way the CLI would: case aside, and without the * `[1m]` context marker it strips before sending. */ @@ -572,12 +573,16 @@ export class ClaudeSession implements DriverSession { } /** Every option but `revise` approves the plan and continues in the mode - * it names; `revise` keeps the agent planning (the user's feedback - * arrives as the next prompt). */ + * it names; `revise` keeps the agent planning, with the user's feedback + * as the refusal's message when they sent some (else it arrives as their + * next prompt). */ private async approvePlan(requestId: string): Promise { - const outcome = await this.ctx.requestPlanApproval(requestId, PLAN_APPROVAL_OPTIONS); + const outcome = await this.ctx.requestPlanApproval(requestId, PLAN_APPROVAL_OPTIONS, PLAN_REVISE); if (outcome.outcome === 'cancelled') return { behavior: 'deny', message: outcome.reason }; - if (outcome.optionId === PLAN_REVISE || !isMode(outcome.optionId)) return { behavior: 'deny', message: KEEP_PLANNING }; + if (outcome.optionId === PLAN_REVISE || !isMode(outcome.optionId)) { + const feedback = outcome.feedback?.trim(); + return { behavior: 'deny', message: feedback ? `${KEEP_PLANNING_WITH_FEEDBACK}\n\n${feedback}` : KEEP_PLANNING }; + } const mode = outcome.optionId; // After the approval resolves: the SDK leaves plan mode on its own // answer, then the chosen mode is applied on top. diff --git a/packages/agent-host/src/drivers/claude/env.ts b/packages/agent-host/src/drivers/claude/env.ts index 2c512bd0..910b5049 100644 --- a/packages/agent-host/src/drivers/claude/env.ts +++ b/packages/agent-host/src/drivers/claude/env.ts @@ -3,28 +3,12 @@ * credentials and the session's provider binding, turned into the variables * the CLI reads. The returned objects carry SECRETS — never log them. */ +import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../provider'; import type { ProviderBinding, StartSession } from '../../types'; export const ANTHROPIC_API_KEY_CREDENTIAL = 'anthropic_api_key'; -/** The message shown when a base URL is refused (the bridge shows the same). */ -export const PROVIDER_BASE_URL_ERROR = - 'Base URL must be https:// (http:// is allowed only for localhost, 127.0.0.1 or [::1])'; - -/** https anywhere, or http ONLY on loopback — a local model server has no - * cert and its traffic never leaves the machine; anything else is a network - * hop carrying a bearer token. */ -export function isValidProviderBaseUrl(raw: string): boolean { - let url: URL; - try { - url = new URL(raw); - } catch { - return false; - } - if (url.protocol === 'https:') return url.host !== ''; - if (url.protocol !== 'http:') return false; - return ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname.toLowerCase()); -} +export { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR }; /** * The env-name namespaces the Claude Code CLI (and the cloud SDKs it embeds) diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts new file mode 100644 index 00000000..e255db89 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseek-adapter.test.ts @@ -0,0 +1,324 @@ +/** + * deepseekUpdateToEntries and toolCallDiffs — the DeepSeek Harness + * translator, in the style of sdk-adapter.test.ts and opencode-adapter.test.ts. + * + * The updates are the ones the harness's own ACP module sends: assistant + * messages and thoughts, generic tool call lifecycles whose kind is always + * `other` and whose title is the tool's name, and usage. Nothing else — no + * plans, no modes, no commands — which the last case covers. + */ +import { describe, expect, it } from 'vitest'; +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import type { DiffLine, OutputEntry } from '../../../types'; +import { deepseekUpdateToEntries, toolCallDiffs, type ToolCallMemory } from '../adapter'; + +type EntryOf = Extract; + +function entries(update: unknown, calls: ToolCallMemory = new Map()): OutputEntry[] { + return deepseekUpdateToEntries(update as SessionUpdate, calls); +} + +/** The call one update reports, ready for its result. */ +function started(name: string, input: Record = {}, calls: ToolCallMemory = new Map()): ToolCallMemory { + entries({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: name, kind: 'other', status: 'in_progress', rawInput: input }, calls); + return calls; +} + +const text = (update: unknown): string => (entries(update)[0] as EntryOf<'text'>).text; + +describe('messages', () => { + it('renders an agent message chunk as agent text', () => { + expect(entries({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Done.' } })).toEqual([ + { entryType: 'text', role: 'agent', text: 'Done.', timestamp: expect.any(String) }, + ]); + }); + + it('drops an empty chunk', () => { + expect(entries({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: '' } })).toEqual([]); + }); + + it('renders a thought as thinking', () => { + expect(entries({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'hmm' } })).toEqual([ + { entryType: 'thinking', text: 'hmm', timestamp: expect.any(String) }, + ]); + }); + + it('ignores the updates the harness never sends over ACP', () => { + // The automation profile carries no plans, commands, modes or terminal + // callbacks; a client must not invent entries for them. + expect(entries({ sessionUpdate: 'plan', entries: [{ content: 'x', status: 'pending', priority: 'low' }] })).toEqual([]); + expect(entries({ sessionUpdate: 'available_commands_update', availableCommands: [] })).toEqual([]); + expect(entries({ sessionUpdate: 'current_mode_update', currentModeId: 'plan' })).toEqual([]); + }); +}); + +describe('tool calls', () => { + it('summarizes a command, with its whole text as the call input', () => { + const call = entries({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'bash', + kind: 'other', + status: 'in_progress', + rawInput: { command: 'npm ci\nnpm test', description: 'Install and test' }, + })[0] as EntryOf<'tool_call'>; + expect(call).toMatchObject({ entryType: 'tool_call', callId: 'c1', toolName: 'bash', kind: 'execute', title: 'npm ci…' }); + expect(call.input).toBe('npm ci\nnpm test'); + }); + + it('uses the ACP name when the harness sends one', () => { + const call = entries({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'Running the tests', + name: 'bash', + kind: 'other', + status: 'in_progress', + rawInput: { command: 'npm test' }, + })[0] as EntryOf<'tool_call'>; + expect(call.toolName).toBe('bash'); + expect(call.kind).toBe('execute'); + }); + + it('names the file a read touches and leaves a single-path input out', () => { + const call = entries({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'read', + kind: 'other', + status: 'in_progress', + rawInput: { file_path: 'src/main.ts' }, + })[0] as EntryOf<'tool_call'>; + expect(call).toMatchObject({ kind: 'read', title: 'src/main.ts', locations: ['src/main.ts'] }); + expect(call.input).toBeUndefined(); + }); + + it('reads str_replace_editor\'s own path as its location', () => { + const call = entries({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'str_replace_editor', + kind: 'other', + status: 'in_progress', + rawInput: { command: 'str_replace', path: '/repo/a.py', old_str: 'x', new_str: 'y' }, + })[0] as EntryOf<'tool_call'>; + expect(call).toMatchObject({ kind: 'edit', title: 'str_replace /repo/a.py', locations: ['/repo/a.py'] }); + }); + + it('shows a checklist a todo call wrote, and no input for it', () => { + const call = entries({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'todo_write', + kind: 'other', + status: 'in_progress', + rawInput: { todos: [{ content: 'Ship it', status: 'in_progress' }] }, + }); + expect(call[0]).toMatchObject({ entryType: 'tool_call', kind: 'think' }); + expect((call[0] as EntryOf<'tool_call'>).input).toBeUndefined(); + expect(call[1]).toEqual({ + entryType: 'todos', + callId: 'c1', + items: [{ text: 'Ship it', status: 'in_progress' }], + timestamp: expect.any(String), + }); + }); + + it('hides the rows of the calls whose card is the exchange', () => { + // The question tool and the plan review both ask through the harness's + // user-questions service: the bridge shows the ask and sends back the + // answer, so their own rows would be a second, emptier copy of it — and + // for the plan review that copy is the plan again, as raw arguments. + const calls = started('ask_user_question', { questions: [{ id: 'q1', question: 'Q?' }] }); + const review = entries( + { + sessionUpdate: 'tool_call', + toolCallId: 'c2', + title: 'exit_plan_mode', + kind: 'other', + status: 'in_progress', + rawInput: { plan: '# Ship it\n\nDo the thing.' }, + }, + calls, + ); + expect(review).toEqual([]); + // The result is as invisible as the call, and neither is left behind. + expect( + entries( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'c2', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'Plan approved' } }], + }, + calls, + ), + ).toEqual([]); + expect(calls.size).toBe(1); + }); + + it('keeps the tool name of an MCP tool', () => { + const call = entries({ + sessionUpdate: 'tool_call', + toolCallId: 'c1', + title: 'mcp__github__create_issue', + kind: 'other', + status: 'in_progress', + rawInput: { title: 'a bug' }, + })[0] as EntryOf<'tool_call'>; + expect(call.toolName).toBe('mcp__github__create_issue'); + expect(call.kind).toBe('other'); + }); +}); + +describe('tool results', () => { + it('renders a completed call and forgets it', () => { + const calls = started('bash', { command: 'ls' }); + const result = entries( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'a.ts\nb.ts' } }], + }, + calls, + ); + expect(result).toEqual([ + { entryType: 'tool_result', callId: 'c1', text: 'a.ts\nb.ts', timestamp: expect.any(String) }, + ]); + expect(calls.size).toBe(0); + }); + + it('marks a failed call', () => { + const calls = started('bash', { command: 'ls' }); + const result = entries( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'failed', + content: [{ type: 'content', content: { type: 'text', text: 'not found' } }], + }, + calls, + )[0] as EntryOf<'tool_result'>; + expect(result).toMatchObject({ entryType: 'tool_result', text: 'not found', isError: true }); + }); + + it('shows an image result as a placeholder', () => { + const calls = started('read_image', { file_path: 'a.png' }); + const result = entries( + { + sessionUpdate: 'tool_call_update', + toolCallId: 'c1', + status: 'completed', + content: [{ type: 'content', content: { type: 'image', data: 'AAAA', mimeType: 'image/png' } }], + }, + calls, + ); + expect((result[0] as EntryOf<'tool_result'>).text).toBe('[image]'); + }); + + it('shows nothing for a progress update', () => { + const calls = started('bash', { command: 'ls' }); + expect( + entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'in_progress', content: [] }, calls), + ).toEqual([]); + expect(calls.size).toBe(1); + }); + + it('renders a result whose call was never seen', () => { + const result = entries({ + sessionUpdate: 'tool_call_update', + toolCallId: 'gone', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'orphan' } }], + }); + expect(result).toEqual([{ entryType: 'tool_result', callId: 'gone', text: 'orphan', timestamp: expect.any(String) }]); + }); +}); + +describe('file changes', () => { + const diffOf = (parsed: OutputEntry[]): { path: string; lines: DiffLine[]; truncated?: boolean } => { + const diff = parsed.find((entry): entry is EntryOf<'diff'> => entry.entryType === 'diff')!; + return { path: diff.path, lines: diff.lines, ...(diff.truncated ? { truncated: true } : {}) }; + }; + + it('shows an edit as the replaced text', () => { + const calls = started('edit', { file_path: 'a.ts', old_string: 'one', new_string: 'two' }); + const result = entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, calls); + expect(diffOf(result)).toEqual({ + path: 'a.ts', + lines: [ + { type: 'del', text: 'one' }, + { type: 'add', text: 'two' }, + ], + }); + }); + + it('shows a write as all additions', () => { + const calls = started('write', { file_path: 'new.ts', content: 'a\nb' }); + const result = entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, calls); + expect(diffOf(result).lines).toEqual([ + { type: 'add', text: 'a' }, + { type: 'add', text: 'b' }, + ]); + }); + + it('follows what str_replace_editor was asked to do', () => { + const create = started('str_replace_editor', { command: 'create', path: '/repo/a.py', file_text: 'print(1)' }); + expect( + diffOf(entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, create)).lines, + ).toEqual([{ type: 'add', text: 'print(1)' }]); + + const replace = started('str_replace_editor', { command: 'str_replace', path: '/repo/a.py', old_str: 'x', new_str: 'y' }); + expect( + diffOf(entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, replace)).lines, + ).toEqual([ + { type: 'del', text: 'x' }, + { type: 'add', text: 'y' }, + ]); + + const view = started('str_replace_editor', { command: 'view', path: '/repo/a.py' }); + expect(entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, view)).toHaveLength(1); + }); + + it('shows nothing for a failed change', () => { + const calls = started('edit', { file_path: 'a.ts', old_string: 'one', new_string: 'two' }); + const result = entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'failed', content: [] }, calls); + expect(result.map((entry) => entry.entryType)).toEqual(['tool_result']); + }); + + it('bounds a change to the wire cap', () => { + const long = Array.from({ length: 250 }, (_, i) => `line ${i}`).join('\n'); + const calls = started('write', { file_path: 'big.ts', content: long }); + const diff = diffOf(entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, calls)); + expect(diff.truncated).toBe(true); + expect(diff.lines).toHaveLength(200); + }); + + it('truncates a very long line', () => { + const calls = started('write', { file_path: 'wide.ts', content: 'x'.repeat(600) }); + const diff = diffOf(entries({ sessionUpdate: 'tool_call_update', toolCallId: 'c1', status: 'completed', content: [] }, calls)); + expect(diff.lines[0]!.text).toHaveLength(501); + expect(diff.lines[0]!.text.endsWith('…')).toBe(true); + }); +}); + +describe('toolCallDiffs', () => { + it('reads nothing out of a tool that changes no file', () => { + expect(toolCallDiffs('bash', { command: 'ls' })).toEqual([]); + expect(toolCallDiffs('edit', {})).toEqual([]); + expect(toolCallDiffs('write', { file_path: 'a.ts' })).toEqual([]); + }); + + it('accepts the path a Claude-shaped editor uses', () => { + expect(toolCallDiffs('write', { path: '/a/b.ts', file_text: 'x' })).toEqual([ + { path: '/a/b.ts', lines: [{ type: 'add', text: 'x' }] }, + ]); + }); +}); + +describe('text helper', () => { + it('reads the text of a chunk', () => { + expect(text({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello' } })).toBe('hello'); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekAcp.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekAcp.test.ts new file mode 100644 index 00000000..45b846a6 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekAcp.test.ts @@ -0,0 +1,208 @@ +/** + * The ACP wire client: what the driver sends and receives over the harness's + * stdio. The harness itself is the fake in fakeHarness.ts; here the peer is + * whatever a test writes, which is how the wire's own cases (a malformed + * line, a reply to nothing, a message split across chunks, the child going + * away) are covered. + */ +import { PassThrough } from 'node:stream'; +import { describe, expect, it, vi } from 'vitest'; +import { AcpClient, AcpRequestError } from '../acp'; + +interface Wire { + client: AcpClient; + /** Everything the client wrote, in order. */ + sent: Array>; + /** The last request the client wrote. */ + last(): Record; + /** One message from the agent. */ + reply(message: unknown): void; + /** One raw line from the agent. */ + raw(line: string): void; + /** End the agent's output, as a dead process does. */ + end(): void; + logs: string[]; +} + +function wire(): Wire { + const out = new PassThrough(); + const incoming = new PassThrough(); + const logs: string[] = []; + const sent: Array> = []; + out.on('data', (chunk: Buffer) => { + for (const line of chunk.toString().split('\n')) if (line.trim() !== '') sent.push(JSON.parse(line)); + }); + const client = new AcpClient({ stdin: out, stdout: incoming, log: (line) => logs.push(line) }); + incoming.resume(); + return { + client, + sent, + logs, + last: () => sent[sent.length - 1]!, + reply: (message) => incoming.write(`${JSON.stringify(message)}\n`), + raw: (line) => incoming.write(line), + end: () => incoming.end(), + }; +} + +/** A client whose initialize has already been answered. */ +async function connected(): Promise { + const w = wire(); + const initialized = w.client.request('initialize', { protocolVersion: 1 }); + w.reply({ jsonrpc: '2.0', id: w.last().id, result: { protocolVersion: 1, agentCapabilities: {} } }); + await initialized; + return w; +} + +describe('requests', () => { + it('writes one JSON-RPC request per line and waits for its reply', async () => { + const w = await connected(); + const pending = w.client.request('session/new', { cwd: '/work', mcpServers: [] }); + expect(w.last()).toEqual({ jsonrpc: '2.0', id: expect.any(Number), method: 'session/new', params: { cwd: '/work', mcpServers: [] } }); + w.reply({ jsonrpc: '2.0', id: w.last().id, result: { sessionId: 's1', configOptions: [] } }); + expect(await pending).toEqual({ sessionId: 's1', configOptions: [] }); + }); + + it('matches replies to their own request, whichever order they arrive in', async () => { + const w = await connected(); + const first = w.client.request('session/list', {}); + const firstId = w.last().id; + const second = w.client.request('session/list', {}); + const secondId = w.last().id; + w.reply({ jsonrpc: '2.0', id: secondId, result: { sessions: [{ sessionId: 's2', cwd: '/b' }] } }); + w.reply({ jsonrpc: '2.0', id: firstId, result: { sessions: [] } }); + expect(await first).toEqual({ sessions: [] }); + expect(await second).toEqual({ sessions: [{ sessionId: 's2', cwd: '/b' }] }); + }); + + it('refuses with the agent’s own error', async () => { + const w = await connected(); + const pending = w.client.request('session/resume', { sessionId: 'gone', cwd: '/work' }); + w.reply({ jsonrpc: '2.0', id: w.last().id, error: { code: -32602, message: 'Invalid params: session is not resumable: gone' } }); + await expect(pending).rejects.toBeInstanceOf(AcpRequestError); + await expect(pending.catch((error: AcpRequestError) => [error.code, error.message])).resolves.toEqual([ + -32602, + 'Invalid params: session is not resumable: gone', + ]); + }); + + it('gives up on a request that is never answered', async () => { + const w = await connected(); + await expect(w.client.request('session/new', { cwd: '/work', mcpServers: [] }, { timeoutMs: 5 })).rejects.toThrow(/did not answer within 5ms/); + }); + + it('sends a notification without waiting for anything', async () => { + const w = await connected(); + w.client.notify('session/cancel', { sessionId: 's1' }); + expect(w.last()).toEqual({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: 's1' } }); + }); +}); + +describe('messages from the agent', () => { + it('hands a notification to its handler', async () => { + const w = await connected(); + const updates = vi.fn(); + w.client.on('session/update', updates); + w.reply({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 's1', update: { sessionUpdate: 'agent_message_chunk' } } }); + expect(updates).toHaveBeenCalledWith({ sessionId: 's1', update: { sessionUpdate: 'agent_message_chunk' } }); + }); + + it('answers a request the agent makes', async () => { + const w = await connected(); + w.client.onRequest('session/request_permission', async () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) as const); + w.reply({ jsonrpc: '2.0', id: 77, method: 'session/request_permission', params: { sessionId: 's1', toolCall: { toolCallId: 'c1' }, options: [] } }); + await vi.waitFor(() => expect(w.sent.some((message) => message.id === 77 && message.result !== undefined)).toBe(true)); + expect(w.sent.find((message) => message.id === 77)?.result).toEqual({ outcome: { outcome: 'selected', optionId: 'allow-once' } }); + }); + + it('refuses a request it has no handler for, rather than leaving the agent waiting', async () => { + const w = await connected(); + w.reply({ jsonrpc: '2.0', id: 78, method: 'fs/read_text_file', params: { sessionId: 's1', path: '/x' } }); + await vi.waitFor(() => expect(w.sent.some((message) => message.id === 78)).toBe(true)); + expect(w.sent.find((message) => message.id === 78)?.error).toEqual({ code: -32601, message: 'Method not found: fs/read_text_file' }); + }); + + it('turns a handler failure into an error reply', async () => { + const w = await connected(); + w.client.onRequest('session/request_permission', async () => { + throw new Error('the phone is gone'); + }); + w.reply({ jsonrpc: '2.0', id: 79, method: 'session/request_permission', params: { sessionId: 's1', toolCall: {}, options: [] } }); + await vi.waitFor(() => expect(w.sent.some((message) => message.id === 79)).toBe(true)); + expect(w.sent.find((message) => message.id === 79)?.error).toEqual({ code: -32603, message: 'the phone is gone' }); + }); + + it('survives a handler that throws synchronously', async () => { + const w = await connected(); + w.client.on('session/update', () => { + throw new Error('bad update'); + }); + w.reply({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 's1', update: {} } }); + expect(w.logs.some((line) => /session\/update handler failed: bad update/.test(line))).toBe(true); + expect(w.client.isOpen).toBe(true); + }); +}); + +describe('the wire itself', () => { + it('drops a line that is not JSON and keeps going', async () => { + const w = await connected(); + w.raw('not json at all\n'); + expect(w.logs.some((line) => /dropped a malformed line/.test(line))).toBe(true); + const pending = w.client.request('session/list', {}); + w.reply({ jsonrpc: '2.0', id: w.last().id, result: { sessions: [] } }); + expect(await pending).toEqual({ sessions: [] }); + }); + + it('drops a message that is not an object, and a reply to nothing', async () => { + const w = await connected(); + w.raw('"just a string"\n'); + w.reply({ jsonrpc: '2.0', id: 4242, result: {} }); + expect(w.logs.some((line) => /dropped a non-object message/.test(line))).toBe(true); + expect(w.logs.some((line) => /dropped a reply to unknown request 4242/.test(line))).toBe(true); + }); + + it('reads a message split across chunks, and tolerates CRLF', async () => { + const w = new PassThrough(); + const incoming = new PassThrough(); + const client = new AcpClient({ stdin: w, stdout: incoming, log: () => {} }); + const updates = vi.fn(); + client.on('session/update', updates); + const line = `${JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: { sessionId: 's1' } })}\r\n`; + incoming.write(line.slice(0, 20)); + incoming.write(line.slice(20)); + expect(updates).toHaveBeenCalledWith({ sessionId: 's1' }); + }); + + it('refuses anything once the agent is gone', async () => { + const w = await connected(); + const pending = w.client.request('session/list', {}); + w.end(); + await expect(pending).rejects.toThrow(/closed its output/); + expect(await w.client.closed).toEqual({ error: 'the agent closed its output' }); + expect(w.client.isOpen).toBe(false); + expect(() => w.client.notify('session/cancel', { sessionId: 's1' })).toThrow(/closed its output/); + }); + + it('reports an error the transport itself raised', async () => { + const w = await connected(); + w.client.on('session/update', () => {}); + const incoming = new PassThrough(); + const client = new AcpClient({ stdin: new PassThrough(), stdout: incoming, log: () => {} }); + const closed = client.closed; + incoming.emit('error', new Error('EPIPE')); + expect(await closed).toEqual({ error: 'EPIPE' }); + }); + + it('finishes once, however many ways it is ended', async () => { + const w = await connected(); + let notifications = 0; + void w.client.closed.then(() => { + notifications++; + }); + w.client.finish({ error: 'first' }); + w.client.finish({ error: 'second' }); + w.end(); + expect(await w.client.closed).toEqual({ error: 'first' }); + expect(notifications).toBe(1); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCommands.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCommands.test.ts new file mode 100644 index 00000000..56acac98 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekCommands.test.ts @@ -0,0 +1,517 @@ +/** + * The command bridge: the plugin CodeDeck writes into the harness profile, + * run here as the harness would run it (a fake plugin context and a real + * socket), and the client the driver asks it questions with. + */ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { createServer, type Server, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { describe, expect, it, vi } from 'vitest'; +import { askPlugin, bridgeSocketPath, listSessionCommands, runSessionCommand, steerSession } from '../bridge'; +import { BRIDGE_SOCKET_ENV, HARNESS_PLUGIN, QUESTION_MARKER, installHarnessPlugin } from '../plugin'; +import { parseQuestionLine, planReviewOf, toAnswerItems, toQuestionSpecs } from '../questions'; + +/** A socket of the test's own: a file in its directory, or on Windows — where + * a socket is a named pipe — a pipe named after that directory. */ +const socketPathIn = (dir: string): string => + process.platform === 'win32' ? bridgeSocketPath(dir, 'test') : path.join(dir, 'commands.sock'); + +/** A stand-in for the harness's plugin context: the two services the plugin + * asks for, the effect disposer, and a logger. */ +function pluginContext(commands: unknown, agents: { get: (id: string) => unknown } = { get: () => ({ id: 'a1' }) }) { + const cleanups: Array<() => void> = []; + /** What the plugin registered with `ctx.on`, by event name. */ + const listeners = new Map never) => unknown>(); + return { + cleanups, + listeners, + ctx: { + get: (service: string) => (service === 'commands' ? commands : service === 'agents' ? agents : undefined), + on: (event: string, listener: (payload: never, next: () => never) => unknown) => { + listeners.set(event, listener); + }, + logger: { warn: () => {}, debug: () => {} }, + effect: (callback: () => () => void) => { + cleanups.push(callback()); + }, + }, + }; +} + +/** Run the plugin's source the way the harness does: import it and apply it. */ +async function runPlugin( + profileDir: string, + commandService: unknown, + agents: { get: (id: string) => unknown } = { get: () => ({ id: 'a1' }) }, +): Promise<{ + cleanups: Array<() => void>; + socket: string; + listeners: Map never) => unknown>; +}> { + const socket = socketPathIn(profileDir); + await installHarnessPlugin(profileDir, () => {}); + const module = (await import(pathToFileURL(path.join(profileDir, 'node_modules', HARNESS_PLUGIN, 'index.js')).href)) as { + apply: (ctx: unknown) => void; + name: string; + inject: string[]; + }; + expect(module.name).toBe('codedeck-bridge'); + expect(module.inject).toEqual(['agents', 'commands', 'userQuestions']); + const { ctx, cleanups, listeners } = pluginContext(commandService, agents); + // The runtime names each process's socket in its environment; the plugin + // reads it as it applies. + process.env[BRIDGE_SOCKET_ENV] = socket; + try { + module.apply(ctx); + } finally { + delete process.env[BRIDGE_SOCKET_ENV]; + } + return { cleanups, socket, listeners }; +} + +/** Wait for a socket to accept a connection (the plugin listens asynchronously). */ +async function waitForSocket(socket: string): Promise { + const { connect } = await import('node:net'); + for (let attempt = 0; attempt < 100; attempt++) { + const connected = await new Promise((resolve) => { + const probe = connect(socket); + probe.on('connect', () => { + probe.destroy(); + resolve(true); + }); + probe.on('error', () => resolve(false)); + }); + if (connected) return; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + throw new Error(`nothing is listening on ${socket}`); +} + +describe('installing the command plugin', () => { + it('writes its package into the profile and its row into the layer', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + writeFileSync(path.join(dir, 'cordis.patch.yml'), '# the profile\n[]\n'); + await installHarnessPlugin(dir, () => {}); + const manifest = JSON.parse(readFileSync(path.join(dir, 'node_modules', HARNESS_PLUGIN, 'package.json'), 'utf8')) as { + name: string; + type: string; + main: string; + }; + expect(manifest).toMatchObject({ name: HARNESS_PLUGIN, type: 'module', main: 'index.js' }); + const layer = readFileSync(path.join(dir, 'cordis.patch.yml'), 'utf8'); + expect(layer).toMatch(/CodeDeck\+ bridge/); + expect(layer).toMatch(/- id: codedeck-bridge/); + expect(layer).toMatch(new RegExp(`name: '${HARNESS_PLUGIN}'`)); + // The row names no socket: the profile is shared by every process, and + // each process has its own. + expect(layer).not.toMatch(/socket:/); + // The profile's own content is still there. + expect(layer).toMatch(/# the profile/); + }); + + it('leaves the files alone when they are already right', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const write = vi.fn(); + await installHarnessPlugin(dir, () => {}); + const first = readFileSync(path.join(dir, 'node_modules', HARNESS_PLUGIN, 'index.js'), 'utf8'); + // A second run must not rewrite the source the harness may be running — + // the content check is what makes it safe to run at every start. + await installHarnessPlugin(dir, () => {}); + expect(readFileSync(path.join(dir, 'node_modules', HARNESS_PLUGIN, 'index.js'), 'utf8')).toBe(first); + expect(write).not.toHaveBeenCalled(); + }); +}); + +describe('the plugin in a harness CodeDeck did not start', () => { + it('neither listens nor takes questions it could never answer', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + await installHarnessPlugin(dir, () => {}); + const module = (await import(pathToFileURL(path.join(dir, 'node_modules', HARNESS_PLUGIN, 'index.js')).href)) as { + apply: (ctx: unknown) => void; + }; + const { ctx, cleanups, listeners } = pluginContext({ list: () => [] }); + delete process.env[BRIDGE_SOCKET_ENV]; + module.apply(ctx); + // The harness's own "no answerer" stands, rather than a question that + // waits on a host that is not there. + expect(listeners.size).toBe(0); + expect(cleanups).toEqual([]); + }); +}); + +describe('the plugin, talking to the driver', () => { + const commands = { + list: () => [ + { name: 'compact', description: 'Compact the conversation', input: { hint: '[]' } }, + { name: 'goal', description: 'Set or view the goal', input: { hint: '[]', attachments: true } }, + { name: 'plain', description: 'No input at all' }, + ], + execute: (_agent: unknown, line: string) => + line.startsWith('/compact') + ? Promise.resolve({ commandId: 'c1', result: { kind: 'success', text: 'Compacted 12 messages.' } }) + : Promise.resolve(undefined), + }; + + it('steers a running agent with a message of the harness\'s own making, and leaves an idle one alone', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const steered: unknown[] = []; + const agent = { id: 'a1', status: 'running', steer: (message: unknown) => steered.push(message) }; + const { cleanups, socket } = await runPlugin(dir, commands, { get: () => agent }); + await waitForSocket(socket); + // The plugin builds the message with the harness it runs in, found from + // the CLI node was started with: here, the harness this repo pins. + const argv = process.argv[1] ?? ''; + process.argv[1] = fileURLToPath(new URL('../../../../node_modules/@deepseek-ai/dsh/lib/bin.js', import.meta.url)); + try { + expect(await steerSession(socket, 's1', 'use the other parser', () => {})).toBe(true); + expect(steered).toHaveLength(1); + expect(steered[0]).toMatchObject({ role: 'user', content: [{ type: 'text', text: 'use the other parser' }] }); + agent.status = 'idle'; + // No turn to take it: the host prompts it instead. + expect(await steerSession(socket, 's1', 'and then?', () => {})).toBe(false); + expect(steered).toHaveLength(1); + } finally { + process.argv[1] = argv; + for (const cleanup of cleanups) cleanup(); + } + }); + + it('lists them, with the hint the harness gives', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket } = await runPlugin(dir, commands); + await waitForSocket(socket); + expect(await listSessionCommands(socket, 's1', () => {})).toEqual([ + { name: 'compact', description: 'Compact the conversation', argumentHint: '[]' }, + { name: 'goal', description: 'Set or view the goal', argumentHint: '[]' }, + { name: 'plain', description: 'No input at all' }, + ]); + for (const cleanup of cleanups) cleanup(); + }); + + it('runs one line and reports what it said, and says so when it cannot', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket } = await runPlugin(dir, commands); + await waitForSocket(socket); + expect(await runSessionCommand(socket, 's1', '/compact now', () => {})).toEqual({ ok: true, text: 'Compacted 12 messages.' }); + // The harness answers `undefined` for a line it does not have: that is a + // refusal to report, not a success with no text. + const unknown = await runSessionCommand(socket, 's1', '/nope', () => {}); + expect(unknown?.ok).toBe(false); + for (const cleanup of cleanups) cleanup(); + }); + + it('reports a session the harness does not have', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket } = await runPlugin(dir, { ...commands, list: () => [] }, { get: () => undefined }); + await waitForSocket(socket); + expect(await listSessionCommands(socket, 'gone', () => {})).toBeUndefined(); + for (const cleanup of cleanups) cleanup(); + }); + + it('answers a command whose handler failed as an error', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const failing = { + list: () => [], + execute: () => Promise.resolve({ commandId: 'c2', result: { kind: 'error', text: 'nothing to compact' } }), + }; + const { cleanups, socket } = await runPlugin(dir, failing); + await waitForSocket(socket); + expect(await runSessionCommand(socket, 's1', '/compact', () => {})).toEqual({ ok: false, text: 'nothing to compact' }); + for (const cleanup of cleanups) cleanup(); + }); +}); + +describe('questions, through the plugin', () => { + /** One question as the harness's model asks it. */ + const asked = { + questions: [ + { + id: 'q1', + question: 'Which database?', + header: 'Storage', + options: [{ label: 'SQLite' }, { label: 'Postgres', description: 'A server' }], + }, + ], + agent: { session: { id: 's1' } }, + signal: new AbortController().signal, + wait: { callId: 'c1' }, + }; + + it('pushes it to the host on stderr and returns what the host answers with', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket, listeners } = await runPlugin(dir, { list: () => [], execute: () => Promise.resolve(undefined) }); + await waitForSocket(socket); + const pushed: string[] = []; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => { + pushed.push(String(chunk)); + return true; + }); + const listener = listeners.get('user-questions/request')!; + const pending = listener(asked as never, () => Promise.reject(new Error('delegated')) as never); + stderr.mockRestore(); + + // The host learns about it on stderr — the stream ACP does not own. + const line = pushed.find((chunk) => chunk.includes(QUESTION_MARKER)); + expect(line).toBeDefined(); + const parsed = parseQuestionLine(line!, QUESTION_MARKER)!; + expect(parsed).toMatchObject({ sessionId: 's1', callId: 'c1' }); + expect(parsed.questions[0]).toMatchObject({ id: 'q1', question: 'Which database?', header: 'Storage' }); + + // And answers on the socket, which is what the harness's tool returns. + await askPlugin(socket, { method: 'answer', callId: 'c1', sessionId: 's1', answer: [{ id: 'q1', selected: ['SQLite'] }] }, 2_000, () => {}); + expect(await pending).toEqual({ answers: [{ id: 'q1', selected: ['SQLite'] }] }); + for (const cleanup of cleanups) cleanup(); + }); + + it('tells the model it was not answered when the host says nothing', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket, listeners } = await runPlugin(dir, { list: () => [], execute: () => Promise.resolve(undefined) }); + await waitForSocket(socket); + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + const pending = listeners.get('user-questions/request')!(asked as never, () => Promise.reject(new Error('delegated')) as never); + stderr.mockRestore(); + // The handler is attached before the answer arrives: a rejection nobody + // is waiting on yet is an unhandled one. + const refused = expect(pending).rejects.toThrow(/did not answer/); + await askPlugin(socket, { method: 'answer', callId: 'c1', sessionId: 's1' }, 2_000, () => {}); + await refused; + for (const cleanup of cleanups) cleanup(); + }); + + it('shows a plan review, whose ask names no wait, and returns the verdict', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket, listeners } = await runPlugin(dir, { list: () => [], execute: () => Promise.resolve(undefined) }); + await waitForSocket(socket); + const pushed: string[] = []; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => { + pushed.push(String(chunk)); + return true; + }); + // The plan review of `exit_plan_mode` asks the same service the question + // tool does: the plan is the question's detail, and the call id is on its + // intent — there is no `wait` to read it from. + const review = { + questions: [ + { + id: 'plan-review', + header: 'Plan review', + question: 'Approve this plan and leave plan mode?', + detail: '# Ship the harness\n\nDo the thing.', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], + intent: { kind: 'plan-review', approve: 'Approve', callId: 'call-7' }, + }, + ], + agent: { session: { id: 's1' } }, + signal: new AbortController().signal, + }; + const pending = listeners.get('user-questions/request')!(review as never, () => Promise.reject(new Error('delegated')) as never); + stderr.mockRestore(); + + const parsed = parseQuestionLine(pushed.find((chunk) => chunk.includes(QUESTION_MARKER))!, QUESTION_MARKER)!; + expect(parsed).toMatchObject({ sessionId: 's1', callId: 'call-7' }); + expect(toQuestionSpecs(parsed.questions)).toEqual([ + { + question: 'Approve this plan and leave plan mode?\n\n# Ship the harness\n\nDo the thing.', + header: 'Plan review', + options: [{ label: 'Approve' }, { label: 'Keep planning' }], + }, + ]); + + // The verdict travels back as the harness reads it: the chosen label. + await askPlugin( + socket, + { method: 'answer', callId: 'call-7', sessionId: 's1', answer: toAnswerItems(parsed.questions, ['Approve']) }, + 2_000, + () => {}, + ); + expect(await pending).toEqual({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }); + for (const cleanup of cleanups) cleanup(); + }); + + it('asks under a key of its own when the request names none', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, socket, listeners } = await runPlugin(dir, { list: () => [], execute: () => Promise.resolve(undefined) }); + await waitForSocket(socket); + const pushed: string[] = []; + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => { + pushed.push(String(chunk)); + return true; + }); + const pending = listeners.get('user-questions/request')!( + { questions: [{ id: 'q1', question: 'And now?' }], agent: { session: { id: 's1' } } } as never, + () => Promise.reject(new Error('delegated')) as never, + ); + stderr.mockRestore(); + + const parsed = parseQuestionLine(pushed.find((chunk) => chunk.includes(QUESTION_MARKER))!, QUESTION_MARKER)!; + expect(parsed.callId).not.toBe(''); + await askPlugin(socket, { method: 'answer', callId: parsed.callId, sessionId: 's1', answer: [{ id: 'q1', selected: [] }] }, 2_000, () => {}); + expect(await pending).toEqual({ answers: [{ id: 'q1', selected: [] }] }); + for (const cleanup of cleanups) cleanup(); + }); + + it('hands a request it cannot show to the next answerer', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const { cleanups, listeners } = await runPlugin(dir, { list: () => [], execute: () => Promise.resolve(undefined) }); + const listener = listeners.get('user-questions/request')!; + // `next()` is how a waterfall listener passes a request on. Returning + // without it vetoes the chain and leaves the caller with `undefined` where + // the answer batch belongs — a crash wherever the ask's result is read. + const delegated = vi.fn(() => Promise.resolve({ answers: [] })); + const nothingAsked = await listener({ questions: [], agent: { session: { id: 's1' } } } as never, delegated as never); + const noSession = await listener({ questions: [{ id: 'q1', question: 'Q?' }] } as never, delegated as never); + expect([nothingAsked, noSession]).toEqual([{ answers: [] }, { answers: [] }]); + expect(delegated).toHaveBeenCalledTimes(2); + for (const cleanup of cleanups) cleanup(); + }); +}); + +describe('what the host makes of a question', () => { + it('shows the harness questions as the wire ones, detail and all', () => { + const specs = toQuestionSpecs([ + { id: 'q1', question: 'Which database?', header: 'Storage', options: [{ label: 'SQLite' }, { label: 'Postgres', description: 'A server' }] }, + { id: 'q2', question: 'Anything else?', detail: 'A line of context.', multiSelect: true }, + ]); + expect(specs).toEqual([ + { + question: 'Which database?', + header: 'Storage', + options: [{ label: 'SQLite' }, { label: 'Postgres', description: 'A server' }], + }, + { question: 'Anything else?\n\nA line of context.', options: [], multiSelect: true }, + ]); + }); + + it('answers the way the harness takes an answer: labels, or what was typed', () => { + const questions = [ + { id: 'q1', question: 'Which database?', options: [{ label: 'SQLite' }, { label: 'Postgres' }] }, + { id: 'q2', question: 'Anything else?' }, + { id: 'q3', question: 'Which parts?', options: [{ label: 'api' }, { label: 'app' }], multiSelect: true }, + ]; + expect(toAnswerItems(questions, ['SQLite', 'a note', 'api, app'])).toEqual([ + { id: 'q1', selected: ['SQLite'] }, + { id: 'q2', selected: [], custom: 'a note' }, + { id: 'q3', selected: ['api', 'app'] }, + ]); + // A multi-select answered with something that is not a list of its own + // labels is text the user typed, not labels. + expect(toAnswerItems(questions, ['Postgres', 'a, b', 'api, something else'])[2]).toEqual({ + id: 'q3', + selected: [], + custom: 'api, something else', + }); + }); + + it('reads a pushed line, and ignores anything that is not one', () => { + const line = `${QUESTION_MARKER}${JSON.stringify({ sessionId: 's1', callId: 'c1', questions: [{ id: 'q1', question: 'Q?' }] })}`; + expect(parseQuestionLine(line, QUESTION_MARKER)).toEqual({ sessionId: 's1', callId: 'c1', questions: [{ id: 'q1', question: 'Q?' }] }); + expect(parseQuestionLine('the harness logging something', QUESTION_MARKER)).toBeUndefined(); + expect(parseQuestionLine(`${QUESTION_MARKER}not json`, QUESTION_MARKER)).toBeUndefined(); + expect(parseQuestionLine(`${QUESTION_MARKER}{"questions":[]}`, QUESTION_MARKER)).toBeUndefined(); + // What an ask is for travels with it: a plan review is told from a plain + // question by nothing else. + const withIntent = `${QUESTION_MARKER}${JSON.stringify({ + sessionId: 's1', + callId: 'c1', + questions: [{ id: 'q1', question: 'Q?', intent: { kind: 'plan-review', approve: 'Approve' } }], + })}`; + expect(parseQuestionLine(withIntent, QUESTION_MARKER)?.questions[0]?.intent).toEqual({ kind: 'plan-review', approve: 'Approve' }); + }); + + it('reads a plan review out of an ask, and leaves plain questions alone', () => { + expect(planReviewOf([{ id: 'q1', question: 'Q?' }])).toBeUndefined(); + // Nothing to show, or nothing to choose: an ordinary question either way. + expect(planReviewOf([{ id: 'q1', question: 'Q?', intent: { kind: 'plan-review' } }])).toBeUndefined(); + expect( + planReviewOf([{ id: 'q1', question: 'Q?', detail: '# P', intent: { kind: 'plan-review' } }]), + ).toBeUndefined(); + expect( + planReviewOf([ + { + id: 'plan-review', + header: 'Plan review', + question: 'Approve this plan and leave plan mode?', + detail: '# Ship it', + options: [{ label: 'Approve', description: 'Go.' }, { label: 'Keep planning' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }, + ]), + ).toEqual({ + id: 'plan-review', + plan: '# Ship it', + // The label is the option id: that is what the harness reads back. + options: [ + { id: 'Approve', label: 'Approve', description: 'Go.' }, + { id: 'Keep planning', label: 'Keep planning' }, + ], + // Not the approval: the choice the user's feedback goes with. + revise: 'Keep planning', + }); + }); +}); + +describe('the client on its own', () => { + /** A socket that answers whatever the test scripts (or nothing at all). */ + async function fakeBridge(answer: (request: Record, socket: Socket) => void): Promise<{ socket: string; close: () => void }> { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const socket = socketPathIn(dir); + const server: Server = createServer((connection) => { + connection.setEncoding('utf8'); + let buffered = ''; + connection.on('data', (chunk: string) => { + buffered += chunk; + const end = buffered.indexOf('\n'); + if (end < 0) return; + answer(JSON.parse(buffered.slice(0, end)) as Record, connection); + }); + connection.on('error', () => {}); + }); + await new Promise((resolve) => server.listen(socket, resolve)); + return { socket, close: () => server.close() }; + } + + it('answers nothing when no plugin is there', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-cmd-')); + const logs: string[] = []; + expect(await listSessionCommands(socketPathIn(dir), 's1', (line) => logs.push(line))).toBeUndefined(); + expect(logs.some((line) => /command bridge is not running/.test(line))).toBe(true); + }); + + it('answers nothing on a malformed reply, a wrong id, or no reply at all', async () => { + const malformed = await fakeBridge((_request, socket) => socket.write('not json at all\n')); + expect(await runSessionCommand(malformed.socket, 's1', '/x', () => {}, 2_000)).toBeUndefined(); + malformed.close(); + + const silent = await fakeBridge(() => {}); + const logs: string[] = []; + expect(await listSessionCommands(silent.socket, 's1', (line) => logs.push(line), 50)).toBeUndefined(); + expect(logs.some((line) => /did not answer within 50ms/.test(line))).toBe(true); + silent.close(); + }); + + it('forgets about a command name that is not a name', async () => { + const bridge = await fakeBridge((_request, socket) => + socket.write(`${JSON.stringify({ id: 1, ok: true, commands: [{ name: '' }, { name: 'ok' }] })}\n`), + ); + expect((await listSessionCommands(bridge.socket, 's1', () => {}))?.map((command) => command.name)).toEqual(['ok']); + bridge.close(); + }); +}); + +describe('the socket path', () => { + it('is one per harness process, and a named pipe on Windows', () => { + expect(bridgeSocketPath('/data', 'a1', 'linux')).toBe(path.join('/data', 'codedeck', 'dsh-bridge-a1.sock')); + // Two processes of one home never share a socket: the second would take + // the first one's file, and whichever closed first would unlink the + // other's. + expect(bridgeSocketPath('/data', 'a1', 'linux')).not.toBe(bridgeSocketPath('/data', 'b2', 'linux')); + expect(bridgeSocketPath('/data', 'a1', 'win32')).not.toBe(bridgeSocketPath('/data', 'b2', 'win32')); + const pipe = bridgeSocketPath('/data', 'a1', 'win32'); + expect(pipe.startsWith('\\\\.\\pipe\\codedeck-dsh-bridge-')).toBe(true); + // Two homes, two pipes: a machine running two bridges must not have them + // answer each other's questions. + expect(bridgeSocketPath('/data', 'a1', 'win32')).not.toBe(bridgeSocketPath('/other', 'a1', 'win32')); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts new file mode 100644 index 00000000..38d08b98 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekDriver.test.ts @@ -0,0 +1,1140 @@ +import { EventEmitter } from 'node:events'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { createServer, type Socket } from 'node:net'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { recordingContext, type RecordingContext } from '../../../__tests__/context'; +import type { StartSession } from '../../../types'; +import type { DriverSession } from '../../../driver'; +import { DeepSeekDriver } from '../driver'; +import { BRIDGE_SOCKET_ENV, QUESTION_MARKER } from '../plugin'; +import { DeepSeekMcp } from '../mcp'; +import { DeepSeekRuntime, dshHomeDir } from '../runtime'; +import { FakeHarness } from './fakeHarness'; + +/** A harness home in a temp directory, with the profile directory the CLI + * would create on its first run. */ +function harnessHome(): string { + const home = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-')); + mkdirSync(path.join(home, 'profiles', 'acp'), { recursive: true }); + writeFileSync(path.join(home, 'profiles', 'acp', 'cordis.patch.yml'), '# the profile layer\n[]\n'); + return home; +} + +interface Harness { + driver: DeepSeekDriver; + runtime: DeepSeekRuntime; + harness: FakeHarness; + home: string; + spawns: Array<{ command: string; args: string[]; env: Record }>; + /** What the runtime logged (the harness's own stderr lines included). */ + logs: string[]; + /** The session the last `started()` call created. */ + session: DriverSession; +} + +/** A driver over a scripted harness. The install seam never downloads: the + * fake spawn answers instead. */ +function withDriver( + options: { idleCloseMs?: number; env?: NodeJS.ProcessEnv; dshPath?: string; log?: (line: string) => void } = {}, +): Harness { + const home = harnessHome(); + const harness = new FakeHarness(); + const spawns: Harness['spawns'] = []; + const logs: string[] = []; + const log = (line: string): void => { + logs.push(line); + }; + const mcp = new DeepSeekMcp({ profileDir: path.join(home, 'profiles', 'acp'), log }); + const runtime = new DeepSeekRuntime({ + ...(options.dshPath !== undefined ? { dshPath: options.dshPath } : {}), + home, + cacheDir: path.join(home, 'agents'), + installDsh: async () => '/fake/dsh/lib/bin.js', + configVersion: () => mcp.version, + spawnFn: ((command: string, args: string[], opts: { env: Record }) => { + spawns.push({ command, args, env: opts.env }); + return harness.child; + }) as never, + idleCloseMs: options.idleCloseMs ?? 0, + log: options.log ?? log, + }); + const driver = DeepSeekDriver.create({ + runtime, + home, + mcp, + baseEnv: options.env ?? ({} as NodeJS.ProcessEnv), + log: options.log ?? log, + }); + return { driver, runtime, harness, home, spawns, logs, session: undefined as never }; +} + +function start(overrides: Partial = {}): StartSession { + return { sessionId: 'b1', agent: 'deepseek-harness', cwd: '/work', ...overrides }; +} + +/** Start a session and wait for it to accept prompts. The session is kept + * on the harness so a test can prompt it and change its options. */ +async function started( + ready: Harness, + overrides: Partial = {}, + ctx: RecordingContext = recordingContext(), +): Promise { + ready.session = ready.driver.startSession(start(overrides), ctx); + await ctx.waitFor((event) => event.type === 'ready'); + return ctx; +} + +/** The socket the runtime gave the process it spawned, where the plugin + * of that process listens. */ +const socketOf = (ready: Harness): string => ready.spawns[0]!.env[BRIDGE_SOCKET_ENV]!; + +const infoEvents = (ctx: RecordingContext) => ctx.events.filter((event) => event.type === 'info'); + +describe('DeepSeekDriver.info', () => { + it('advertises what the ACP surface carries, and what it does not', () => { + const { driver } = withDriver(); + const info = driver.info(); + expect(info.id).toBe('deepseek-harness'); + expect(info.displayName).toBe('DeepSeek Harness'); + expect(info.modes?.map((mode) => mode.id)).toEqual(['ask', 'default']); + expect(info.efforts?.map((effort) => effort.id)).toEqual(['off', 'low', 'high', 'max']); + expect(info.defaultMode).toBe('ask'); + expect(info.supports).toEqual({ + models: true, + usage: false, + providers: true, + gsd: true, + interrupt: true, + commands: true, + plugins: true, + mcp: true, + tasks: false, + }); + expect(info.credentials).toEqual([{ id: 'deepseek_api_key', label: 'DeepSeek API key', envVar: 'DEEPSEEK_API_KEY' }]); + }); +}); + +describe('DeepSeekSession startup', () => { + it('starts a session and reports its identity', async () => { + const ready = withDriver(); + const ctx = await started(ready, { model: '["deepseek-official","deepseek-v4-pro"]', effort: 'low' }); + + expect(ready.harness.newSessions).toEqual([{ cwd: '/work', mcpServers: [] }]); + // The model is selected by the harness's own opaque value; the effort by + // its plain id. + expect(ready.harness.setOptions).toEqual([ + { sessionId: 's1', configId: 'model', value: '["deepseek-official","deepseek-v4-pro"]' }, + { sessionId: 's1', configId: 'reasoning_effort', value: 'low' }, + ]); + expect(infoEvents(ctx)[0]).toEqual({ type: 'info', nativeSessionId: 's1', model: 'DeepSeek-V4-Pro', mode: 'ask' }); + expect(ctx.entries().map((entry) => entry.entryType)).toEqual(['status']); + }); + + it('accepts a model named by its plain id, as a provider profile names one', async () => { + const ready = withDriver(); + await started(ready, { model: 'deepseek-v4-pro' }); + expect(ready.harness.setOptions[0]).toEqual({ + sessionId: 's1', + configId: 'model', + value: '["deepseek-official","deepseek-v4-pro"]', + }); + }); + + it('refuses a model the harness does not offer', async () => { + const ready = withDriver(); + const ctx = recordingContext(); + ready.driver.startSession(start({ model: 'gpt-9' }), ctx); + const ended = await ctx.ended(); + expect(ended.error).toMatch(/does not offer the model 'gpt-9'/); + expect(ctx.entries()[0]).toMatchObject({ entryType: 'error' }); + }); + + it('keeps going, with an error entry, when a provider-bound model is unknown', async () => { + const ready = withDriver(); + const ctx = await started(ready, { + model: 'kimi-k2', + provider: { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-x', models: [] }, + }); + expect(ready.harness.setOptions.some((option) => option.configId === 'model')).toBe(true); + expect(ctx.entries().some((entry) => entry.entryType === 'error' && /kimi-k2/.test(entry.text))).toBe(true); + }); + + it('reports an effort the model does not have instead of refusing the session', async () => { + const ready = withDriver(); + // A model without a reasoning option (a gateway's, say). + ready.harness.options = [ready.harness.options[0]!]; + const ctx = await started(ready, { effort: 'max' }); + expect(ctx.entries().some((entry) => entry.entryType === 'error' && /no reasoning level/.test(entry.text))).toBe(true); + expect(ready.harness.setOptions).toEqual([]); + }); + + it('reports a start that failed as an error entry and an ended session', async () => { + const ready = withDriver(); + ready.harness.newSessionError = 'the profile could not be loaded'; + const ctx = recordingContext(); + ready.driver.startSession(start(), ctx); + expect((await ctx.ended()).error).toMatch(/the profile could not be loaded/); + }); + + it('lets go of the harness process when a session is closed while starting', async () => { + const ready = withDriver(); + const ctx = recordingContext(); + const session = ready.driver.startSession(start(), ctx); + await session.end(); + await vi.waitFor(() => expect(ready.harness.child.killed).toContain('SIGTERM')); + expect(ctx.events.some((event) => event.type === 'ended')).toBe(false); + }); +}); + +describe('DeepSeekSession environment', () => { + it('hands the session its provider binding, replacing the harness namespace', async () => { + const ready = withDriver({ env: { PATH: '/bin', DEEPSEEK_API_KEY: 'native-key', KEEP: 'yes' } }); + await started(ready, { + provider: { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk-gateway', models: [] }, + }); + expect(ready.spawns[0]?.env.DEEPSEEK_BASE_URL).toBe('https://gateway.example/v1'); + expect(ready.spawns[0]?.env.DEEPSEEK_API_KEY).toBe('sk-gateway'); + expect(ready.spawns[0]?.env.KEEP).toBe('yes'); + }); + + it('leaves the operator environment alone for a native session', async () => { + const ready = withDriver({ env: { PATH: '/bin', DEEPSEEK_BASE_URL: 'https://relay.example' } }); + await started(ready); + expect(ready.spawns[0]?.env.DEEPSEEK_BASE_URL).toBe('https://relay.example'); + }); + + it('refuses a provider profile with an insecure base URL before starting', () => { + const ready = withDriver(); + const ctx = recordingContext(); + expect(() => + ready.driver.startSession( + start({ provider: { id: 'p1', baseUrl: 'http://gateway.example/v1', authToken: 'sk', models: [] } }), + ctx, + ), + ).toThrow(/insecure base URL/); + }); + + it('runs one harness process per environment and shares it between sessions', async () => { + const ready = withDriver(); + await started(ready, { sessionId: 'b1' }); + await started(ready, { sessionId: 'b2' }); + const gateway = { id: 'p1', baseUrl: 'https://gateway.example/v1', authToken: 'sk', models: [] }; + await started(ready, { sessionId: 'b3', provider: gateway }); + expect(ready.spawns).toHaveLength(2); + }); +}); + +describe('DeepSeekSession updates', () => { + it('turns the harness updates into transcript entries', async () => { + const ready = withDriver(); + const ctx = await started(ready); + + ready.harness.thought('s1', 'weighing options'); + ready.harness.message('s1', 'Here is the plan.'); + ready.harness.toolCall('s1', 'c1', 'bash', { command: 'npm ci\nnpm test' }); + ready.harness.toolResult('s1', 'c1', 'all green'); + ready.harness.toolCall('s1', 'c2', 'edit', { file_path: 'src/a.ts', old_string: 'one', new_string: 'two' }); + ready.harness.toolResult('s1', 'c2', ''); + ready.harness.toolCall('s1', 'c3', 'todo_write', { todos: [{ content: 'Ship it', status: 'in_progress' }] }); + + await vi.waitFor(() => expect(ctx.entries()).toHaveLength(10)); + const entries = ctx.entries(); + expect(entries[0]).toMatchObject({ entryType: 'status' }); + expect(entries[1]).toMatchObject({ entryType: 'thinking', text: 'weighing options' }); + expect(entries[2]).toMatchObject({ entryType: 'text', role: 'agent', text: 'Here is the plan.' }); + expect(entries[3]).toMatchObject({ + entryType: 'tool_call', + callId: 'c1', + kind: 'execute', + title: 'npm ci…', + input: 'npm ci\nnpm test', + }); + expect(entries[4]).toMatchObject({ entryType: 'tool_result', callId: 'c1', text: 'all green' }); + expect(entries[5]).toMatchObject({ entryType: 'tool_call', callId: 'c2', kind: 'edit', locations: ['src/a.ts'] }); + // An empty result still marks the call finished, and the file change is + // reconstructed from the call's own arguments. + expect(entries[6]).toMatchObject({ entryType: 'tool_result', callId: 'c2', text: '' }); + expect(entries[7]).toMatchObject({ entryType: 'diff', path: 'src/a.ts', callId: 'c2' }); + expect(entries[8]).toMatchObject({ entryType: 'tool_call', callId: 'c3', kind: 'think' }); + expect(entries[9]).toMatchObject({ entryType: 'todos', items: [{ text: 'Ship it', status: 'in_progress' }] }); + }); + + it('reports how full the context is, and only when it changed', async () => { + const ready = withDriver(); + const ctx = await started(ready); + + ready.harness.usage('s1', 5000, 10_000); + ready.harness.usage('s1', 5000, 10_000); + ready.harness.usage('s1', 7500, 10_000); + await vi.waitFor(() => expect(infoEvents(ctx)).toHaveLength(3)); + expect(infoEvents(ctx).map((event) => [event.contextWindow, event.contextPercentage])).toEqual([ + [undefined, undefined], + [10_000, 50], + [10_000, 75], + ]); + }); + + it('drops an update for a session nobody serves', async () => { + const ready = withDriver(); + const ctx = await started(ready); + ready.harness.message('other', 'not mine'); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(ctx.entries().map((entry) => entry.entryType)).toEqual(['status']); + }); +}); + +describe('DeepSeekSession permissions', () => { + /** Drive one scripted turn that asks for `callId`, and answer with what + * the harness received for its ask. */ + async function askThrough( + ready: Harness, + ctx: RecordingContext, + callId: string, + toolName: string, + input: Record, + ): Promise<{ outcome: string; optionId?: string } | undefined> { + ready.harness.toolCall('s1', callId, toolName, input); + let answer: { outcome: string; optionId?: string } | undefined; + let answered = false; + ready.harness.onPrompt = async (sessionId) => { + answer = await ready.harness.askPermission(sessionId, callId); + answered = true; + return 'end_turn'; + }; + ready.session.prompt('go'); + await vi.waitFor(() => expect(answered).toBe(true)); + return answer; + } + + it('asks the phone with the tool call the harness named, and allows on Allow', async () => { + const ready = withDriver(); + const ctx = recordingContext({ + permission: (request) => { + expect(request).toMatchObject({ + requestId: 'c1', + toolName: 'bash', + kind: 'execute', + title: 'rm -rf build', + rawInput: { command: 'rm -rf build' }, + options: [ + { id: 'allow', label: 'Allow', kind: 'allow_once' }, + { id: 'deny', label: 'Deny', kind: 'reject_once' }, + ], + }); + return { outcome: 'selected', optionId: 'allow' }; + }, + }); + await started(ready, {}, ctx); + expect(await askThrough(ready, ctx, 'c1', 'bash', { command: 'rm -rf build' })).toEqual({ + outcome: 'selected', + optionId: 'allow-once', + }); + }); + + it('refuses the ask when the phone refuses', async () => { + const ready = withDriver(); + const ctx = recordingContext({ permission: () => ({ outcome: 'selected', optionId: 'deny' }) }); + await started(ready, {}, ctx); + expect(await askThrough(ready, ctx, 'c2', 'bash', { command: 'ls' })).toEqual({ + outcome: 'selected', + optionId: 'reject-once', + }); + }); + + it('cancels the ask when nobody answered, rather than pretending a refusal', async () => { + const ready = withDriver(); + const ctx = recordingContext({ permission: () => ({ outcome: 'cancelled', reason: 'timed out' }) }); + await started(ready, {}, ctx); + expect(await askThrough(ready, ctx, 'c3', 'bash', { command: 'ls' })).toEqual({ outcome: 'cancelled' }); + }); + + it('allows without asking in the auto-approve mode', async () => { + const ready = withDriver(); + const ctx = recordingContext({ permission: () => ({ outcome: 'selected', optionId: 'deny' }) }); + await started(ready, { mode: 'default' }, ctx); + expect(await askThrough(ready, ctx, 'c4', 'bash', { command: 'ls' })).toEqual({ + outcome: 'selected', + optionId: 'allow-once', + }); + expect(ctx.permissions).toHaveLength(0); + }); +}); + +describe('DeepSeekSession turns', () => { + it('runs one prompt at a time and marks each turn', async () => { + const ready = withDriver(); + const ctx = await started(ready); + const prompts: string[] = []; + const releases: Array<() => void> = []; + ready.harness.onPrompt = async (sessionId, text) => { + void sessionId; + prompts.push(text); + await new Promise((resolve) => { + releases.push(resolve); + }); + return 'end_turn'; + }; + ready.session.prompt('one'); + ready.session.prompt('two'); + // The second prompt waits for the first turn to be over: ACP refuses one + // while another is in flight. + await vi.waitFor(() => expect(prompts).toEqual(['one'])); + releases[0]?.(); + await vi.waitFor(() => expect(prompts).toEqual(['one', 'two'])); + releases[1]?.(); + await vi.waitFor(() => expect(ctx.entries().filter((entry) => entry.entryType === 'turn_complete')).toHaveLength(2)); + expect(ctx.events.filter((event) => event.type === 'turn')).toHaveLength(4); + }); + + it('reports a failed turn and completes it', async () => { + const ready = withDriver(); + const ctx = await started(ready); + ready.harness.onPrompt = () => { + throw new Error('DeepSeek Messages transport failed'); + }; + ready.session.prompt('go'); + await vi.waitFor(() => + expect(ctx.entries().some((entry) => entry.entryType === 'error' && /transport failed/.test(entry.text))).toBe(true), + ); + expect(ctx.entries().filter((entry) => entry.entryType === 'turn_complete').length).toBeGreaterThan(0); + }); + + it('cancels the running turn on interrupt', async () => { + const ready = withDriver(); + const ctx = await started(ready); + // Never resolves on its own: only the cancel notification ends it. + ready.harness.onPrompt = () => new Promise(() => {}); + ready.session.prompt('long one'); + await vi.waitFor(() => + expect(ready.harness.requests.some((request) => request.method === 'session/prompt')).toBe(true), + ); + await ready.session.interrupt(); + await vi.waitFor(() => expect(ready.harness.notifications.some((n) => n.method === 'session/cancel')).toBe(true)); + await vi.waitFor(() => expect(ctx.entries().some((entry) => entry.entryType === 'turn_complete')).toBe(true)); + }); +}); + +describe('DeepSeekSession options', () => { + it('applies a model and an effort mid-session and reports the resolved model', async () => { + const ready = withDriver(); + const ctx = await started(ready); + await ready.session.setOption('model', '["deepseek-official","deepseek-v4-pro"]'); + await ready.session.setOption('effort', 'off'); + await ready.session.setOption('mode', 'default'); + expect(ready.harness.setOptions.slice(-2)).toEqual([ + { sessionId: 's1', configId: 'model', value: '["deepseek-official","deepseek-v4-pro"]' }, + { sessionId: 's1', configId: 'reasoning_effort', value: 'off' }, + ]); + expect(infoEvents(ctx).some((event) => event.model === 'DeepSeek-V4-Pro')).toBe(true); + }); + + it('refuses a mode the driver does not have', async () => { + const ready = withDriver(); + const ctx = await started(ready); + await expect(ready.session.setOption('mode', 'plan')).rejects.toThrow(/no mode 'plan'/); + }); + + it('passes the harness its own refusal for an unknown effort', async () => { + const ready = withDriver(); + const ctx = await started(ready); + ready.harness.acceptsOptions = false; + await expect(ready.session.setOption('effort', 'turbo')).rejects.toThrow(/Invalid params/); + }); +}); + +describe('DeepSeekSession resume', () => { + it('continues the conversation the bridge names', async () => { + const ready = withDriver(); + await started(ready, { resume: 'old-session' }); + expect(ready.harness.resumed).toEqual([{ sessionId: 'old-session', cwd: '/work', mcpServers: [] }]); + expect(ready.harness.newSessions).toEqual([]); + }); + + it('starts fresh with a notice when the conversation is gone', async () => { + const ready = withDriver(); + ready.harness.resumeError = 'session is not resumable: old-session'; + const ctx = await started(ready, { resume: 'old-session' }); + expect(ready.harness.newSessions).toHaveLength(1); + expect(ctx.entries()[0]).toMatchObject({ entryType: 'notice', kind: 'session_restart' }); + expect(ctx.logs.some((line) => /could not resume old-session/.test(line))).toBe(true); + }); + + it('does not try a resume the harness says it does not support', async () => { + const ready = withDriver(); + ready.harness.supportsResume = false; + await started(ready, { resume: 'old-session' }); + expect(ready.harness.resumed).toEqual([]); + expect(ready.harness.newSessions).toHaveLength(1); + }); +}); + +describe('DeepSeekSession lifecycle', () => { + it('closes the session and stops the idle harness process', async () => { + const ready = withDriver(); + await started(ready); + await ready.session.end(); + expect(ready.harness.closed).toEqual(['s1']); + await vi.waitFor(() => expect(ready.harness.child.killed).toContain('SIGTERM')); + }); + + it('ends every session of a harness process that goes away', async () => { + const ready = withDriver(); + const first = await started(ready, { sessionId: 'b1' }); + const second = await started(ready, { sessionId: 'b2' }); + ready.harness.child.crash(7); + expect((await first.ended()).error).toMatch(/exited \(code 7\)/); + expect((await second.ended()).error).toMatch(/exited \(code 7\)/); + }); + + it('keeps an unused process for the idle grace period', async () => { + const ready = withDriver({ idleCloseMs: 60_000 }); + await started(ready); + await ready.session.end(); + expect(ready.harness.child.killed).toEqual([]); + await ready.driver.shutdown(); + expect(ready.harness.child.killed).toContain('SIGTERM'); + }); +}); + +describe('DeepSeekDriver model catalog', () => { + it('lists the harness catalog from a probe session, once', async () => { + const ready = withDriver(); + const models = await ready.driver.listModels(); + expect(models.defaultModel).toBe('deepseek-v4-flash'); + expect(models.models).toEqual([ + { id: 'deepseek-v4-flash', label: 'deepseek-v4-flash', provider: 'DeepSeek' }, + { id: 'deepseek-v4-pro', label: 'DeepSeek-V4-Pro', provider: 'DeepSeek' }, + ]); + await ready.driver.listModels(); + expect(ready.harness.newSessions).toHaveLength(1); + expect(ready.harness.closed).toEqual(['s1']); + }); + + it('asks again after an empty catalog rather than remembering nothing', async () => { + const ready = withDriver(); + ready.harness.options = []; + expect(await ready.driver.listModels()).toEqual({ models: [] }); + ready.harness.options = [{ id: 'model', type: 'select', currentValue: 'x', options: [{ value: 'x', name: 'X' }] }]; + expect((await ready.driver.listModels()).models).toHaveLength(1); + }); + + it('answers an empty list when the harness cannot be reached at all', async () => { + const home = harnessHome(); + const runtime = new DeepSeekRuntime({ + home, + cacheDir: path.join(home, 'agents'), + installDsh: async () => { + throw new Error('no network'); + }, + idleCloseMs: 0, + log: () => {}, + }); + const driver = DeepSeekDriver.create({ runtime, home, log: () => {} }); + expect(await driver.listModels()).toEqual({ models: [] }); + }); +}); + +describe('DeepSeekDriver credential check', () => { + it('checks the DeepSeek key against the DeepSeek API', async () => { + const httpGet = vi.fn(async () => ({ status: 200 })); + const ready = withDriver(); + const driver = DeepSeekDriver.create({ runtime: ready.runtime, home: ready.home, baseEnv: {} as NodeJS.ProcessEnv, httpGet, log: () => {} }); + expect(await driver.checkCredential('deepseek_api_key', 'sk-1')).toBe(true); + expect(httpGet).toHaveBeenCalledWith('https://api.deepseek.com/models', { authorization: 'Bearer sk-1' }); + expect(await driver.checkCredential('other', 'sk-1')).toBeUndefined(); + }); + + it('reports a refused key, and claims nothing on a network error', async () => { + const ready = withDriver(); + const refusing = DeepSeekDriver.create({ + runtime: ready.runtime, + home: ready.home, + baseEnv: {} as NodeJS.ProcessEnv, + httpGet: async () => ({ status: 401 }), + log: () => {}, + }); + expect(await refusing.checkCredential('deepseek_api_key', 'sk-bad')).toBe(false); + + const unreachable = DeepSeekDriver.create({ + runtime: ready.runtime, + home: ready.home, + baseEnv: {} as NodeJS.ProcessEnv, + httpGet: async () => { + throw new Error('ECONNREFUSED'); + }, + log: () => {}, + }); + expect(await unreachable.checkCredential('deepseek_api_key', 'sk-1')).toBeUndefined(); + }); + + it('checks a key against the gateway it belongs to, not the DeepSeek API', async () => { + const httpGet = vi.fn(async () => ({ status: 200 })); + const ready = withDriver(); + const driver = DeepSeekDriver.create({ + runtime: ready.runtime, + home: ready.home, + baseEnv: { DEEPSEEK_BASE_URL: 'https://gateway.example' } as NodeJS.ProcessEnv, + httpGet, + log: () => {}, + }); + expect(await driver.checkCredential('deepseek_api_key', 'sk-gateway')).toBe(true); + // The list the gateway serves is the one call every gateway has, and the + // one this driver reads its catalog from. + expect(httpGet).toHaveBeenCalledWith('https://gateway.example/v1/models', { authorization: 'Bearer sk-gateway' }); + }); +}); + +describe('session MCP status', () => { + it('reports the profile servers, with what the harness said about them', async () => { + const ready = withDriver(); + await started(ready); + const session = ready.session; + expect((await session.mcpStatus?.())?.servers).toEqual([]); + + await ready.driver.mcp.act('add', [{ name: 'demo', setup: { type: 'stdio', command: '/usr/bin/demo' } }], []); + await ready.driver.mcp.act('add', [{ name: 'fresh', setup: { type: 'http', url: 'https://mcp.example/mcp' } }], []); + expect(readFileSync(path.join(ready.home, 'profiles', 'acp', 'cordis.patch.yml'), 'utf8')).toMatch(/codedeck-mcp-demo/); + + // The harness's own log is the only place it reports a failed server. + ready.harness.child.stderr.write('mcp-client(demo): tool registration failed, no tools registered: boom\n'); + await vi.waitFor(() => expect(ready.logs.some((line) => /mcp-client\(demo\)/.test(line))).toBe(true)); + + const status = await session.mcpStatus?.(); + expect(status?.servers).toEqual([ + // The harness complained about this one: it has no tools. + { name: 'demo', status: 'failed', error: expect.stringMatching(/boom/) }, + // Configured after this process started: not loaded yet. + { name: 'fresh', status: 'pending' }, + ]); + expect(status?.toggles).toBe(false); + await expect(session.toggleMcp?.('demo', false)).rejects.toThrow(/when it starts/); + }); + + it('reports a server the running harness has loaded as connected', async () => { + const ready = withDriver(); + // Configured before the session starts, so the process it spawns loads + // it: this is the state every session of a bridge restart begins in. + await ready.driver.mcp.act('add', [{ name: 'demo', setup: { type: 'stdio', command: '/usr/bin/demo' } }], []); + await started(ready); + expect((await ready.session.mcpStatus?.())?.servers).toEqual([{ name: 'demo', status: 'connected' }]); + }); + + it('reports a server switched off in the profile as disabled', async () => { + const ready = withDriver(); + await started(ready); + await ready.driver.mcp.act('add', [{ name: 'demo', setup: { type: 'stdio', command: '/usr/bin/demo' } }], []); + await ready.driver.mcp.act('disable', [], ['demo']); + expect((await ready.session.mcpStatus?.())?.servers).toEqual([{ name: 'demo', status: 'disabled' }]); + }); +}); + +describe('the harness runtime', () => { + it('is resolved — and installed — as the driver is created, not on the first session', async () => { + const home = harnessHome(); + const installs: number[] = []; + const runtime = new DeepSeekRuntime({ + home, + cacheDir: path.join(home, 'agents'), + installDsh: async () => { + installs.push(Date.now()); + return '/fake/dsh/lib/bin.js'; + }, + idleCloseMs: 0, + log: () => {}, + }); + const driver = DeepSeekDriver.create({ runtime, home, log: () => {} }); + void driver; + // Nothing has started a session; the runtime is already on its way. + await vi.waitFor(() => expect(installs).toHaveLength(1)); + // And a session does not ask for it again. + await driver.listModels(); + expect(installs).toHaveLength(1); + }); + + it('reports a runtime it could not fetch, and keeps the agent listed', async () => { + const home = harnessHome(); + const logs: string[] = []; + const runtime = new DeepSeekRuntime({ + home, + cacheDir: path.join(home, 'agents'), + installDsh: async () => { + throw new Error('no network'); + }, + idleCloseMs: 0, + log: (line) => logs.push(line), + }); + const driver = DeepSeekDriver.create({ runtime, home, log: (line) => logs.push(line) }); + await vi.waitFor(() => expect(logs.some((line) => /not ready yet: no network/.test(line))).toBe(true)); + expect(driver.info().unavailableReason).toBeUndefined(); + }); +}); + +describe('a gateway', () => { + const gatewayEnv = (base: string, key = 'sk-gateway'): NodeJS.ProcessEnv => + ({ DEEPSEEK_BASE_URL: base, DEEPSEEK_API_KEY: key }) as NodeJS.ProcessEnv; + + it('writes the models the gateway serves into the harness profile, as the host starts', async () => { + const ready = withDriver({ env: gatewayEnv('http://gateway.example:3458') }); + ready.driver = DeepSeekDriver.create({ + runtime: ready.runtime, + home: ready.home, + baseEnv: gatewayEnv('http://gateway.example:3458'), + httpGet: async (url, headers) => { + expect(url).toBe('http://gateway.example:3458/v1/models'); + expect(headers.authorization).toBe('Bearer sk-gateway'); + return { status: 200, text: JSON.stringify({ data: [{ id: 'kimi-k2' }, { id: 'glm-4.6', context_length: 200_000 }] }) }; + }, + log: () => {}, + }); + await vi.waitFor(() => + expect(readFileSync(path.join(ready.home, 'profiles', 'acp', 'cordis.patch.yml'), 'utf8')).toMatch(/kimi-k2/), + ); + const layer = readFileSync(path.join(ready.home, 'profiles', 'acp', 'cordis.patch.yml'), 'utf8'); + // The row the harness reads: the catalog it may serve. The endpoint stays + // in the operator's environment, where only the operator's process sees it. + expect(layer).toMatch(/id: llm-deepseek/); + expect(layer).not.toMatch(/baseURL/); + expect(layer).toMatch(/contextWindow: 200000/); + // Its own block, and nothing else of ours: the MCP list is another one. + expect(layer).toMatch(/CodeDeck\+ gateway catalog/); + expect(layer).not.toMatch(/CodeDeck\+ MCP servers/); + }); + + it('leaves the harness catalog alone when the gateway does not answer', async () => { + const ready = withDriver(); + const driver = DeepSeekDriver.create({ + runtime: ready.runtime, + home: ready.home, + baseEnv: gatewayEnv('http://gateway.example:3458'), + httpGet: async () => { + throw new Error('ECONNREFUSED'); + }, + log: () => {}, + }); + await driver.listModels(); + expect(readFileSync(path.join(ready.home, 'profiles', 'acp', 'cordis.patch.yml'), 'utf8')).not.toMatch(/llm-deepseek/); + }); + + it('takes its catalog back out when no gateway is configured any more', async () => { + const ready = withDriver(); + const layer = path.join(ready.home, 'profiles', 'acp', 'cordis.patch.yml'); + writeFileSync( + layer, + `# the profile + +# --- CodeDeck+ gateway catalog: written from the gateway's own model list; everything outside this block is yours --- +- id: llm-deepseek + config: + baseURL: http://old.example +# --- end CodeDeck+ gateway catalog --- +`, + ); + const driver = DeepSeekDriver.create({ runtime: ready.runtime, home: ready.home, baseEnv: {} as NodeJS.ProcessEnv, log: () => {} }); + await driver.listModels(); + const after = readFileSync(layer, 'utf8'); + expect(after).not.toMatch(/llm-deepseek/); + expect(after).toMatch(/# the profile/); + }); +}); + +describe('slash commands', () => { + /** A stand-in for the command plugin: it answers over the socket the driver + * asks on, and records what it was asked. */ + async function commandBridge( + socket: string, + answer: (request: Record) => Record, + ): Promise<{ requests: Array>; close: () => void }> { + const requests: Array> = []; + const server = createServer((connection: Socket) => { + connection.setEncoding('utf8'); + let buffered = ''; + connection.on('data', (chunk: string) => { + buffered += chunk; + const end = buffered.indexOf('\n'); + if (end < 0) return; + const request = JSON.parse(buffered.slice(0, end)) as Record; + requests.push(request); + connection.write(`${JSON.stringify({ id: request.id, ...answer(request) })}\n`); + }); + connection.on('error', () => {}); + }); + mkdirSync(path.dirname(socket), { recursive: true }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socket, resolve); + }); + return { requests, close: () => server.close() }; + } + + const listing = (): Record => ({ + ok: true, + commands: [{ name: 'compact', description: 'Compact the conversation', hint: '[]' }], + }); + + it('lists what the harness has, and runs a typed command rather than prompting', async () => { + const ready = withDriver(); + const ctx = await started(ready); + const bridge = await commandBridge(socketOf(ready), (request) => + request.method === 'list' ? listing() : { ok: true, result: { kind: 'success', text: 'Compacted 12 messages.' } }, + ); + expect(await ready.session.listCommands?.()).toEqual([ + { name: 'compact', description: 'Compact the conversation', argumentHint: '[]' }, + ]); + + ready.session.prompt('/compact keep the decisions'); + await vi.waitFor(() => expect(bridge.requests.some((request) => request.method === 'run')).toBe(true)); + const run = bridge.requests.find((request) => request.method === 'run')!; + expect(run).toMatchObject({ sessionId: 's1', line: '/compact keep the decisions' }); + // The command's own words are in the transcript, and the harness was + // never asked to prompt the model with a slash line. + await vi.waitFor(() => + expect(ctx.entries().some((entry) => entry.entryType === 'text' && /Compacted 12 messages/.test(entry.text))).toBe(true), + ); + expect(ready.harness.requests.some((request) => request.method === 'session/prompt')).toBe(false); + bridge.close(); + }); + + it('reports a command that failed', async () => { + const ready = withDriver(); + const ctx = await started(ready); + const bridge = await commandBridge(socketOf(ready), (request) => + request.method === 'list' ? listing() : { ok: true, result: { kind: 'error', text: 'nothing to compact' } }, + ); + ready.session.prompt('/compact'); + await vi.waitFor(() => + expect(ctx.entries().some((entry) => entry.entryType === 'error' && /nothing to compact/.test(entry.text))).toBe(true), + ); + bridge.close(); + }); + + it('sends a slash line the harness does not have to the model, as text', async () => { + const ready = withDriver(); + const ctx = await started(ready); + const bridge = await commandBridge(socketOf(ready), (request) => (request.method === 'list' ? listing() : { ok: false })); + ready.session.prompt('/etc/hosts is missing'); + await vi.waitFor(() => + expect(ready.harness.requests.some((request) => request.method === 'session/prompt')).toBe(true), + ); + expect(bridge.requests.some((request) => request.method === 'run')).toBe(false); + expect(ctx.entries().length).toBeGreaterThan(0); + bridge.close(); + }); + + it('asks for the commands again once the plugin answers', async () => { + const ready = withDriver(); + await started(ready); + // Nothing listening yet: the line is text, and that is not remembered as + // "this harness has no commands". + ready.session.prompt('/compact'); + await vi.waitFor(() => expect(ready.harness.requests.some((request) => request.method === 'session/prompt')).toBe(true)); + const bridge = await commandBridge(socketOf(ready), (request) => + request.method === 'list' ? listing() : { ok: true, result: { kind: 'success', text: 'Compacted.' } }, + ); + ready.session.prompt('/compact'); + await vi.waitFor(() => expect(bridge.requests.some((request) => request.method === 'run')).toBe(true)); + bridge.close(); + }); + + /** A turn the test ends itself: `release` lets the harness answer the + * prompt it is holding. */ + function heldTurn(ready: Harness): { release: () => void } { + const held: Array<() => void> = []; + ready.harness.onPrompt = () => new Promise((resolve) => held.push(() => resolve('end_turn'))); + return { release: () => held.shift()?.() }; + } + const prompts = (ready: Harness) => ready.harness.requests.filter((request) => request.method === 'session/prompt'); + + it('steers a message sent while a turn runs into that turn', async () => { + const ready = withDriver(); + const turn = heldTurn(ready); + await started(ready); + const bridge = await commandBridge(socketOf(ready), (request) => + request.method === 'steer' ? { ok: true, steered: true } : { ok: false }, + ); + ready.session.prompt('build the parser'); + await vi.waitFor(() => expect(prompts(ready)).toHaveLength(1)); + ready.session.prompt('use a recursive descent one'); + await vi.waitFor(() => + expect(bridge.requests.find((request) => request.method === 'steer')).toMatchObject({ + sessionId: 's1', + text: 'use a recursive descent one', + }), + ); + turn.release(); + // The model read it inside the turn: ACP was never asked for a second + // prompt, which it would have refused while the first was in flight. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(prompts(ready)).toHaveLength(1); + bridge.close(); + }); + + it('prompts a message the running turn could not take once that turn ends', async () => { + const ready = withDriver(); + const turn = heldTurn(ready); + await started(ready); + const bridge = await commandBridge(socketOf(ready), (request) => + request.method === 'steer' ? { ok: true, steered: false } : { ok: false }, + ); + ready.session.prompt('build the parser'); + await vi.waitFor(() => expect(prompts(ready)).toHaveLength(1)); + ready.session.prompt('then the printer'); + await vi.waitFor(() => expect(bridge.requests.some((request) => request.method === 'steer')).toBe(true)); + expect(prompts(ready)).toHaveLength(1); + turn.release(); + await vi.waitFor(() => expect(prompts(ready)).toHaveLength(2)); + expect(prompts(ready)[1]).toMatchObject({ params: { prompt: [{ type: 'text', text: 'then the printer' }] } }); + turn.release(); + bridge.close(); + }); + + it('runs the session normally when nothing answers on the command socket', async () => { + const ready = withDriver(); + const ctx = await started(ready); + expect(await ready.session.listCommands?.()).toEqual([]); + ready.session.prompt('/compact'); + await vi.waitFor(() => + expect(ready.harness.requests.some((request) => request.method === 'session/prompt')).toBe(true), + ); + expect(ctx.entries().some((entry) => entry.entryType === 'error')).toBe(false); + }); +}); + +describe('the questions the model asks', () => { + /** A question as the plugin pushes it: a marker line on the harness's + * stderr, which is the one stream ACP does not own. */ + const pushed = (callId: string): string => + `${QUESTION_MARKER}${JSON.stringify({ + sessionId: 's1', + callId, + questions: [{ id: 'q1', question: 'Which database?', options: [{ label: 'SQLite' }, { label: 'Postgres' }] }], + })}\n`; + + async function bridge(socket: string): Promise<{ requests: Array>; close: () => void }> { + const requests: Array> = []; + const server = createServer((connection: Socket) => { + connection.setEncoding('utf8'); + let buffered = ''; + connection.on('data', (chunk: string) => { + buffered += chunk; + const end = buffered.indexOf('\n'); + if (end < 0) return; + const request = JSON.parse(buffered.slice(0, end)) as Record; + requests.push(request); + connection.write(`${JSON.stringify({ id: request.id, ok: true })}\n`); + }); + connection.on('error', () => {}); + }); + mkdirSync(path.dirname(socket), { recursive: true }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socket, resolve); + }); + return { requests, close: () => server.close() }; + } + + it('shows it on the phone and answers the harness with what it chose', async () => { + const ready = withDriver(); + const ctx = recordingContext({ + question: (requestId, questions) => { + expect(requestId).toBe('c9'); + expect(questions).toEqual([ + { question: 'Which database?', options: [{ label: 'SQLite' }, { label: 'Postgres' }] }, + ]); + return { outcome: 'answered', answers: ['SQLite'] }; + }, + }); + await started(ready, {}, ctx); + const socket = await bridge(socketOf(ready)); + ready.harness.child.stderr.write(pushed('c9')); + await vi.waitFor(() => expect(socket.requests.some((request) => request.method === 'answer')).toBe(true)); + expect(socket.requests.find((request) => request.method === 'answer')).toMatchObject({ + sessionId: 's1', + callId: 'c9', + answer: [{ id: 'q1', selected: ['SQLite'] }], + }); + socket.close(); + }); + + it('answers with nothing when the user does not, so the model is told', async () => { + const ready = withDriver(); + const ctx = recordingContext({ question: () => ({ outcome: 'cancelled', reason: 'the phone went away' }) }); + await started(ready, {}, ctx); + const socket = await bridge(socketOf(ready)); + ready.harness.child.stderr.write(pushed('c9')); + await vi.waitFor(() => expect(socket.requests.some((request) => request.method === 'answer')).toBe(true)); + const answer = socket.requests.find((request) => request.method === 'answer')!; + expect(answer).toMatchObject({ sessionId: 's1', callId: 'c9' }); + expect(answer.answer).toBeUndefined(); + socket.close(); + }); + + /** A plan review as the plugin pushes it: `exit_plan_mode` asks through the + * same service as the question tool, with the plan as one question's + * detail and the verdicts as its options. */ + const pushedPlan = (callId: string): string => + `${QUESTION_MARKER}${JSON.stringify({ + sessionId: 's1', + callId, + questions: [ + { + id: 'plan-review', + header: 'Plan review', + question: 'Approve this plan and leave plan mode?', + detail: '# Ship the harness\n\nDo the thing.', + options: [{ label: 'Approve', description: 'Leave plan mode.' }, { label: 'Keep planning' }], + intent: { kind: 'plan-review', approve: 'Approve' }, + }, + ], + })}\n`; + + it('shows a plan review as the plan plus an approval, and sends the verdict', async () => { + const ready = withDriver(); + const ctx = recordingContext({ + plan: (requestId, options) => { + expect(requestId).toBe('c9'); + // The harness's own labels are the choices: they are what the tool + // reads its verdict from. + expect(options).toEqual([ + { id: 'Approve', label: 'Approve', description: 'Leave plan mode.' }, + { id: 'Keep planning', label: 'Keep planning' }, + ]); + return { outcome: 'selected', optionId: 'Approve' }; + }, + }); + await started(ready, {}, ctx); + const socket = await bridge(socketOf(ready)); + ready.harness.child.stderr.write(pushedPlan('c9')); + await vi.waitFor(() => expect(socket.requests.some((request) => request.method === 'answer')).toBe(true)); + // The plan is a plan of its own, the way Claude Code's plan review shows + // it — not a question card with a plan in its body. + expect(ctx.entries().some((entry) => entry.entryType === 'plan' && entry.text === '# Ship the harness\n\nDo the thing.')).toBe(true); + expect(ctx.questions).toEqual([]); + expect(socket.requests.find((request) => request.method === 'answer')).toMatchObject({ + sessionId: 's1', + callId: 'c9', + answer: [{ id: 'plan-review', selected: ['Approve'] }], + }); + socket.close(); + }); + + it('sends feedback with the plan back to the harness, as the answer its tool revises with', async () => { + const ready = withDriver(); + const ctx = recordingContext({ + plan: (_requestId, _options, revise) => { + // Anything but the approval the intent declares keeps planning. + expect(revise).toBe('Keep planning'); + return { outcome: 'selected', optionId: 'Keep planning', feedback: ' Ship it in two steps. ' }; + }, + }); + await started(ready, {}, ctx); + const socket = await bridge(socketOf(ready)); + ready.harness.child.stderr.write(pushedPlan('c9')); + await vi.waitFor(() => expect(socket.requests.some((request) => request.method === 'answer')).toBe(true)); + expect(socket.requests.find((request) => request.method === 'answer')).toMatchObject({ + callId: 'c9', + answer: [{ id: 'plan-review', selected: ['Keep planning'], custom: 'Ship it in two steps.' }], + }); + socket.close(); + }); + + it('leaves a plan the user did not approve unanswered, so the tool says so', async () => { + const ready = withDriver(); + const ctx = recordingContext({ plan: () => ({ outcome: 'cancelled', reason: 'the phone went away' }) }); + await started(ready, {}, ctx); + const socket = await bridge(socketOf(ready)); + ready.harness.child.stderr.write(pushedPlan('c9')); + await vi.waitFor(() => expect(socket.requests.some((request) => request.method === 'answer')).toBe(true)); + const answer = socket.requests.find((request) => request.method === 'answer')!; + expect(answer.answer).toBeUndefined(); + socket.close(); + }); + + it('keeps a pushed question out of the harness log', async () => { + const ready = withDriver(); + const ctx = recordingContext({ question: () => ({ outcome: 'cancelled', reason: 'never mind' }) }); + await started(ready, {}, ctx); + const socket = await bridge(socketOf(ready)); + ready.harness.child.stderr.write(pushed('c9')); + await vi.waitFor(() => expect(socket.requests.length).toBeGreaterThan(0)); + expect(ready.logs.some((line) => line.includes(QUESTION_MARKER))).toBe(false); + socket.close(); + }); +}); + +describe('the harness process', () => { + it('spawns the installed CLI with the profile and the harness home', async () => { + const ready = withDriver(); + await started(ready); + expect(ready.spawns[0]?.args).toEqual(['/fake/dsh/lib/bin.js', '--profile', 'acp']); + expect(ready.spawns[0]?.env.DSH_HOME).toBe(ready.home); + }); + + it('gives every process a socket of its own', async () => { + // Two environments, two processes — a stored GitHub token is enough to + // tell a session's environment from the model probe's. Sharing one path, + // the second plugin would take the first one's socket file, and the first + // to close would unlink the other's, leaving it unreachable. + const ready = withDriver(); + await started(ready); + await started(ready, { sessionId: 'b2', env: { GITHUB_TOKEN: 'ghp_x' } }); + expect(ready.spawns).toHaveLength(2); + const [first, second] = ready.spawns.map((spawn) => spawn.env[BRIDGE_SOCKET_ENV]); + expect(first).toBeDefined(); + expect(second).toBeDefined(); + expect(first).not.toBe(second); + // Under the harness's home — or, on Windows, a named pipe. + if (process.platform === 'win32') expect(first!.startsWith('\\\\.\\pipe\\')).toBe(true); + else expect(path.dirname(first!)).toBe(path.join(ready.home, 'codedeck')); + }); + + it('runs a plugin command in the sessions\' home, and puts its own plugin back after it', async () => { + const ready = withDriver(); + const pluginDir = path.join(ready.home, 'profiles', 'acp', 'node_modules', 'codedeck-dsh-bridge'); + const runs: Array> = []; + const driver = DeepSeekDriver.create({ + runtime: ready.runtime, + home: ready.home, + baseEnv: {} as NodeJS.ProcessEnv, + spawnFn: ((_command: string, _args: string[], opts: { env: Record }) => { + runs.push(opts.env); + // What an install may do to a package no lockfile lists. + rmSync(pluginDir, { recursive: true, force: true }); + const child = Object.assign(new EventEmitter(), { stdout: new EventEmitter(), stderr: new EventEmitter(), kill: () => true }); + setImmediate(() => child.emit('close', 0)); + return child; + }) as never, + log: () => {}, + }); + await vi.waitFor(() => expect(existsSync(path.join(pluginDir, 'index.js'))).toBe(true)); + await driver.plugins.act('install', 'demo-plugin'); + expect(runs[0]?.DSH_HOME).toBe(ready.home); + expect(existsSync(path.join(pluginDir, 'index.js'))).toBe(true); + }); + + it('runs an operator-provided entry point instead of installing one', async () => { + const home = harnessHome(); + const dshPath = path.join(home, 'my-dsh.js'); + writeFileSync(dshPath, '// a standalone harness CLI\n'); + const ready = withDriver({ dshPath }); + await started(ready); + expect(ready.spawns[0]?.args).toEqual([dshPath, '--profile', 'acp']); + }); + + it('refuses a path that is not a file', async () => { + const ready = withDriver({ dshPath: path.join(harnessHome(), 'missing.js') }); + const ctx = recordingContext(); + ready.driver.startSession(start(), ctx); + expect((await ctx.ended()).error).toMatch(/which is not a file/); + }); +}); + +describe('dshHomeDir', () => { + it('prefers the operator override and otherwise follows the agent cache', () => { + expect(dshHomeDir({ CODEDECK_DEEPSEEK_HOME: '/srv/dsh' } as NodeJS.ProcessEnv)).toBe('/srv/dsh'); + expect(dshHomeDir({ CODEDECK_AGENT_CACHE: path.join('/srv/home', 'agents') } as NodeJS.ProcessEnv)).toBe( + path.join('/srv/home', 'dsh'), + ); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts new file mode 100644 index 00000000..bb044d5a --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekEnv.test.ts @@ -0,0 +1,140 @@ +/** + * The environment one harness process runs in, for the native route and for a + * custom provider (a gateway), in the style of claudeEnv.test.ts. + */ +import { describe, expect, it } from 'vitest'; +import { PROVIDER_BASE_URL_ERROR } from '../../../provider'; +import type { ProviderBinding } from '../../../types'; +import { + DEEPSEEK_API_KEY_CREDENTIAL, + buildDeepSeekEnv, + sanitizeDeepSeekBaseEnv, + takeDeepSeekEnv, +} from '../env'; + +const BASE: Record = { + PATH: '/usr/bin', + HOME: '/home/bridge', + DSH_HOME: '/data/dsh', + GITHUB_TOKEN: 'gh', + DEEPSEEK_BASE_URL: 'https://relay.example/anthropic', + DEEPSEEK_API_KEY: 'native-key', +}; + +const provider = (overrides: Partial = {}): ProviderBinding => ({ + id: 'gateway', + baseUrl: 'https://gateway.example/v1', + authToken: 'sk-gateway', + models: [], + ...overrides, +}); + +describe('buildDeepSeekEnv', () => { + it('leaves the operator environment alone when nothing is stored', () => { + const env = buildDeepSeekEnv({}, BASE); + expect(env.DEEPSEEK_API_KEY).toBe('native-key'); + expect(env.DEEPSEEK_BASE_URL).toBe('https://relay.example/anthropic'); + expect(env.PATH).toBe('/usr/bin'); + }); + + it('adds the stored credential when the operator set none', () => { + const env = buildDeepSeekEnv({ credentials: { [DEEPSEEK_API_KEY_CREDENTIAL]: 'sk-stored' } }, { PATH: '/usr/bin' }); + expect(env.DEEPSEEK_API_KEY).toBe('sk-stored'); + }); + + it('lets the operator’s own key win over a stored one', () => { + const env = buildDeepSeekEnv({ credentials: { [DEEPSEEK_API_KEY_CREDENTIAL]: 'sk-stored' } }, BASE); + expect(env.DEEPSEEK_API_KEY).toBe('native-key'); + }); + + it('merges the bridge-level environment last', () => { + const env = buildDeepSeekEnv( + { credentials: { [DEEPSEEK_API_KEY_CREDENTIAL]: 'sk-stored' }, env: { GITHUB_TOKEN: 'gh-session', DEEPSEEK_API_KEY: 'sk-env' } }, + BASE, + ); + expect(env.GITHUB_TOKEN).toBe('gh-session'); + expect(env.DEEPSEEK_API_KEY).toBe('sk-env'); + }); + + it('points a provider-bound session at the provider, with its own token', () => { + const env = buildDeepSeekEnv({ provider: provider() }, BASE); + expect(env.DEEPSEEK_BASE_URL).toBe('https://gateway.example/v1'); + expect(env.DEEPSEEK_API_KEY).toBe('sk-gateway'); + }); + + it('leaves nothing of the harness’s own endpoint or key namespace behind', () => { + const env = buildDeepSeekEnv({ provider: provider() }, BASE); + // The operator's relay must not survive: the session is bound to the + // provider profile, and routing it anywhere else would bill the wrong + // account. + expect(env.DEEPSEEK_BASE_URL).toBe('https://gateway.example/v1'); + expect(Object.keys(env).filter((key) => key.startsWith('DEEPSEEK_'))).toEqual(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY']); + // Everything else — including where the harness keeps its state — is + // untouched: a session that cannot find its home is not more secure. + expect(env.DSH_HOME).toBe('/data/dsh'); + expect(env.GITHUB_TOKEN).toBe('gh'); + expect(env.PATH).toBe('/usr/bin'); + }); + + it('merges the bridge-level environment into a provider session too', () => { + const env = buildDeepSeekEnv({ provider: provider(), env: { GITHUB_TOKEN: 'gh-session' } }, BASE); + expect(env.GITHUB_TOKEN).toBe('gh-session'); + }); + + it('refuses an insecure provider base URL', () => { + expect(() => buildDeepSeekEnv({ provider: provider({ baseUrl: 'http://gateway.example/v1' }) }, BASE)).toThrow( + PROVIDER_BASE_URL_ERROR, + ); + expect(() => buildDeepSeekEnv({ provider: provider({ baseUrl: 'ftp://gateway.example' }) }, BASE)).toThrow(PROVIDER_BASE_URL_ERROR); + expect(() => buildDeepSeekEnv({ provider: provider({ baseUrl: 'not a url' }) }, BASE)).toThrow(PROVIDER_BASE_URL_ERROR); + }); + + it('allows cleartext to a local model server', () => { + const env = buildDeepSeekEnv({ provider: provider({ baseUrl: 'http://127.0.0.1:11434/v1' }) }, BASE); + expect(env.DEEPSEEK_BASE_URL).toBe('http://127.0.0.1:11434/v1'); + }); + + it('refuses a provider profile with no token', () => { + expect(() => buildDeepSeekEnv({ provider: provider({ authToken: '' }) }, BASE)).toThrow(/has no stored auth token/); + }); +}); + +describe('sanitizeDeepSeekBaseEnv', () => { + it('drops the harness namespace, in any case, and keeps the rest', () => { + expect( + sanitizeDeepSeekBaseEnv({ + PATH: '/usr/bin', + DSH_HOME: '/data/dsh', + GITHUB_TOKEN: 'gh', + DEEPSEEK_API_KEY: 'k', + DEEPSEEK_BASE_URL: 'https://relay.example', + deepseek_anything_else: 'future', + }), + ).toEqual({ PATH: '/usr/bin', DSH_HOME: '/data/dsh', GITHUB_TOKEN: 'gh' }); + }); + + it('skips variables an operator left unset', () => { + expect(sanitizeDeepSeekBaseEnv({ PATH: undefined, HOME: '/home' })).toEqual({ HOME: '/home' }); + }); +}); + +describe('takeDeepSeekEnv', () => { + it('keeps the harness settings for its driver and out of every other agent', () => { + const host = { + PATH: '/bin', + DEEPSEEK_API_KEY: 'sk-ds', + DEEPSEEK_BASE_URL: 'https://gateway.example', + CODEDECK_DEEPSEEK_HOME: '/data/dsh', + } as NodeJS.ProcessEnv; + const harness = takeDeepSeekEnv(host); + expect(harness).toEqual({ + PATH: '/bin', + DEEPSEEK_API_KEY: 'sk-ds', + DEEPSEEK_BASE_URL: 'https://gateway.example', + CODEDECK_DEEPSEEK_HOME: '/data/dsh', + }); + // What the other agents' processes inherit: the host's own settings for + // the harness stay, the harness's endpoint and key do not. + expect(host).toEqual({ PATH: '/bin', CODEDECK_DEEPSEEK_HOME: '/data/dsh' }); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts new file mode 100644 index 00000000..d82ab03e --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekGateway.test.ts @@ -0,0 +1,135 @@ +/** + * A gateway the harness is pointed at: reading the models it serves, and + * writing them into the harness's own profile as its catalog — beside the + * block the MCP list owns, since both live in that one file. + */ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { DeepSeekMcp } from '../mcp'; +import { fetchGatewayCatalog, gatewayModelsUrl, parseModels, renderCatalogLayer, syncGatewayCatalog } from '../gateway'; + +function profile(initial = '# Your patch layer for this dsh profile.\n[]\n'): string { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-gateway-')); + writeFileSync(path.join(dir, 'cordis.patch.yml'), initial); + return dir; +} + +const layerOf = (dir: string): string => readFileSync(path.join(dir, 'cordis.patch.yml'), 'utf8'); + +/** A gateway answering with `body`. */ +const answering = (body: unknown, status = 200, expectUrl?: string, expectKey?: string) => + vi.fn(async (url: string, headers: Record) => { + if (expectUrl !== undefined) expect(url).toBe(expectUrl); + if (expectKey !== undefined) expect(headers.authorization).toBe(`Bearer ${expectKey}`); + return { status, text: typeof body === 'string' ? body : JSON.stringify(body) }; + }); + +describe('the model list a gateway serves', () => { + it('is read from the root the harness itself posts to', async () => { + expect(gatewayModelsUrl('http://gateway.example:3458')).toBe('http://gateway.example:3458/v1/models'); + expect(gatewayModelsUrl('http://gateway.example:3458/')).toBe('http://gateway.example:3458/v1/models'); + // The harness appends `/v1` unless the path already ends in it, and this + // mirrors that rule, so one setting configures both. + expect(gatewayModelsUrl('https://gateway.example/v1')).toBe('https://gateway.example/v1/models'); + }); + + it('reads the shapes gateways answer with, and keeps what says something', () => { + expect(parseModels({ data: [{ id: 'kimi-k2' }, { id: 'glm-4.6', context_length: 200_000 }] })).toEqual([ + { id: 'kimi-k2' }, + { id: 'glm-4.6', contextWindow: 200_000 }, + ]); + expect(parseModels(['a', 'b'])).toEqual([{ id: 'a' }, { id: 'b' }]); + expect(parseModels({ models: [{ id: 'x', name: 'X' }] })).toEqual([{ id: 'x', name: 'X' }]); + // Duplicates, blanks and anything that is not a model are dropped. + expect(parseModels({ data: [{ id: 'a' }, { id: 'a' }, { id: ' ' }, { no: 'id' }, 7] })).toEqual([{ id: 'a' }]); + expect(parseModels({ error: 'nope' })).toEqual([]); + expect(parseModels('not json at all')).toEqual([]); + }); + + it('is asked with the key, and answered with the endpoint as the harness reads it', async () => { + const httpGet = answering({ data: [{ id: 'kimi-k2' }] }, 200, 'http://gw.example/v1/models', 'sk-1'); + const catalog = await fetchGatewayCatalog('http://gw.example/', 'sk-1', httpGet, () => {}); + expect(catalog).toEqual({ baseUrl: 'http://gw.example', models: [{ id: 'kimi-k2' }], defaultModel: 'kimi-k2' }); + }); + + it('answers nothing — and says so — when the gateway refuses, breaks or lists nothing', async () => { + const logs: string[] = []; + const log = (line: string): void => { + logs.push(line); + }; + expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering({}, 401), log)).toBeUndefined(); + expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering('', 200), log)).toBeUndefined(); + expect(await fetchGatewayCatalog('http://gw.example', 'sk', answering({ data: [] }, 200), log)).toBeUndefined(); + expect( + await fetchGatewayCatalog('http://gw.example', 'sk', async () => { + throw new Error('ECONNREFUSED'); + }, log), + ).toBeUndefined(); + expect(logs.some((line) => /answered 401/.test(line))).toBe(true); + expect(logs.some((line) => /listed no models/.test(line))).toBe(true); + expect(logs.some((line) => /ECONNREFUSED/.test(line))).toBe(true); + }); +}); + +describe('the catalog the harness reads', () => { + it('is a row over the deployment entry, carrying the models and never the endpoint', () => { + const rows = renderCatalogLayer({ + baseUrl: 'http://gw.example', + models: [{ id: 'kimi-k2' }, { id: 'glm-4.6', contextWindow: 200_000 }], + defaultModel: 'kimi-k2', + }); + expect(rows).toMatch(/^- id: llm-deepseek/m); + // An endpoint in the shared profile would outrank the one a provider-bound + // session's own environment names, and take that profile's token to the + // operator's gateway. + expect(rows).not.toMatch(/baseURL/); + expect(rows).not.toMatch(/gw\.example/); + expect(rows).toMatch(/id: kimi-k2/); + expect(rows).toMatch(/contextWindow: 200000/); + // And the model a session starts on moves with the catalog: the harness + // always offers the model it is on, so a default it does not serve would + // show up as one nobody can run. + expect(rows).toMatch(/^- id: acp$/m); + expect(rows).toMatch(/^- id: agent-default-model/m); + expect(rows).toMatch(/provider: deepseek-official/); + expect(rows).toMatch(/model: kimi-k2/); + }); + + it('is written beside the MCP block without disturbing it, and taken back out again', async () => { + const dir = profile(); + await new DeepSeekMcp({ profileDir: dir, log: () => {} }).act('add', [{ name: 'demo', setup: { type: 'stdio', command: '/usr/bin/demo' } }], []); + await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet: answering({ data: [{ id: 'kimi-k2' }] }) }, 'http://gw.example', 'sk-1'); + const both = layerOf(dir); + expect(both).toMatch(/CodeDeck\+ MCP servers/); + expect(both).toMatch(/serverName: demo/); + expect(both).toMatch(/CodeDeck\+ gateway catalog/); + expect(both).toMatch(/id: kimi-k2/); + + // The MCP list still reads exactly what it wrote. + expect((await new DeepSeekMcp({ profileDir: dir, log: () => {} }).list()).servers).toEqual([ + { name: 'demo', transport: 'stdio', target: '/usr/bin/demo', enabled: true }, + ]); + + // No gateway any more: its block goes, the MCP one stays. + await syncGatewayCatalog({ profileDir: dir, log: () => {} }, undefined, undefined); + const after = layerOf(dir); + expect(after).not.toMatch(/gateway catalog/); + expect(after).toMatch(/serverName: demo/); + }); + + it('stays as it was when the gateway cannot be read', async () => { + const dir = profile(); + const wrote = await syncGatewayCatalog({ profileDir: dir, log: () => {} }, 'http://gw.example', 'sk-1'); + expect(wrote).toBe(false); + expect(layerOf(dir)).toBe('# Your patch layer for this dsh profile.\n[]\n'); + }); + + it('is written only when it changed', async () => { + const dir = profile(); + const httpGet = answering({ data: [{ id: 'kimi-k2' }] }); + expect(await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(true); + expect(await syncGatewayCatalog({ profileDir: dir, log: () => {}, httpGet }, 'http://gw.example', 'sk-1')).toBe(false); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts new file mode 100644 index 00000000..918aae41 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekMcp.test.ts @@ -0,0 +1,196 @@ +/** + * The harness's MCP servers: the managed block this driver keeps in a + * profile's own patch layer — the file the harness reads after every bundle. + * Everything around the block (comments, a user's rows) is read, kept and + * written back untouched, which is what most of these cases are about. + */ +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import type { McpServerAdd } from '../../../types'; +import { DeepSeekMcp } from '../mcp'; + +const INITIAL_LAYER = `# Your patch layer for this dsh profile, applied after every bundle layer. +[]`; + +function profile(initial: string = INITIAL_LAYER): string { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-mcp-')); + writeFileSync(path.join(dir, 'cordis.patch.yml'), initial); + return dir; +} + +const layerOf = (dir: string): string => readFileSync(path.join(dir, 'cordis.patch.yml'), 'utf8'); +const manager = (dir: string): DeepSeekMcp => new DeepSeekMcp({ profileDir: dir, log: () => {} }); + +const stdio = (name: string, command = '/usr/bin/tool', env?: Record): McpServerAdd => ({ + name, + setup: { type: 'stdio', command, ...(env ? { env } : {}), args: ['--serve'] }, +}); + +describe('listing', () => { + it('answers nothing for a profile without a patch layer', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-mcp-')); + expect(await manager(dir).list()).toEqual({ servers: [], toggles: true }); + }); + + it('answers nothing for the layer the harness writes itself', async () => { + expect(await manager(profile()).list()).toEqual({ servers: [], toggles: true }); + }); +}); + +describe('adding', () => { + it('writes a managed block and reads it back without a secret value', async () => { + const dir = profile(); + const state = await manager(dir).act('add', [stdio('demo', '/usr/bin/tool', { API_KEY: 'secret' })], []); + expect(state.servers).toEqual([ + { name: 'demo', transport: 'stdio', target: '/usr/bin/tool', envKeys: ['API_KEY'], enabled: true }, + ]); + const file = layerOf(dir); + expect(file).toMatch(/codedeck-mcp-demo/); + expect(file).toMatch(/@deepseek-ai\/dsh-mcp-client/); + // The layer keeps its own header; the harness's initial empty list is + // gone (an empty sequence and a block sequence cannot share a document). + expect(file.startsWith('# Your patch layer')).toBe(true); + expect(file).not.toMatch(/^\[\]$/m); + // The value is in the file — the harness needs it — but never in what the + // phone is shown. + expect(file).toMatch(/secret/); + }); + + it('keeps an HTTP server’s URL in the layer and its redacted form on the wire', async () => { + const dir = profile(); + const state = await manager(dir).act( + 'add', + [{ name: 'remote', setup: { type: 'http', url: 'https://user:pw@mcp.example/mcp?key=q', headers: { Authorization: 'Bearer t' } } }], + [], + ); + expect(state.servers).toEqual([ + { name: 'remote', transport: 'http', target: 'https://mcp.example/mcp', headerKeys: ['Authorization'], enabled: true }, + ]); + expect(layerOf(dir)).toMatch(/transport: streamable-http/); + }); + + it('replaces a server it already has, keeping the switch the user set', async () => { + const dir = profile(); + const mcp = manager(dir); + await mcp.act('add', [stdio('demo', '/usr/bin/one')], []); + await mcp.act('disable', [], ['demo']); + const state = await mcp.act('add', [stdio('demo', '/usr/bin/two')], []); + expect(state.servers).toEqual([{ name: 'demo', transport: 'stdio', target: '/usr/bin/two', enabled: false }]); + }); + + it('refuses a name the harness cannot namespace', async () => { + const dir = profile(); + await expect(manager(dir).act('add', [stdio('my server!')], [])).rejects.toThrow(/not a usable MCP server name/); + await expect(manager(dir).act('add', [stdio('x'.repeat(33))], [])).rejects.toThrow(/not a usable MCP server name/); + }); + + it('refuses the SSE transport the harness does not speak', async () => { + const dir = profile(); + await expect(manager(dir).act('add', [{ name: 'old', setup: { type: 'sse', url: 'https://x/mcp' } }], [])).rejects.toThrow( + /stdio or streamable HTTP, not SSE/, + ); + }); +}); + +describe('switching', () => { + it('switches a server off and on without removing it', async () => { + const dir = profile(); + const mcp = manager(dir); + await mcp.act('add', [stdio('demo')], []); + expect((await mcp.act('disable', [], ['demo'])).servers).toEqual([ + { name: 'demo', transport: 'stdio', target: '/usr/bin/tool', enabled: false }, + ]); + // The row stays, with a disable row after it — the layer's own way of + // switching a row off. + expect(layerOf(dir)).toMatch(/disabled: true/); + expect((await mcp.act('enable', [], ['demo'])).servers).toEqual([ + { name: 'demo', transport: 'stdio', target: '/usr/bin/tool', enabled: true }, + ]); + expect(layerOf(dir)).not.toMatch(/disabled: true/); + }); + + it('counts a change so a session can tell what its harness has loaded', async () => { + // Nothing here restarts a running harness: the file is read as it starts, + // which is what a session's MCP status reports as pending. + const dir = profile(); + const mcp = manager(dir); + expect(mcp.version).toBe(0); + await mcp.act('add', [stdio('demo')], []); + expect(mcp.version).toBe(1); + // A write that changes nothing is not a change. + await mcp.act('add', [stdio('demo')], []); + expect(mcp.version).toBe(1); + await mcp.act('remove', [], ['demo']); + expect(mcp.version).toBe(2); + }); + + it('refuses to switch a server it does not have', async () => { + const dir = profile(); + await expect(manager(dir).act('disable', [], ['ghost'])).rejects.toThrow(/has no MCP server named 'ghost'/); + await expect(manager(dir).act('remove', [], ['ghost'])).rejects.toThrow(/has no MCP server named 'ghost'/); + }); +}); + +describe('removing', () => { + it('takes the server out and leaves the layer as it was', async () => { + const dir = profile(); + const mcp = manager(dir); + await mcp.act('add', [stdio('demo')], []); + expect(await mcp.act('remove', [], ['demo'])).toEqual({ servers: [], toggles: true }); + expect(layerOf(dir)).toBe(`${INITIAL_LAYER}\n`); + }); + + it('keeps everything outside the managed block', async () => { + const mine = `# Your patch layer for this dsh profile. +- insert: + - id: my-own-server + name: '@deepseek-ai/dsh-mcp-client' + config: + serverName: mine + transport: stdio + command: /usr/bin/mine + +# a trailing note`; + const dir = profile(mine); + const mcp = manager(dir); + await mcp.act('add', [stdio('demo')], []); + expect(layerOf(dir).trimEnd().startsWith(mine)).toBe(true); + + // Adding a second one rewrites only the block, so the user's own rows are + // still exactly where they were. + await mcp.act('add', [stdio('other')], []); + expect(layerOf(dir).trimEnd().startsWith(mine)).toBe(true); + expect(await mcp.list()).toEqual({ + servers: [ + { name: 'demo', transport: 'stdio', target: '/usr/bin/tool', enabled: true }, + { name: 'other', transport: 'stdio', target: '/usr/bin/tool', enabled: true }, + ], + toggles: true, + }); + + // Removing ours leaves the user's layer alone again. + await mcp.act('remove', [], ['demo', 'other']); + expect(layerOf(dir).trimEnd()).toBe(mine); + }); +}); + +describe('a layer this bridge cannot read', () => { + it('says so instead of losing the servers quietly', async () => { + const dir = profile( + `# --- CodeDeck+ MCP servers: managed from the MCP screen; everything outside this block is yours ---\n: not: yaml: [\n# --- end CodeDeck+ MCP servers ---`, + ); + await expect(manager(dir).list()).rejects.toThrow(/not valid YAML/); + }); +}); + +describe('the profile layer', () => { + it('is created when the profile has none yet', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-mcp-')); + mkdirSync(path.join(dir, 'profiles'), { recursive: true }); + const profileDir = path.join(dir, 'profiles', 'acp'); + await manager(profileDir).act('add', [stdio('demo')], []); + expect(readFileSync(path.join(profileDir, 'cordis.patch.yml'), 'utf8')).toMatch(/codedeck-mcp-demo/); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekPlugins.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekPlugins.test.ts new file mode 100644 index 00000000..5cc2fd11 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekPlugins.test.ts @@ -0,0 +1,221 @@ +/** + * The harness's plugins: npm packages installed into a profile and mounted as + * patch layers. The harness's own CLI does the installing (its pnpm, its + * locks, its compatibility gate), so what is faked here is that CLI — and the + * cases are the ones its output decides. + */ +import { EventEmitter } from 'node:events'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import { DeepSeekPlugins, runDshPlugin, type DshRun } from '../plugins'; +import type { SpawnFn } from '../runtime'; + +/** A profile directory: its manifest, and whatever it has installed. */ +function profile( + manifest: Record = { name: 'dsh-profile-acp', private: true, dependencies: {} }, + packages: Record> = {}, +): string { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-plugins-')); + writeFileSync(path.join(dir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`); + for (const [name, pkg] of Object.entries(packages)) { + const target = path.join(dir, 'node_modules', ...name.split('/')); + mkdirSync(target, { recursive: true }); + writeFileSync(path.join(target, 'package.json'), JSON.stringify(pkg)); + } + return dir; +} + +const bundles = (...names: string[]): Record => ({ + dsh: { profile: { bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app', ...names] } }, +}); + +const pluginPackage = (version: string, bundle = true): Record => ({ + name: 'demo-plugin', + version, + ...(bundle ? { dsh: { bundle: {} } } : {}), +}); + +interface Harness { + plugins: DeepSeekPlugins; + runs: string[][]; + manifest(): { dependencies?: Record; dsh?: { profile?: { bundles?: string[] } } }; +} + +/** A plugin manager whose CLI answers are scripted by `behaviour`. */ +function withPlugins( + dir: string, + behaviour: (args: string[]) => DshRun | Promise = () => ({ code: 0, stdout: '', stderr: '' }), +): Harness { + const runs: string[][] = []; + const plugins = new DeepSeekPlugins({ + profileDir: dir, + run: async (args) => { + runs.push(args); + return behaviour(args); + }, + log: () => {}, + }); + return { plugins, runs, manifest: () => JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')) }; +} + +describe('listing', () => { + it('answers nothing for a profile nobody has touched', async () => { + const dir = mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-plugins-')); + expect(await withPlugins(dir).plugins.list(false)).toEqual({ installed: [], toggles: true }); + }); + + it('shows what is installed, and which of it is a layer', async () => { + const dir = profile( + { ...bundles('demo-plugin'), dependencies: { 'demo-plugin': '1.2.3', 'plain-dep': '2.0.0' } }, + { 'demo-plugin': pluginPackage('1.2.3'), 'plain-dep': { name: 'plain-dep', version: '2.0.0' } }, + ); + const state = await withPlugins(dir).plugins.list(true); + expect(state.installed).toEqual([ + { id: '@deepseek-ai/dsh-acp-app', name: '@deepseek-ai/dsh-acp-app', version: undefined, enabled: true }, + { id: '@deepseek-ai/dsh-base', name: '@deepseek-ai/dsh-base', version: undefined, enabled: true }, + { id: 'demo-plugin', name: 'demo-plugin', version: '1.2.3', enabled: true }, + { id: 'plain-dep', name: 'plain-dep', version: '2.0.0', enabled: false }, + ]); + // The harness installs plugins from npm; it has no marketplaces to ask. + expect(state.marketplaces).toBeUndefined(); + expect(state.available).toBeUndefined(); + }); +}); + +describe('installing and removing', () => { + it('installs through the harness’s own command and says what it did', async () => { + const dir = profile(); + const harness = withPlugins(dir, () => ({ + code: 0, + stdout: 'Packages: +1\nDone in 1.1s using pnpm v10.8.0\n', + stderr: 'dsh: warning: demo-plugin declares no dsh.bundle — installed as a plain dependency, not a profile layer\n', + })); + const state = await harness.plugins.act('install', 'demo-plugin'); + expect(harness.runs).toEqual([['add', 'demo-plugin']]); + expect(state.message).toMatch(/declares no dsh\.bundle/); + }); + + it('refuses a target that is not a package name, without asking the CLI', async () => { + const dir = profile(); + const harness = withPlugins(dir); + await expect(harness.plugins.act('install', 'not a package; rm -rf /')).rejects.toThrow(/not a package name/); + expect(harness.runs).toEqual([]); + }); + + it('reports what the harness printed when the command fails', async () => { + const dir = profile(); + const harness = withPlugins(dir, () => ({ + code: 1, + stdout: '', + stderr: 'dsh: installation rejected: Plugin demo-plugin@0.0.1 is incompatible with dsh 0.2.0-rc.2\n', + })); + await expect(harness.plugins.act('install', 'demo-plugin')).rejects.toThrow(/incompatible with dsh/); + }); + + it('removes and updates with the harness’s own commands', async () => { + const dir = profile({ ...bundles(), dependencies: { 'demo-plugin': '^1.0.0' } }, { 'demo-plugin': pluginPackage('1.0.0') }); + const harness = withPlugins(dir); + await harness.plugins.act('uninstall', 'demo-plugin'); + await harness.plugins.act('update', 'demo-plugin'); + expect(harness.runs).toEqual([ + ['remove', 'demo-plugin'], + ['update', 'demo-plugin'], + ]); + }); + + it('refuses a plugin the profile does not have', async () => { + const dir = profile(); + const harness = withPlugins(dir); + await expect(harness.plugins.act('uninstall', 'ghost')).rejects.toThrow(/profile has no plugin 'ghost'/); + expect(harness.runs).toEqual([]); + }); + + it('refuses the profile’s own composition', async () => { + const dir = profile(); + const harness = withPlugins(dir); + await expect(harness.plugins.act('uninstall', '@deepseek-ai/dsh-base')).rejects.toThrow(/profile's own composition/); + await expect(harness.plugins.act('install', '@deepseek-ai/dsh-acp-app')).rejects.toThrow(/already installed/); + expect(harness.runs).toEqual([]); + }); + + it('has no marketplaces to act on', async () => { + const dir = profile(); + const harness = withPlugins(dir); + await expect(harness.plugins.act('add-marketplace', 'owner/repo')).rejects.toThrow(/no plugin marketplaces/); + await expect(harness.plugins.act('remove-marketplace', 'acme')).rejects.toThrow(/no plugin marketplaces/); + await expect(harness.plugins.act('update-marketplace', 'acme')).rejects.toThrow(/no plugin marketplaces/); + }); +}); + +describe('switching a plugin off', () => { + it('takes its layer out and leaves it installed', async () => { + const dir = profile({ ...bundles('demo-plugin'), dependencies: { 'demo-plugin': '1.0.0' } }, { 'demo-plugin': pluginPackage('1.0.0') }); + const harness = withPlugins(dir); + const disabled = await harness.plugins.act('disable', 'demo-plugin'); + expect(disabled.installed).toEqual([ + { id: '@deepseek-ai/dsh-acp-app', name: '@deepseek-ai/dsh-acp-app', version: undefined, enabled: true }, + { id: '@deepseek-ai/dsh-base', name: '@deepseek-ai/dsh-base', version: undefined, enabled: true }, + { id: 'demo-plugin', name: 'demo-plugin', version: '1.0.0', enabled: false }, + ]); + expect(harness.manifest().dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app']); + expect(harness.runs).toEqual([]); + + // Switched back on, it goes last: the layers are an ordered stack. + const enabled = await harness.plugins.act('enable', 'demo-plugin'); + expect(enabled.installed.find((plugin) => plugin.name === 'demo-plugin')?.enabled).toBe(true); + expect(harness.manifest().dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app', 'demo-plugin']); + }); + + it('refuses a plugin that is not installed, or is no layer', async () => { + const dir = profile( + { ...bundles(), dependencies: { 'plain-dep': '1.0.0' } }, + { 'plain-dep': { name: 'plain-dep', version: '1.0.0' } }, + ); + const harness = withPlugins(dir); + await expect(harness.plugins.act('enable', 'ghost')).rejects.toThrow(/install it first/); + await expect(harness.plugins.act('enable', 'plain-dep')).rejects.toThrow(/declares no dsh\.bundle/); + }); + + it('refuses to switch off the profile’s own composition', async () => { + const dir = profile(bundles()); + const harness = withPlugins(dir); + await expect(harness.plugins.act('disable', '@deepseek-ai/dsh-base')).rejects.toThrow(/cannot be switched off/); + }); +}); + +describe('runDshPlugin', () => { + class FakeChild extends EventEmitter { + stdout = new EventEmitter(); + stderr = new EventEmitter(); + killed: string[] = []; + kill(signal: string): boolean { + this.killed.push(signal); + return true; + } + } + + it('runs the harness CLI with the profile, the pnpm arguments and the sessions\' home', async () => { + const child = new FakeChild(); + const spawnFn = vi.fn(() => child) as unknown as SpawnFn; + const pending = runDshPlugin('/tree/dsh/lib/bin.js', '/data/dsh', 'acp', ['add', 'demo'], spawnFn); + expect(spawnFn).toHaveBeenCalledWith( + process.execPath, + ['/tree/dsh/lib/bin.js', 'plugin', '--profile', 'acp', 'add', 'demo'], + expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'], env: expect.objectContaining({ DSH_HOME: '/data/dsh' }) }), + ); + child.stdout.emit('data', Buffer.from('installed\n')); + child.stderr.emit('data', Buffer.from('a warning\n')); + child.emit('close', 0); + expect(await pending).toEqual({ code: 0, stdout: 'installed\n', stderr: 'a warning\n' }); + }); + + it('answers a failure the CLI could not even start', async () => { + const child = new FakeChild(); + const spawnFn = vi.fn(() => child) as unknown as SpawnFn; + const pending = runDshPlugin('/tree/dsh/lib/bin.js', '/data/dsh', 'acp', ['add', 'demo'], spawnFn); + child.emit('error', new Error('ENOENT')); + await expect(pending).rejects.toThrow(/ENOENT/); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/deepseekProfileTools.test.ts b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekProfileTools.test.ts new file mode 100644 index 00000000..337520b1 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/deepseekProfileTools.test.ts @@ -0,0 +1,57 @@ +/** + * The rows CodeDeck adds to a harness profile on top of its bundles — the + * question tool, which no automation profile mounts by itself — and how they + * share the profile's patch layer with the bridge plugin's own block. + */ +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { HARNESS_PLUGIN, installHarnessPlugin } from '../plugin'; +import { ASK_USER_TOOL, installProfileTools } from '../profileTools'; + +const fresh = (): string => mkdtempSync(path.join(tmpdir(), 'codedeck-dsh-tools-')); +const layerOf = (dir: string): string => readFileSync(path.join(dir, 'cordis.patch.yml'), 'utf8'); + +describe('the tools a profile needs', () => { + it('mounts the question tool, naming the harness package that provides it', async () => { + const dir = fresh(); + writeFileSync(path.join(dir, 'cordis.patch.yml'), "# a person's own layer\n[]\n"); + await installProfileTools(dir, () => {}); + const layer = layerOf(dir); + expect(layer).toMatch(/- id: tool-ask-user/); + expect(layer).toContain(`name: '${ASK_USER_TOOL}'`); + // The harness's own package: a row naming it resolves from the harness's + // installation, so nothing has to be installed into the profile. + expect(ASK_USER_TOOL).toBe('@deepseek-ai/dsh-tool-ask-user'); + // What the profile already had is untouched. + expect(layer).toMatch(/a person's own layer/); + }); + + it('leaves the layer alone when its block is already right', async () => { + const dir = fresh(); + await installProfileTools(dir, () => {}); + const first = layerOf(dir); + await installProfileTools(dir, () => {}); + expect(layerOf(dir)).toBe(first); + }); + + it('keeps its block and the bridge plugin\'s block apart', async () => { + const dir = fresh(); + // What a start does: both blocks, every time. The second start must find + // the layer exactly as the first left it — blocks replaced where they + // stand, never re-appended, since a file that grows on every start grows + // without end. + const start = async (): Promise => { + await installHarnessPlugin(dir, () => {}); + await installProfileTools(dir, () => {}); + return layerOf(dir); + }; + const first = await start(); + expect(first).toMatch(/- id: codedeck-bridge/); + expect(first).toContain(`name: '${HARNESS_PLUGIN}'`); + expect(first).toMatch(/- id: tool-ask-user/); + expect(await start()).toBe(first); + expect(await start()).toBe(first); + }); +}); diff --git a/packages/agent-host/src/drivers/deepseek/__tests__/fakeHarness.ts b/packages/agent-host/src/drivers/deepseek/__tests__/fakeHarness.ts new file mode 100644 index 00000000..8105f2b1 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/__tests__/fakeHarness.ts @@ -0,0 +1,342 @@ +/** + * A scripted DeepSeek Harness behind the driver's spawn seam: it answers the + * ACP requests a session makes, in the shapes the real runtime sends, so the + * driver can be tested without the CLI, a credential or a network. + * + * The shapes are the harness's own (`@deepseek-ai/dsh-acp`): a session's + * option state, `tool_call` updates carrying the tool name as their title + * with `kind: "other"`, permission asks that name only the tool call, and + * stop reasons rather than a synthetic end. A test sets `onPrompt` to script + * one turn — sending updates and asking for a permission — and everything + * else is answered the way the harness would. + */ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import type { SpawnFn } from '../runtime'; + +/** Minimal fake ChildProcess: the streams the driver talks over, plus the + * exit and kill behaviour the runtime's close ladder needs. */ +export class FakeChild extends EventEmitter { + readonly stdin = new PassThrough(); + readonly stdout = new PassThrough(); + readonly stderr = new PassThrough(); + pid = 4242; + exitCode: number | null = null; + signalCode: string | null = null; + readonly killed: string[] = []; + + kill(signal: string): boolean { + this.killed.push(signal); + this.signalCode = signal; + this.exitCode = 0; + queueMicrotask(() => this.emit('exit', 0, signal)); + return true; + } + + /** The harness process dying on its own (a crash, a plugin error). */ + crash(code = 1): void { + this.exitCode = code; + this.emit('exit', code, null); + } +} + +/** One message the driver sent to the harness. */ +interface Sent { + id?: number | string; + method?: string; + params?: Record; + result?: unknown; + error?: unknown; +} + +/** The option state a session starts with, as the harness composes it. */ +export function configOptions(currentModel = '["deepseek-official","deepseek-v4-flash"]'): unknown[] { + return [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: currentModel, + options: [ + { + group: 'deepseek-official', + name: 'DeepSeek', + options: [ + { value: '["deepseek-official","deepseek-v4-flash"]', name: 'deepseek-v4-flash' }, + { value: '["deepseek-official","deepseek-v4-pro"]', name: 'DeepSeek-V4-Pro' }, + ], + }, + ], + }, + { + id: 'reasoning_effort', + name: 'Reasoning effort', + category: 'thought_level', + type: 'select', + currentValue: 'high', + options: [ + { value: 'off', name: 'Off' }, + { value: 'low', name: 'Low' }, + { value: 'high', name: 'High' }, + { value: 'max', name: 'Max' }, + ], + }, + ]; +} + +export class FakeHarness { + readonly child = new FakeChild(); + /** Requests the driver sent, in order. */ + readonly requests: Array<{ method: string; params: Record }> = []; + /** Notifications the driver sent, in order. */ + readonly notifications: Array<{ method: string; params: Record }> = []; + /** Every `session/new` the driver asked for. */ + readonly newSessions: Array> = []; + readonly resumed: Array> = []; + readonly closed: string[] = []; + readonly setOptions: Array<{ sessionId: string; configId: string; value: string }> = []; + /** The option state session/new and session/resume answer with. */ + options: unknown[] = configOptions(); + /** Make `session/resume` fail the way a missing conversation does. */ + resumeError: string | undefined; + /** Whether the harness accepts the values `set_config_option` is given. */ + acceptsOptions = true; + /** Whether the connection advertises `session/resume` at all. */ + supportsResume = true; + /** Whether `session/new` succeeds. */ + newSessionError: string | undefined; + /** Scripts one turn; the default completes at once. */ + onPrompt: ((sessionId: string, text: string, harness: FakeHarness) => Promise | string) | undefined; + private nextId = 1; + private readonly pending = new Map void>(); + private readonly inbound: Sent[] = []; + private buffer = ''; + + constructor(readonly spawnFn: SpawnFn = (() => this.child) as unknown as SpawnFn) { + this.child.stdin.on('data', (chunk: Buffer) => this.receive(chunk.toString())); + // Nothing reads the harness's own log here; keep the stream drained. + this.child.stderr.resume(); + } + + private receive(text: string): void { + this.buffer += text; + for (;;) { + const end = this.buffer.indexOf('\n'); + if (end < 0) return; + const line = this.buffer.slice(0, end); + this.buffer = this.buffer.slice(end + 1); + if (line.trim() === '') continue; + this.handle(JSON.parse(line) as Sent); + } + } + + private handle(message: Sent): void { + if (message.method === undefined) { + const settle = message.id !== undefined ? this.pending.get(message.id) : undefined; + if (settle && message.id !== undefined) { + this.pending.delete(message.id); + settle(message); + } + return; + } + if (message.id === undefined) { + this.notifications.push({ method: message.method, params: message.params ?? {} }); + if (message.method === 'session/cancel') { + this.resolveTurn(message.params?.sessionId as string, 'cancelled'); + } + return; + } + const params = message.params ?? {}; + this.requests.push({ method: message.method, params }); + void this.answer(message.id, message.method, params); + } + + private async answer(id: number | string, method: string, params: Record): Promise { + switch (method) { + case 'initialize': + this.reply(id, { + protocolVersion: 1, + agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, + agentCapabilities: { + mcpCapabilities: { http: true }, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { + close: {}, + list: {}, + ...(this.supportsResume ? { resume: {} } : {}), + }, + }, + authMethods: [], + }); + return; + case 'session/new': { + if (this.newSessionError !== undefined) { + this.errorReply(id, -32602, `Invalid params: ${this.newSessionError}`); + return; + } + this.newSessions.push(params); + const sessionId = `s${this.newSessions.length}`; + this.reply(id, { sessionId, configOptions: this.options }); + return; + } + case 'session/resume': { + if (this.resumeError !== undefined) { + this.errorReply(id, -32602, `Invalid params: ${this.resumeError}`); + return; + } + this.resumed.push(params); + this.reply(id, { configOptions: this.options }); + return; + } + case 'session/set_config_option': { + const configId = String(params.configId); + const value = String(params.value); + this.setOptions.push({ sessionId: String(params.sessionId), configId, value }); + // The harness answers a change with the complete, updated option + // state — including a current value the change did not accept. + const option = this.options.find( + (entry) => typeof entry === 'object' && entry !== null && (entry as { id?: string }).id === configId, + ) as { options?: Array<{ value?: string; options?: Array<{ value?: string }> }> } | undefined; + // The harness offers a select option's values flat or grouped (its + // models come grouped by provider). + const offered = + option?.options?.some((entry) => + Array.isArray(entry.options) + ? entry.options.some((inner) => inner.value === value) + : entry.value === value, + ) === true; + if (!this.acceptsOptions || !offered) { + this.errorReply(id, -32602, `Invalid params: unknown ${configId} option: ${value}`); + return; + } + this.options = this.options.map((entry) => (entry === option ? { ...(entry as object), currentValue: value } : entry)); + this.reply(id, { configOptions: this.options }); + return; + } + case 'session/close': { + this.closed.push(String(params.sessionId)); + this.reply(id, {}); + return; + } + case 'session/prompt': { + const sessionId = String(params.sessionId); + const text = (params.prompt as Array<{ text?: string }> | undefined)?.[0]?.text ?? ''; + this.turnIds.set(sessionId, id); + let stop = 'end_turn'; + try { + stop = this.onPrompt ? await this.onPrompt(sessionId, text, this) : 'end_turn'; + } catch (error) { + // A turn that failed is a failed request, as the harness reports it. + this.turnIds.delete(sessionId); + this.errorReply(id, -32603, `Internal error: turn failed: ${error instanceof Error ? error.message : String(error)}`); + return; + } + // A scripted turn may have been completed by a cancel already. + if (this.turnIds.get(sessionId) === id) { + this.turnIds.delete(sessionId); + this.reply(id, { stopReason: stop }); + } + return; + } + default: + this.errorReply(id, -32601, `Method not found: ${method}`); + } + } + + private readonly turnIds = new Map(); + + /** Finish the turn running in `sessionId`, as the harness does for a + * cancelled prompt. */ + resolveTurn(sessionId: string, reason: string): void { + const id = this.turnIds.get(sessionId); + if (id === undefined) return; + this.turnIds.delete(sessionId); + this.reply(id, { stopReason: reason }); + } + + // --- messages to the client --- + + send(method: string, params: unknown): void { + this.child.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', method, params })}\n`); + } + + /** One `session/update` notification. */ + update(sessionId: string, update: unknown): void { + this.send('session/update', { sessionId, update }); + } + + /** One agent message chunk, as the harness derives it from a committed + * assistant message. */ + message(sessionId: string, text: string): void { + this.update(sessionId, { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }); + } + + thought(sessionId: string, text: string): void { + this.update(sessionId, { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text } }); + } + + toolCall(sessionId: string, toolCallId: string, name: string, rawInput: unknown = {}): void { + this.update(sessionId, { sessionUpdate: 'tool_call', toolCallId, title: name, kind: 'other', status: 'in_progress', rawInput }); + } + + toolResult(sessionId: string, toolCallId: string, text: string, failed = false): void { + this.update(sessionId, { + sessionUpdate: 'tool_call_update', + toolCallId, + status: failed ? 'failed' : 'completed', + content: text === '' ? [] : [{ type: 'content', content: { type: 'text', text } }], + }); + } + + usage(sessionId: string, used: number, size: number): void { + this.update(sessionId, { sessionUpdate: 'usage_update', used, size }); + } + + /** Ask the client to allow one tool call, as the harness does, and answer + * with what it decided. */ + askPermission( + sessionId: string, + toolCallId: string, + options?: unknown[], + ): Promise<{ outcome: string; optionId?: string } | undefined> { + const id = 9000 + this.nextId++; + const message = new Promise((resolve) => this.pending.set(id, resolve)); + this.child.stdout.write( + `${JSON.stringify({ + jsonrpc: '2.0', + id, + method: 'session/request_permission', + params: { + sessionId, + toolCall: { toolCallId }, + options: + options ?? + [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + }, + })}\n`, + ); + return message.then((answer) => { + const outcome = ( + answer.result as { outcome?: { outcome?: string; optionId?: string } } | undefined + )?.outcome; + return outcome === undefined ? undefined : { outcome: outcome.outcome ?? '', ...(outcome.optionId ? { optionId: outcome.optionId } : {}) }; + }); + } + + /** Erase stdout/stderr support for a line that is not JSON-RPC at all. */ + raw(line: string): void { + this.child.stdout.write(line); + } + + private reply(id: number | string, result: unknown): void { + this.child.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); + } + + private errorReply(id: number | string, code: number, message: string): void { + this.child.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id, error: { code, message } })}\n`); + } +} diff --git a/packages/agent-host/src/drivers/deepseek/acp.ts b/packages/agent-host/src/drivers/deepseek/acp.ts new file mode 100644 index 00000000..e94d7bac --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/acp.ts @@ -0,0 +1,270 @@ +/** + * The Agent Client Protocol side of the DeepSeek Harness driver: JSON-RPC 2.0 + * over the newline-delimited stdio of a `dsh --profile acp` child — the ACP + * v1 wire the framing here implements, typed by the protocol's own schema + * package (a type-only import, so nothing of that package ships in the host). + * + * Small on purpose: the host needs requests with their replies, the agent's + * notifications, its `session/request_permission` asks answered asynchronously, + * and a definite end when the child dies. Everything above that — which + * session a notification belongs to, what a permission ask becomes on the + * phone — is the driver's, not the wire's. + * + * A line that is not a JSON-RPC message is logged and dropped rather than + * killing the connection: the runtime owns stdout for ACP, but a wrapped or + * interleaved line is not worth losing every running session over. A closed + * stdio is the end: every pending request is rejected and `closed` settles. + */ +import type { + AgentNotificationParamsByMethod, + AgentRequestParamsByMethod, + AgentRequestResponsesByMethod, + ClientNotificationParamsByMethod, + ClientRequestParamsByMethod, + ClientRequestResponsesByMethod, +} from '@agentclientprotocol/sdk'; + +/** A JSON-RPC id as it rides the wire (the agent may use either kind). */ +type WireId = string | number; + +/** A failed request, with the agent's own JSON-RPC error text. */ +export class AcpRequestError extends Error { + constructor( + message: string, + readonly code: number, + readonly data: unknown = undefined, + ) { + super(message); + this.name = 'AcpRequestError'; + } +} + +/** The end of a connection: `error` is absent for an orderly close. */ +export interface AcpClosed { + error?: string; +} + +export interface AcpClientOptions { + /** The child's stdin — messages are written here. */ + stdin: NodeJS.WritableStream; + /** The child's stdout — messages are read from here. */ + stdout: NodeJS.ReadableStream; + /** Diagnostics (the host's stderr). Never given message contents verbatim + * beyond what a protocol error already says. */ + log: (message: string) => void; +} + +/** Milliseconds a request may wait before it is abandoned. `initialize` gets + * one because a first run initializes the profile; a prompt does not, since + * a turn is as long as the model takes. */ +export const INITIALIZE_TIMEOUT_MS = 120_000; + +interface Pending { + resolve: (value: unknown) => void; + reject: (error: Error) => void; + timer?: NodeJS.Timeout; +} + +/** A notification the agent sent. */ +type NotificationHandler = ( + params: ClientNotificationParamsByMethod[M], +) => void; + +/** An agent request (currently only permission) the client answers. */ +type RequestHandler = ( + params: ClientRequestParamsByMethod[M], +) => Promise; + +export class AcpClient { + private readonly pending = new Map(); + private readonly notifications = new Map void>(); + private readonly requests = new Map Promise>(); + private nextId = 1; + private buffer = ''; + private ending: AcpClosed | undefined; + private readonly done: Promise; + private settleDone!: (closed: AcpClosed) => void; + private readonly onData: (chunk: Buffer | string) => void; + private readonly onEnd: () => void; + private readonly onError: (error: Error) => void; + + constructor(private readonly options: AcpClientOptions) { + this.done = new Promise((resolve) => { + this.settleDone = resolve; + }); + this.onData = (chunk) => this.receive(chunk.toString()); + this.onEnd = () => this.finish({ error: 'the agent closed its output' }); + this.onError = (error) => this.finish({ error: error.message }); + options.stdout.on('data', this.onData as (chunk: unknown) => void); + options.stdout.on('end', this.onEnd); + options.stdout.on('error', this.onError); + } + + /** Settles when the connection is over (either side ended it). */ + get closed(): Promise { + return this.done; + } + + /** Whether the connection can still carry messages. */ + get isOpen(): boolean { + return this.ending === undefined; + } + + /** Send one request and wait for its reply. */ + request( + method: M, + params: AgentRequestParamsByMethod[M], + options?: { timeoutMs?: number }, + ): Promise { + const id = this.nextId++; + return new Promise((resolve, reject) => { + const pending: Pending = { resolve, reject }; + if (options?.timeoutMs !== undefined) { + pending.timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${String(method)} did not answer within ${options.timeoutMs}ms`)); + }, options.timeoutMs); + pending.timer.unref?.(); + } + this.pending.set(id, pending); + try { + this.send({ jsonrpc: '2.0', id, method, params }); + } catch (error) { + this.pending.delete(id); + if (pending.timer) clearTimeout(pending.timer); + reject(error instanceof Error ? error : new Error(String(error))); + } + }) as Promise; + } + + /** Send one notification (no reply follows, by definition). */ + notify( + method: M, + params: AgentNotificationParamsByMethod[M], + ): void { + this.send({ jsonrpc: '2.0', method, params }); + } + + /** Handle one notification kind the agent sends. */ + on(method: M, handler: NotificationHandler): void { + this.notifications.set(method, handler as (params: never) => void); + } + + /** Answer one kind of request the agent sends. */ + onRequest(method: M, handler: RequestHandler): void { + this.requests.set(method, handler as (params: never) => Promise); + } + + /** Stop reading and end every pending request. Idempotent. */ + finish(closed: AcpClosed): void { + if (this.ending !== undefined) return; + this.ending = closed; + this.options.stdout.off?.('data', this.onData as (chunk: unknown) => void); + this.options.stdout.off?.('end', this.onEnd); + this.options.stdout.off?.('error', this.onError); + const reason = new Error(closed.error ?? 'the agent connection was closed'); + for (const [, pending] of this.pending) { + if (pending.timer) clearTimeout(pending.timer); + pending.reject(reason); + } + this.pending.clear(); + this.settleDone(closed); + } + + private send(message: object): void { + if (this.ending !== undefined) throw new Error(this.ending.error ?? 'the agent connection was closed'); + this.options.stdin.write(`${JSON.stringify(message)}\n`); + } + + /** One chunk of the child's output: whole lines are dispatched, a partial + * one waits for the rest. */ + private receive(text: string): void { + this.buffer += text; + for (;;) { + const end = this.buffer.indexOf('\n'); + if (end < 0) return; + const line = this.buffer.slice(0, end).replace(/\r$/, ''); + this.buffer = this.buffer.slice(end + 1); + if (line.trim() !== '') this.dispatch(line); + } + } + + private dispatch(line: string): void { + let message: unknown; + try { + message = JSON.parse(line); + } catch { + this.options.log(`[deepseek] dropped a malformed line from the agent: ${line.slice(0, 200)}`); + return; + } + if (typeof message !== 'object' || message === null) { + this.options.log('[deepseek] dropped a non-object message from the agent'); + return; + } + const frame = message as { id?: WireId; method?: unknown; params?: unknown; result?: unknown; error?: unknown }; + if (typeof frame.method === 'string') { + if (frame.id === undefined || frame.id === null) this.notifyIncoming(frame.method, frame.params); + else void this.answer(frame.id, frame.method, frame.params); + return; + } + if (frame.id === undefined || frame.id === null) { + this.options.log('[deepseek] dropped a message from the agent with no id and no method'); + return; + } + const pending = this.pending.get(frame.id); + if (!pending) { + this.options.log(`[deepseek] dropped a reply to unknown request ${String(frame.id)}`); + return; + } + this.pending.delete(frame.id); + if (pending.timer) clearTimeout(pending.timer); + if (frame.error !== undefined && frame.error !== null) { + const error = frame.error as { code?: unknown; message?: unknown; data?: unknown }; + const code = typeof error.code === 'number' ? error.code : -32603; + const text = typeof error.message === 'string' ? error.message : 'the agent refused the request'; + pending.reject(new AcpRequestError(text, code, error.data)); + } else { + pending.resolve(frame.result); + } + } + + private notifyIncoming(method: string, params: unknown): void { + const handler = this.notifications.get(method); + if (!handler) return; + try { + (handler as (params: unknown) => void)(params); + } catch (error) { + this.options.log(`[deepseek] ${method} handler failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** Answer one agent request. A handler that throws becomes a JSON-RPC + * error, so the agent is never left waiting on a reply. */ + private async answer(id: WireId, method: string, params: unknown): Promise { + const handler = this.requests.get(method); + if (!handler) { + this.reply({ jsonrpc: '2.0', id, error: { code: -32601, message: `Method not found: ${method}` } }); + return; + } + try { + const result = await (handler as (params: unknown) => Promise)(params); + this.reply({ jsonrpc: '2.0', id, result }); + } catch (error) { + this.reply({ + jsonrpc: '2.0', + id, + error: { code: -32603, message: error instanceof Error ? error.message : String(error) }, + }); + } + } + + private reply(message: object): void { + // A reply to a request the agent sent is best effort: a connection that + // closed in the meantime has nobody left to answer. + try { + this.send(message); + } catch (error) { + this.options.log(`[deepseek] could not answer the agent: ${error instanceof Error ? error.message : String(error)}`); + } + } +} diff --git a/packages/agent-host/src/drivers/deepseek/adapter.ts b/packages/agent-host/src/drivers/deepseek/adapter.ts new file mode 100644 index 00000000..e262a0a9 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/adapter.ts @@ -0,0 +1,226 @@ +/** + * Translates the DeepSeek Harness's ACP `session/update` notifications into + * transcript entries — the counterpart of the Claude driver's + * `sdkMessageToEntries` and the OpenCode driver's `opencodeEventToEntries`. + * + * Pure: one update in, zero or more entries out, with the only state being + * the facts a tool call's own update carried (`ToolCallFacts`), which the + * harness's result update does not repeat. Two updates are not entries and + * are the session's own: context usage (`usage_update`) and the option list + * (`config_option_update`). + * + * The harness sends the standard ACP updates for what it does — agent + * messages and thoughts, generic tool call lifecycles, usage, config — and + * nothing else: no plans, no slash commands, no modes, no terminal or + * filesystem callbacks. Its tool calls arrive with `kind: "other"` and the + * tool's own name as their title, so the agent-neutral kind and summary come + * from the name (src/tools.ts) exactly as they do for the other agents. + */ +import type { SessionUpdate } from '@agentclientprotocol/sdk'; +import { todosOf, toolInput, toolKindOf, toolLocations, toolTitle } from '../../tools'; +import { MAX_DIFF_LINES, truncateToolResult, toDiffLines, type DiffPayload } from '../../transcript'; +import type { OutputEntry } from '../../types'; + +/** + * The tools whose card *is* the exchange. The question tool asks through the + * harness's `user-questions` service and so does the plan review + * (`exit_plan_mode`); the bridge shows each as a question card and sends the + * answer back, so their own tool rows would be a second, emptier copy — and + * for a plan review the copy is its raw arguments, the whole plan as JSON. + */ +const QUESTION_TOOLS = new Set(['ask_user_question', 'exit_plan_mode']); + +/** What a tool call's own update carried: its result update repeats neither + * the tool's name nor its input. */ +export interface ToolCallFacts { + name: string; + input: Record; +} + +/** Every tool call of one session, by id. */ +export type ToolCallMemory = Map; + +/** Convert one update into entries. */ +export function deepseekUpdateToEntries(update: SessionUpdate, calls: ToolCallMemory): OutputEntry[] { + const ts = new Date().toISOString(); + switch (update.sessionUpdate) { + case 'agent_message_chunk': + return textOf(update.content, 'agent', ts); + case 'agent_thought_chunk': + return textOf(update.content, 'thinking', ts); + case 'tool_call': + return toolCallEntries(update, calls, ts); + case 'tool_call_update': + return toolResultEntries(update, calls, ts); + default: + return []; + } +} + +function textOf(content: unknown, role: 'agent' | 'thinking', ts: string): OutputEntry[] { + const block = content as { type?: unknown; text?: unknown } | null; + if (block?.type !== 'text' || typeof block.text !== 'string' || block.text === '') return []; + return [role === 'thinking' ? { entryType: 'thinking', text: block.text, timestamp: ts } : { entryType: 'text', role, text: block.text, timestamp: ts }]; +} + +/** One started tool call: its row, and the checklist when the call wrote one. */ +function toolCallEntries( + update: Extract, + calls: ToolCallMemory, + ts: string, +): OutputEntry[] { + const name = toolNameOf(update); + const input = record(update.rawInput); + calls.set(update.toolCallId, { name, input }); + if (QUESTION_TOOLS.has(name)) return []; + const locations = fileLocations(name, input); + const full = toolInput(name, input); + const todos = todosOf(input); + return [ + { + entryType: 'tool_call', + callId: update.toolCallId, + toolName: name, + kind: toolKindOf(name), + title: toolTitle(name, input) || name, + ...(locations.length > 0 ? { locations } : {}), + ...(full !== undefined ? { input: full } : {}), + timestamp: ts, + }, + ...(todos ? [{ entryType: 'todos' as const, items: todos, callId: update.toolCallId, timestamp: ts }] : []), + ]; +} + +/** One finished tool call: its result text, and the file change it made when + * the harness's own input says what changed (its result update carries no + * diff block). A non-terminal update carries nothing new to show — the + * call's row is already there. */ +function toolResultEntries( + update: Extract, + calls: ToolCallMemory, + ts: string, +): OutputEntry[] { + const status = update.status ?? undefined; + if (status !== 'completed' && status !== 'failed') return []; + const facts = calls.get(update.toolCallId); + calls.delete(update.toolCallId); + if (facts !== undefined && QUESTION_TOOLS.has(facts.name)) return []; + const text = contentText(update.content); + const entries: OutputEntry[] = [ + { + entryType: 'tool_result', + callId: update.toolCallId, + text: truncateToolResult(text), + ...(status === 'failed' ? { isError: true } : {}), + timestamp: ts, + }, + ]; + if (status === 'completed' && facts) { + for (const diff of toolCallDiffs(facts.name, facts.input)) { + entries.push({ entryType: 'diff', ...diff, callId: update.toolCallId, timestamp: ts }); + } + } + return entries; +} + +/** The tool's own name: ACP carries it as `name` when the agent has one, and + * the harness puts it in `title`. */ +function toolNameOf(update: Extract): string { + return update.name ?? update.title; +} + +/** Everything a result's content blocks say, one line each. */ +function contentText(content: unknown): string { + if (!Array.isArray(content)) return ''; + const parts: string[] = []; + for (const item of content) { + const block = record(item); + if (block?.type !== 'content') continue; + const inner = record(block.content); + if (inner?.type === 'text' && typeof inner.text === 'string') parts.push(inner.text); + else if (inner?.type === 'image') parts.push('[image]'); + } + return parts.join('\n'); +} + +// --- file changes --- + +/** + * The files a completed call changed, from its own input. The harness's + * result update carries text only — no ACP diff block — so a file change is + * reconstructed the way the agent described it: the replaced text for `edit` + * and `str_replace_editor`, the whole file for `write` and a create. + * + * `str_replace_editor` is the Claude-Code-shaped editor the harness also + * mounts: its `command` decides which of its arguments are the change. + */ +export function toolCallDiffs(name: string, input: Record): DiffPayload[] { + switch (name) { + case 'edit': { + const path = str(input.file_path) ?? str(input.path); + if (!path) return []; + return bounded(path, [ + ...toDiffLines(str(input.old_string) ?? '', 'del'), + ...toDiffLines(str(input.new_string) ?? '', 'add'), + ]); + } + case 'write': { + const path = str(input.file_path) ?? str(input.path); + const content = str(input.content) ?? str(input.file_text); + if (!path || content === undefined) return []; + return bounded(path, toDiffLines(content, 'add')); + } + case 'str_replace_editor': { + const path = str(input.path); + if (!path) return []; + const command = str(input.command) ?? ''; + if (command === 'create') { + const content = str(input.file_text); + return content === undefined ? [] : bounded(path, toDiffLines(content, 'add')); + } + if (command === 'str_replace') { + return bounded(path, [ + ...toDiffLines(str(input.old_str) ?? '', 'del'), + ...toDiffLines(str(input.new_str) ?? '', 'add'), + ]); + } + if (command === 'insert') { + return bounded(path, toDiffLines(str(input.new_str) ?? '', 'add')); + } + return []; + } + default: + return []; + } +} + +/** A diff payload inside the shared wire cap; nothing for an empty change. */ +function bounded(path: string, lines: DiffPayload['lines']): DiffPayload[] { + if (lines.length === 0) return []; + const truncated = lines.length > MAX_DIFF_LINES; + return [{ + path, + lines: truncated ? lines.slice(0, MAX_DIFF_LINES) : lines, + ...(truncated ? { truncated: true } : {}), + }]; +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined; +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; +} + +/** + * The file a call names. The harness's own file tools agree on `file_path`; + * `str_replace_editor` is the odd one out with `path` — a key `glob` and + * `grep` use for a directory, so only that editor is read that way. + */ +function fileLocations(name: string, input: Record): string[] { + const direct = toolLocations(input); + if (direct.length > 0) return direct; + const path = name === 'str_replace_editor' ? str(input.path) : undefined; + return path ? [path] : []; +} diff --git a/packages/agent-host/src/drivers/deepseek/bridge.ts b/packages/agent-host/src/drivers/deepseek/bridge.ts new file mode 100644 index 00000000..1ca6cbb0 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/bridge.ts @@ -0,0 +1,146 @@ +/** + * The driver's side of the socket the plugin listens on (plugin.ts): what a + * session's commands are, running one line of them, and answering a question + * the harness's model asked. + * + * One connection per request, line-delimited JSON, and `undefined` for + * anything that does not work out — a harness without the plugin, a plugin + * that could not start, a timeout. Commands and questions are conveniences: + * a session runs without them exactly as it did before, and nothing here may + * fail one. + */ +import { createHash } from 'node:crypto'; +import * as path from 'node:path'; +import { connect } from 'node:net'; +import { slashCommand } from '../../commands'; +import type { SlashCommand } from '../../types'; + +/** How long one question may take. The plugin answers from memory; a command + * that does real work (a compaction asks a model) takes as long as it takes, + * and the caller's own long timeout is the one that applies. */ +const LIST_TIMEOUT_MS = 5_000; +const RUN_TIMEOUT_MS = 10 * 60_000; + +/** + * Where one harness process's plugin listens. Every process has its own: the + * runtime runs one per environment, so several can be up at once, and two + * plugins on one path take the file from each other — the second unlinks the + * first one's socket as it starts, and whichever closes first unlinks the + * other's, leaving a live process that nothing can reach. `tag` names the + * process. A file inside the bridge's home on a POSIX machine, and a named + * pipe on Windows, named after the home too so two bridges on one machine do + * not collide. + */ +export function bridgeSocketPath(home: string, tag: string, platform: NodeJS.Platform = process.platform): string { + if (platform === 'win32') { + const homeTag = createHash('sha256').update(path.resolve(home)).digest('hex').slice(0, 12); + return `\\\\.\\pipe\\codedeck-dsh-bridge-${homeTag}-${tag}`; + } + return path.join(home, 'codedeck', `dsh-bridge-${tag}.sock`); +} + +/** One command as the plugin lists it. */ +interface CommandListing { + name: string; + description?: string; + hint?: string; +} + +/** The plugin's answer. */ +export interface Reply { + ok: boolean; + error?: string; + commands?: CommandListing[]; + result?: { kind: string; text?: string }; + /** For `steer`: whether a running turn took the message. */ + steered?: boolean; +} + +/** The commands of one session, or `undefined` when they cannot be read. */ +export async function listSessionCommands( + socket: string, + sessionId: string, + log: (message: string) => void, + timeoutMs: number = LIST_TIMEOUT_MS, +): Promise { + const reply = await askPlugin(socket, { method: 'list', sessionId }, timeoutMs, log); + if (!reply?.ok) return undefined; + return (reply.commands ?? []) + .filter((command) => typeof command.name === 'string' && command.name !== '') + .map((command) => slashCommand(command.name, command.description, command.hint)); +} + +/** What running one command line produced, or `undefined` when it did not run + * (an unknown command, an unreachable plugin, a failure in the handler). */ +export async function runSessionCommand( + socket: string, + sessionId: string, + line: string, + log: (message: string) => void, + timeoutMs: number = RUN_TIMEOUT_MS, +): Promise<{ ok: boolean; text: string } | undefined> { + const reply = await askPlugin(socket, { method: 'run', sessionId, line }, timeoutMs, log); + if (!reply) return undefined; + if (!reply.ok) return { ok: false, text: reply.error ?? 'the command could not be run' }; + return { ok: reply.result?.kind !== 'error', text: reply.result?.text ?? '' }; +} + +/** Hand a message to the turn a session is running, as the harness's own + * apps steer one. `false` when there was no turn to take it, or nothing to + * ask — the message is then for a prompt of its own. */ +export async function steerSession( + socket: string, + sessionId: string, + text: string, + log: (message: string) => void, + timeoutMs: number = LIST_TIMEOUT_MS, +): Promise { + const reply = await askPlugin(socket, { method: 'steer', sessionId, text }, timeoutMs, log); + return reply?.ok === true && reply.steered === true; +} + +/** One request to the plugin, one answer. Never throws: a socket that is not + * there (no plugin, a harness that has not started) is not an error worth a + * stack. */ +export async function askPlugin( + socket: string, + request: Record, + timeoutMs: number, + log: (message: string) => void, +): Promise { + const id = 1; + return new Promise((resolve) => { + let buffered = ''; + let settled = false; + const connection = connect(socket); + const finish = (reply: Reply | undefined, reason?: string): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + connection.destroy(); + if (reply === undefined && reason !== undefined) log(`[deepseek] ${reason}`); + resolve(reply); + }; + const timer = setTimeout(() => finish(undefined, `the command bridge did not answer within ${timeoutMs}ms`), timeoutMs); + timer.unref?.(); + connection.on('error', (error: NodeJS.ErrnoException) => { + // A missing socket means the plugin is not running: worth one line, not + // a failure. + finish(undefined, error.code === 'ENOENT' || error.code === 'ECONNREFUSED' ? 'the command bridge is not running' : error.message); + }); + connection.on('data', (chunk: Buffer) => { + buffered += chunk.toString(); + const end = buffered.indexOf('\n'); + if (end < 0) return; + try { + const answer = JSON.parse(buffered.slice(0, end)) as Reply & { id?: number }; + if (answer.id !== undefined && answer.id !== id) return; + finish(answer); + } catch { + finish(undefined, 'the command bridge answered something that is not JSON'); + } + }); + connection.on('close', () => finish(undefined, 'the command bridge closed without answering')); + connection.on('connect', () => connection.write(`${JSON.stringify({ id, ...request })}\n`)); + }); +} diff --git a/packages/agent-host/src/drivers/deepseek/driver.ts b/packages/agent-host/src/drivers/deepseek/driver.ts new file mode 100644 index 00000000..b0be197b --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/driver.ts @@ -0,0 +1,1029 @@ +/** + * The DeepSeek Harness driver, over the ACP profile of the `dsh` CLI + * (`dsh --profile acp`, runtime.ts) — the only interface the harness exposes + * that has everything an interactive client needs: prompts, cancellation, + * one-shot permission asks, and a model/reasoning selection that can change + * mid-session. Its `sdk` profile has none of the last three, and `headless` + * is one-shot. + * + * What ACP does not carry — the harness's slash commands, the questions its + * model asks, and a message for the turn that is running — reaches the + * harness through CodeDeck's own plugin in the profile (plugin.ts), over a + * socket each process has to itself. + * + * Deliberate limits, all of them the ACP surface's: + * - no modes: the harness has none, so `ask`/`default` are this driver's own + * (every ask to the phone, or auto-approve); + * - no background tasks: the automation profile carries none; + * - no subscription usage: there is nothing to ask (the context meter + * travels as session info instead); + * - MCP servers are attached when the harness starts, so a session shows + * them but cannot switch one; + * - a file change is reconstructed from the call's own arguments: the + * harness's result update carries no diff. + */ +import type { RequestPermissionRequest, RequestPermissionResponse, SessionConfigOption, SessionNotification } from '@agentclientprotocol/sdk'; +import type { Driver, DriverSession, McpManager, PluginManager, SessionContext, SessionMcpState } from '../../driver'; +import type { HttpGet } from '../../net'; +import { mcpStatus as mcpStatusOf } from '../../mcp'; +import { parseSlashCommand } from '../../commands'; +import { PERMISSION_ALLOW, PERMISSION_DENY, now, toolKindOf, toolLocations, toolTitle } from '../../tools'; +import type { + AgentInfo, + McpStatus, + ModelEntry, + OutputEntry, + SessionMcpServer, + SessionOption, + SlashCommand, + StartSession, + UsageData, +} from '../../types'; +import { deepseekUpdateToEntries, type ToolCallMemory } from './adapter'; +import { DEEPSEEK_API_KEY_CREDENTIAL, DEEPSEEK_API_KEY_ENV, DEEPSEEK_BASE_URL_ENV, buildDeepSeekEnv } from './env'; +import { askPlugin, listSessionCommands, runSessionCommand, steerSession } from './bridge'; +import { HARNESS_PLUGIN, installHarnessPlugin, QUESTION_MARKER } from './plugin'; +import { ASK_USER_TOOL, installProfileTools } from './profileTools'; +import { parseQuestionLine, planReviewOf, toAnswerItems, toQuestionSpecs, type PlanReview, type PushedQuestionLine } from './questions'; +import { gatewayModelsUrl, syncGatewayCatalog } from './gateway'; +import { DSH_LABEL } from './install'; +import { DeepSeekMcp } from './mcp'; +import { DeepSeekPlugins, runDshPlugin, type DshRun } from './plugins'; +import { + DSH_PROFILE, + DeepSeekRuntime, + dshProfileDir, + type DeepSeekProcess, + type DeepSeekProcessEvents, + type SpawnFn, +} from './runtime'; + +export const DEEPSEEK_AGENT_ID = 'deepseek-harness'; + +/** `ask` sends every permission the harness asks for to the phone; `default` + * (the auto-approve mode every agent shares) allows them all. */ +const AUTO_APPROVE_MODE = 'default'; +const DEFAULT_MODE = 'ask'; +const DEEPSEEK_MODES = [ + { id: DEFAULT_MODE, label: 'Ask', description: 'Ask before each tool call' }, + { id: AUTO_APPROVE_MODE, label: 'YOLO', description: 'Run every tool without asking' }, +]; + +/** + * The harness's reasoning levels. They are the harness's own vocabulary and + * are offered in `info()` because a session's options — which would say so + * precisely — only exist once a session has started; a level this list offers + * and the running model does not have is refused by the harness itself, with + * its own reason. + */ +const DEEPSEEK_EFFORTS = [ + { id: 'off', label: 'Off', description: 'Use for simple tasks that do not need reasoning.' }, + { id: 'low', label: 'Low', description: 'Prefer for routine or latency-sensitive tasks.' }, + { id: 'high', label: 'High', description: 'The default balance for most tasks.' }, + { id: 'max', label: 'Max', description: 'Reserve for the hardest quality-first tasks.' }, +]; +/** What the DeepSeek route runs at when nothing is chosen. */ +const DEFAULT_EFFORT = 'high'; + +/** How long the harness waits for the phone's answer to a question before + * the bridge gives up on it (the harness's own waits are its business; this + * is only the socket round trip that carries the answer). */ +const QUESTION_TIMEOUT_MS = 60_000; + +/** The harness's own option ids (standard ACP session config options). */ +const MODEL_CONFIG_ID = 'model'; +const EFFORT_CONFIG_ID = 'reasoning_effort'; + +/** One choice of a session config option. */ +interface Choice { + /** The harness's own selector value (what `set_config_option` takes). */ + value: string; + label: string; + /** Who serves it, as a person reads it (`DeepSeek`), when the option says. */ + provider?: string; + description?: string; +} + +/** + * The choices of a select option. ACP lets an agent send them as one flat + * list or grouped, and the harness groups its models by provider (one group + * per configured provider, named after it) while its reasoning levels come + * flat. + */ +export function selectChoices(option: SessionConfigOption | undefined): Choice[] { + if (!option || option.type !== 'select') return []; + const choices: Choice[] = []; + for (const entry of option.options) { + if (!isGroup(entry)) { + choices.push({ value: entry.value, label: entry.name, ...(entry.description ? { description: entry.description } : {}) }); + continue; + } + for (const inner of entry.options) { + choices.push({ + value: inner.value, + label: inner.name, + provider: entry.name, + ...(inner.description ? { description: inner.description } : {}), + }); + } + } + return choices; +} + +/** ACP sends a select option's values either as one flat list or as groups; + * only a group entry carries values of its own. */ +function isGroup(entry: { + value?: unknown; + options?: unknown; +}): entry is { name: string; group?: string; options: Array<{ value: string; name: string; description?: string | null }> } { + return Array.isArray(entry.options); +} + +/** + * One model as the phone sees it, in the shape the other drivers report: the + * id is the model's own — a gateway's usually names its channel before a + * slash (`Z.ai (Global) - Coding Plan/glm-5.3-flash`, Claude Code's gateway + * models arrive the same way) — and the channel becomes the provider, with + * the model part as the label. The harness's own selector value is what + * travels back to it; `resolveModel` maps one to the other. + */ +export function toModelEntry(choice: Choice): ModelEntry { + const id = modelIdOf(choice.value); + const slash = id.indexOf('/'); + const channel = slash > 0 ? id.slice(0, slash) : undefined; + const label = channel !== undefined ? id.slice(slash + 1) : choice.label; + return { + id, + label, + ...(channel !== undefined ? { provider: channel } : choice.provider ? { provider: choice.provider } : {}), + }; +} + +/** The model choices of a session's option state: `value` is the opaque + * selector the harness sets and returns, `label` what its own name says. */ +export function modelChoices(option: SessionConfigOption | undefined): Choice[] { + return selectChoices(option); +} + +/** The plain reasoning levels of a session's option state (the empty value + * the harness offers as "provider default" is not a level). */ +export function effortChoices(option: SessionConfigOption | undefined): Choice[] { + return selectChoices(option).filter((choice) => choice.value !== ''); +} + +/** + * The model inside a selector: its value is the harness's own encoding of + * `[provider, model]`, and a model named by a provider profile is the plain + * id inside it. A value that is not that pair is returned as it is, so an + * unknown encoding matches nothing rather than everything. + */ +export function modelIdOf(value: string): string { + try { + const parsed: unknown = JSON.parse(value); + if (Array.isArray(parsed) && parsed.length === 2 && typeof parsed[1] === 'string') return parsed[1]; + } catch { + // Not a selector: the value is already what it looks like. + } + return value; +} + +export class DeepSeekSession implements DriverSession { + /** What each tool call's own update carried, for its result and for the + * permission card the harness asks about it. */ + private readonly calls: ToolCallMemory = new Map(); + private readonly cwd: string; + private readonly env: Record; + private readonly ready: Promise; + private mode: string; + private process?: DeepSeekProcess; + private nativeId?: string; + private options: SessionConfigOption[] = []; + private contextPercentage?: number; + private contextWindow?: number; + /** Whether the phone has been told the session is up: until then its model + * travels in the identity info, not as a change. */ + private announced = false; + /** The names this session's harness can run, from the last list fetched + * (dropped again on failure, so the next look asks again). */ + private commandNames?: Promise>; + private ended = false; + /** Prompts run one at a time: ACP refuses a second prompt while one is in + * flight, so a message that cannot steer the running turn waits for it. */ + private tail: Promise = Promise.resolve(); + /** Whether a `session/prompt` of this session is in flight — the turn a + * message sent now can steer. */ + private promptInFlight = false; + /** Messages sent during the running turn, steered (or queued) one after + * another so they reach the model in the order they were sent. */ + private steering: Promise = Promise.resolve(); + /** A message of this turn already waits for the next one: whatever is sent + * after it waits too, so it never overtakes it. */ + private queuedBehindTurn = false; + + constructor( + private readonly params: StartSession, + private readonly ctx: SessionContext, + private readonly deps: { + runtime: DeepSeekRuntime; + mcp: DeepSeekMcp; + baseEnv: NodeJS.ProcessEnv; + /** Where this session is registered while it runs, so the driver can + * end it when the harness process it shares goes away. */ + live: { + add(sessionId: string, session: DeepSeekSession): void; + remove(sessionId: string): void; + events(): DeepSeekProcessEvents; + }; + /** The profile as it must be before a harness boots: the endpoint's + * catalog written, the command plugin in place. */ + prepared: Promise; + }, + ) { + this.cwd = params.cwd; + this.mode = params.mode ?? DEFAULT_MODE; + // Built here, not in `init`: a provider profile that cannot be used at + // all refuses the session before it starts, rather than after an error + // entry the phone would have to read. + this.env = buildDeepSeekEnv(params, deps.baseEnv); + this.ready = this.init(); + // init() reports its own failure as `ended`; nothing else awaits this + // rejection except prompt/interrupt, which catch it themselves. + this.ready.catch(() => {}); + } + + private get client() { + const process_ = this.process; + if (!process_) throw new Error('the DeepSeek Harness session has not started'); + return process_.client; + } + + /** Where the plugin of the process holding this session answers. Every + * process has its own socket, so it is always this process's. */ + private get bridgeSocket(): string | undefined { + return this.process?.bridgeSocket; + } + + /** The harness process this session shares went away, taking the session + * with it. */ + endFromProcess(error: string): void { + this.finish(error); + } + + private async init(): Promise { + try { + // The harness reads its catalog and mounts its plugins as it boots, so + // both have to be in the profile before a process starts. + await this.deps.prepared; + const process_ = await this.deps.runtime.acquire(this.env, this.deps.live.events()); + this.process = process_; + // A session closed while its harness was starting keeps nothing: the + // process it acquired is let go at once. + if (this.ended) { + this.abandon(); + return; + } + const opened = await this.openSession(); + this.nativeId = opened.sessionId; + this.options = opened.configOptions; + if (this.ended) { + this.abandon(); + return; + } + process_.attach(opened.sessionId, { + update: (notification) => this.onUpdate(notification), + permission: (request) => this.onPermission(request), + }); + this.deps.live.add(opened.sessionId, this); + await this.applySelection(); + if (this.ended) return; + const model = this.currentModel(); + this.announced = true; + this.ctx.emit({ type: 'info', nativeSessionId: opened.sessionId, ...(model ? { model: model.label } : {}), mode: this.mode }); + this.deliver({ entryType: 'status', text: `${DSH_LABEL} session started${model ? ` (${model.label})` : ''}`, timestamp: now() }); + this.ctx.emit({ type: 'ready' }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.deliver({ entryType: 'error', text: message, timestamp: now() }); + this.finish(message); + } + } + + /** Start a fresh conversation, or continue the one the bridge names. A + * conversation the harness no longer has (removed, or started in another + * workspace — it checks) falls back to a fresh one with a notice, as the + * other drivers do: the transcript is the bridge's, the memory is the + * agent's. */ + private async openSession(): Promise<{ sessionId: string; configOptions: SessionConfigOption[] }> { + const resume = this.params.resume; + const resumable = this.process?.capabilities.sessionCapabilities?.resume; + if (resume && resumable !== undefined) { + try { + const resumed = await this.client.request('session/resume', { sessionId: resume, cwd: this.cwd, mcpServers: [] }); + return { sessionId: resume, configOptions: resumed.configOptions ?? [] }; + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + this.ctx.log(`[deepseek] could not resume ${resume} (${reason}) — starting a fresh session`); + this.deliver({ + entryType: 'notice', + kind: 'session_restart', + text: + `${DSH_LABEL} could not continue this conversation — starting a fresh one in the same workspace. ` + + 'The transcript is preserved, but the model does not remember earlier turns.', + timestamp: now(), + }); + } + } + const created = await this.client.request('session/new', { cwd: this.cwd, mcpServers: [] }); + return { sessionId: created.sessionId, configOptions: created.configOptions ?? [] }; + } + + /** Apply the model and reasoning level the session was started with. The + * model goes first: which reasoning levels exist is the model's own. */ + private async applySelection(): Promise { + const wanted = this.params.model; + if (wanted) { + const value = this.resolveModel(wanted); + try { + await this.setConfig(MODEL_CONFIG_ID, value ?? wanted); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + if (!this.params.provider) { + // The phone chooses from this driver's own model list, so a model + // it cannot have is a stale choice: refusing the session says so + // loudly instead of quietly running something else. + throw new Error(`${DSH_LABEL} does not offer the model '${wanted}': ${reason} — choose one from its model list.`); + } + // A provider-bound session's model names the gateway's own model, + // while the harness sends its catalog's name on the wire and lets the + // gateway map it: a name the catalog does not know is not fatal, but + // the phone must not believe it took effect. + this.deliver({ + entryType: 'error', + text: `The provider profile's model '${wanted}' is not one the harness knows: ${reason}`, + timestamp: now(), + }); + } + } + const effort = this.params.effort; + if (effort && effortChoices(this.optionOf(EFFORT_CONFIG_ID)).some((choice) => choice.value === effort)) { + await this.setConfig(EFFORT_CONFIG_ID, effort); + } else if (effort) { + this.deliver({ + entryType: 'error', + text: `This model has no reasoning level '${effort}' in ${DSH_LABEL} — the session runs at the model's own default.`, + timestamp: now(), + }); + } + } + + /** + * The value to select for a model the bridge names. Two shapes reach here: + * the model id this driver reports (what the phone was shown, and what a + * provider profile names), and the harness's own selector value. Anything + * else matches nothing, so it is refused rather than guessed at. + */ + private resolveModel(model: string): string | undefined { + const choices = modelChoices(this.optionOf(MODEL_CONFIG_ID)); + return ( + choices.find((choice) => choice.value === model)?.value ?? choices.find((choice) => modelIdOf(choice.value) === model)?.value + ); + } + + private optionOf(id: string): SessionConfigOption | undefined { + return this.options.find((option) => option.id === id); + } + + /** The model the session is on, as its option state describes it. */ + private currentModel(): Choice | undefined { + const option = this.optionOf(MODEL_CONFIG_ID); + if (!option || option.type !== 'select') return undefined; + return ( + modelChoices(option).find((choice) => choice.value === option.currentValue) ?? { + value: option.currentValue, + label: option.currentValue, + } + ); + } + + /** Change one option and keep the state that comes back — the harness + * answers every change with the complete option list. */ + private async setConfig(configId: string, value: string): Promise { + const sessionId = this.nativeId; + if (!sessionId) throw new Error('the session has not started'); + const result = await this.client.request('session/set_config_option', { sessionId, configId, value }); + const before = this.currentModel()?.value; + this.options = result.configOptions ?? this.options; + const after = this.currentModel(); + if (this.announced && configId === MODEL_CONFIG_ID && after && after.value !== before) { + this.ctx.emit({ type: 'info', model: after.label }); + } + } + + /** One update of this session: entries for the transcript, and the two + * updates that are session state rather than conversation. */ + private onUpdate(notification: SessionNotification): void { + if (this.ended) return; + const update = notification.update; + if (update.sessionUpdate === 'usage_update') { + this.reportContext(update.used, update.size); + return; + } + if (update.sessionUpdate === 'config_option_update') { + this.options = update.configOptions; + return; + } + const entries = deepseekUpdateToEntries(update, this.calls); + if (entries.length > 0) this.ctx.emit({ type: 'entries', entries }); + } + + /** How full the context is, over the window the harness reports. */ + private reportContext(used: number, size: number): void { + if (!(size > 0) || !Number.isFinite(used)) return; + const percentage = Math.max(0, Math.min(100, Math.round((used / size) * 100))); + if (percentage === this.contextPercentage && size === this.contextWindow) return; + this.contextPercentage = percentage; + this.contextWindow = size; + this.ctx.emit({ type: 'info', contextWindow: size, contextPercentage: percentage }); + } + + /** + * One permission ask. The harness names only the tool call, so the card + * comes from that call's own update — which the harness sends before it + * asks — and the choices are the two it offers: it has no "always allow" + * to choose. + */ + private async onPermission(request: RequestPermissionRequest): Promise { + const allow = request.options.find((option) => option.kind === 'allow_once'); + const reject = request.options.find((option) => option.kind === 'reject_once'); + const refuse = (): RequestPermissionResponse => + reject ? { outcome: { outcome: 'selected', optionId: reject.optionId } } : { outcome: { outcome: 'cancelled' } }; + if (this.ended || !allow) return { outcome: { outcome: 'cancelled' } }; + if (this.mode === AUTO_APPROVE_MODE) return { outcome: { outcome: 'selected', optionId: allow.optionId } }; + + const callId = request.toolCall.toolCallId; + const facts = this.calls.get(callId); + const toolName = facts?.name ?? request.toolCall.name ?? request.toolCall.title ?? 'tool'; + const input = facts?.input ?? {}; + const locations = toolLocations(input); + const outcome = await this.ctx.requestPermission({ + requestId: callId, + toolName, + kind: toolKindOf(toolName), + title: toolTitle(toolName, input) || toolName, + ...(locations.length > 0 ? { locations } : {}), + rawInput: input, + options: [PERMISSION_ALLOW, PERMISSION_DENY], + }); + if (outcome.outcome === 'cancelled') return { outcome: { outcome: 'cancelled' } }; + return outcome.optionId === PERMISSION_ALLOW.id ? { outcome: { outcome: 'selected', optionId: allow.optionId } } : refuse(); + } + + private deliver(entry: OutputEntry): void { + this.ctx.emit({ type: 'entries', entries: [entry] }); + } + + private finish(error?: string): void { + if (this.ended) return; + this.ended = true; + this.abandon(); + this.ctx.emit({ type: 'ended', ...(error ? { error } : {}) }); + } + + /** Let go of the harness process this session held. */ + private abandon(): void { + const process_ = this.process; + this.process = undefined; + if (this.nativeId !== undefined) { + process_?.detach(this.nativeId); + this.deps.live.remove(this.nativeId); + } + process_?.release(); + } + + /** + * One message from the user. While a turn runs, ACP takes no second prompt, + * so a message is handed to that turn instead — the harness's own steering, + * which its apps use for a message sent while the agent works, through the + * plugin — and the model reads it at its next step. One the turn cannot take + * (a slash command, a turn just ending, no plugin) waits for the turn to end. + */ + prompt(text: string): void { + if (this.promptInFlight && !parseSlashCommand(text)) { + const next = this.steering.then(() => this.steerOrQueue(text)); + this.steering = next.catch(() => {}); + return; + } + this.enqueue(text); + } + + private enqueue(text: string): void { + const next = this.tail.then( + () => this.runPrompt(text), + () => this.runPrompt(text), + ); + this.tail = next.catch(() => {}); + } + + private async steerOrQueue(text: string): Promise { + const socket = this.bridgeSocket; + const sessionId = this.nativeId; + if (!this.queuedBehindTurn && this.promptInFlight && socket !== undefined && sessionId !== undefined) { + if (await steerSession(socket, sessionId, text, this.ctx.log)) return; + } + this.queuedBehindTurn = true; + this.enqueue(text); + } + + /** + * A typed `/name` that this session's harness has runs as that command — + * `listCommands` is what makes the phone offer it, and the harness's own + * command registry is what runs it (`/compact` compacts, rather than being + * text the model reads). Anything else is a prompt. + */ + private async runPrompt(text: string): Promise { + const command = parseSlashCommand(text); + if (command && (await this.knownCommands()).has(command.name)) { + await this.runCommand(text); + return; + } + try { + await this.ready; + const sessionId = this.nativeId; + if (this.ended || sessionId === undefined) return; + this.ctx.emit({ type: 'turn', state: 'running' }); + this.promptInFlight = true; + try { + await this.client.request('session/prompt', { sessionId, prompt: [{ type: 'text', text }] }); + } finally { + this.promptInFlight = false; + this.queuedBehindTurn = false; + } + this.endTurn(); + } catch (error) { + if (this.ended) return; + this.deliver({ entryType: 'error', text: error instanceof Error ? error.message : String(error), timestamp: now() }); + this.endTurn(); + } + } + + /** Run one command line and show what it said. A command is not a turn in + * the harness, but the phone is waiting on one: it is opened and closed + * around it, so the session never sits there looking busy. */ + private async runCommand(line: string): Promise { + await this.ready; + const sessionId = this.nativeId; + if (this.ended || sessionId === undefined) return; + this.ctx.emit({ type: 'turn', state: 'running' }); + const socket = this.bridgeSocket; + const outcome = socket === undefined ? undefined : await runSessionCommand(socket, sessionId, line, this.ctx.log); + if (this.ended) return; + if (outcome === undefined) { + this.deliver({ entryType: 'error', text: `${line} did not run: the command bridge is not answering.`, timestamp: now() }); + } else if (outcome.text !== '') { + this.deliver({ + ...(outcome.ok + ? { entryType: 'text' as const, role: 'agent' as const, text: outcome.text } + : { entryType: 'error' as const, text: outcome.text }), + timestamp: now(), + }); + } else if (!outcome.ok) { + this.deliver({ entryType: 'error', text: `${line} failed.`, timestamp: now() }); + } + this.endTurn(); + } + + /** + * The harness's model asked the user something, and the plugin pushed it + * here. A question goes to the phone as the same card the other agents send, + * and the answer goes back the way the harness takes it — labels for a + * chosen option, free text for a typed one. An unanswered question (the + * phone is gone, the user cancelled, the turn ended) is answered with + * nothing, which the harness reports to the model as a question that was + * not answered. + */ + askQuestion(pushed: PushedQuestionLine): void { + const review = planReviewOf(pushed.questions); + void (review === undefined ? this.showQuestion(pushed) : this.showPlanReview(pushed, review)).catch((error: unknown) => { + this.ctx.log(`[deepseek] could not pass a question on: ${error instanceof Error ? error.message : String(error)}`); + }); + } + + private async showQuestion(pushed: PushedQuestionLine): Promise { + const outcome = await this.ctx.askQuestion(pushed.callId, toQuestionSpecs(pushed.questions)); + await this.answerHarness(pushed.callId, { + ...(outcome.outcome === 'answered' ? { answer: toAnswerItems(pushed.questions, outcome.answers) } : {}), + }); + } + + /** + * A plan review is the harness's `exit_plan_mode` asking whether to leave + * plan mode. It is the same exchange Claude Code's driver has — a plan the + * user reads and then approves or sends back — so it gets the same two + * pieces on the phone: the plan as a plan of its own, and the approval card, + * whose choices are the harness's own labels because that is what the tool + * reads its verdict from. Feedback on a plan the user sends back goes with + * that choice, as the answer's free text — where the harness's tool reads + * it and hands it to the model with the request to revise. + */ + private async showPlanReview(pushed: PushedQuestionLine, review: PlanReview): Promise { + this.deliver({ entryType: 'plan', text: review.plan, timestamp: now() }); + const outcome = await this.ctx.requestPlanApproval(pushed.callId, review.options, review.revise); + if (outcome.outcome !== 'selected') { + await this.answerHarness(pushed.callId, {}); + return; + } + const feedback = outcome.optionId === review.revise ? outcome.feedback?.trim() : undefined; + await this.answerHarness(pushed.callId, { + // The chosen label is the whole verdict: the tool looks for the one its + // intent declared as the approval. + answer: [{ id: review.id, selected: [outcome.optionId], ...(feedback ? { custom: feedback } : {}) }], + }); + } + + /** What the phone decided, back to the harness: the plugin is holding the + * ask open, and an answer it is not given is one the user did not make. */ + private async answerHarness(callId: string, answer: Record): Promise { + const socket = this.bridgeSocket; + if (socket === undefined) return; + await askPlugin(socket, { method: 'answer', sessionId: this.nativeId, callId, ...answer }, QUESTION_TIMEOUT_MS, this.ctx.log); + } + + /** What this session's harness can run, asked of the harness itself (the + * command plugin) and cached until the session ends once it has been + * read. A list that could not be read is asked for again next time — the + * plugin may simply not be answering yet — and meanwhile every slash line + * is ordinary text, which is what a harness without the plugin does. */ + private knownCommands(): Promise> { + this.commandNames ??= this.ready + .then(() => this.sessionCommands()) + .then((commands) => { + if (commands === undefined) this.commandNames = undefined; + return new Set((commands ?? []).map((command) => command.name)); + }) + .catch(() => { + this.commandNames = undefined; + return new Set(); + }); + return this.commandNames; + } + + /** The session's commands as its harness lists them, or `undefined` when + * the plugin cannot be asked. */ + private async sessionCommands(): Promise { + const socket = this.bridgeSocket; + const sessionId = this.nativeId; + if (socket === undefined || sessionId === undefined) return undefined; + return listSessionCommands(socket, sessionId, this.ctx.log); + } + + async listCommands(): Promise { + await this.ready; + if (this.ended) return []; + return (await this.sessionCommands()) ?? []; + } + + /** The turn is over: the transcript marks it and the phone stops showing + * the agent as working. Every ending is one of these — a cancelled turn + * and a failed one end the turn too. */ + private endTurn(): void { + if (this.ended) return; + this.deliver({ entryType: 'turn_complete', timestamp: now() }); + this.ctx.emit({ type: 'turn', state: 'idle' }); + } + + async interrupt(): Promise { + const sessionId = this.nativeId; + if (sessionId === undefined || this.ended) return; + // Best effort: an already-idle session has nothing to cancel. + try { + this.client.notify('session/cancel', { sessionId }); + } catch { + // The connection is gone; `ended` has already said so. + } + } + + async setOption(option: SessionOption, value: string): Promise { + switch (option) { + case 'mode': + if (!DEEPSEEK_MODES.some((mode) => mode.id === value)) throw new Error(`${DSH_LABEL} has no mode '${value}'`); + this.mode = value; + return; + case 'model': + await this.setConfig(MODEL_CONFIG_ID, this.resolveModel(value) ?? value); + return; + case 'effort': + await this.setConfig(EFFORT_CONFIG_ID, value); + return; + } + } + + async getUsage(): Promise { + // Nothing to ask for: the harness has no subscription usage, and its + // context meter arrives as session info. + return null; + } + + /** + * The session's MCP servers. The harness reads them when it starts, so + * this reports what the running process has: a server configured after it + * started is not in it yet, and a server the harness complained about + * (on stderr — the only place it says so) is the one failure it reports. + */ + async mcpStatus(): Promise { + const state = await this.deps.mcp.list(); + const process_ = this.process; + const failures = process_?.mcpFailures() ?? new Map(); + const configured = this.deps.mcp.version; + const loaded = process_?.configVersion ?? Number.POSITIVE_INFINITY; + const servers: SessionMcpServer[] = state.servers.map((server) => { + const failure = failures.get(server.name); + // The harness's own word first: a server it complained about has no + // tools, whatever the configuration says. Then a configuration this + // process was not started with, which it therefore has not loaded. + const status: McpStatus = !server.enabled + ? 'disabled' + : failure + ? mcpStatusOf('failed') + : process_?.alive !== true || configured > loaded + ? 'pending' + : 'connected'; + return { name: server.name, status, ...(failure ? { error: failure } : {}) }; + }); + return { servers, toggles: false, projectWide: false }; + } + + async toggleMcp(name: string, enabled: boolean): Promise { + void name; + void enabled; + throw new Error( + `${DSH_LABEL} attaches its MCP servers when it starts, so a running session cannot switch one. ` + + 'Change it in the MCP list: the next session uses the new set.', + ); + } + + async end(): Promise { + if (this.ended) return; + const sessionId = this.nativeId; + const process_ = this.process; + this.ended = true; + if (sessionId !== undefined && process_) { + // Closing the session lets the harness cancel its work and flush; the + // conversation stays on disk for a later resume. + await process_.client.request('session/close', { sessionId }).catch(() => {}); + } + this.abandon(); + } +} + +export interface DeepSeekDriverOptions { + /** The runtime that owns the harness processes. */ + runtime: DeepSeekRuntime; + /** `$DSH_HOME` — where the harness's profiles and sessions live. */ + home: string; + /** The MCP manager over that home's profile layer. The runtime is told to + * watch its version, so the process and the manager share one instance. */ + mcp?: DeepSeekMcp; + /** The environment this driver's own probe inherits (the host's). */ + baseEnv?: NodeJS.ProcessEnv; + /** HTTPS client for the credential check. */ + httpGet?: HttpGet; + /** Spawn seam for the plugin commands, for tests. */ + spawnFn?: SpawnFn; + log: (message: string) => void; +} + +export class DeepSeekDriver implements Driver { + readonly mcp: DeepSeekMcp; + readonly plugins: PluginManager; + private unavailable: string | undefined; + private models?: Promise<{ models: ModelEntry[]; defaultModel?: string }>; + /** + * Everything that has to be true of the harness's profile before a process + * starts: the gateway's catalog (a process reads its catalog once, as it + * boots) and the command bridge (a process mounts its plugins once, too). + * Sessions and the model probe wait for it. + */ + private prepared: Promise = Promise.resolve(); + /** Sessions by the harness's own ids: a process serves several, and it is + * this map that ends them all when it goes away. */ + private readonly sessions = new Map(); + private readonly live = { + events: (): DeepSeekProcessEvents => ({ + ended: (sessionIds: readonly string[], error: string) => { + for (const sessionId of sessionIds) this.sessions.get(sessionId)?.endFromProcess(error); + }, + question: (line: string) => { + const pushed = parseQuestionLine(line, QUESTION_MARKER); + if (pushed?.sessionId !== undefined) this.sessions.get(pushed.sessionId)?.askQuestion(pushed); + }, + }), + add: (sessionId: string, session: DeepSeekSession): void => { + this.sessions.set(sessionId, session); + }, + remove: (sessionId: string): void => { + this.sessions.delete(sessionId); + }, + }; + + private constructor(private readonly options: DeepSeekDriverOptions) { + const profileDir = dshProfileDir(options.home); + this.mcp = options.mcp ?? new DeepSeekMcp({ profileDir, log: options.log }); + this.plugins = new DeepSeekPlugins({ + profileDir, + run: (args) => this.runDsh(args), + packagesDir: () => options.runtime.packagesRoot(), + log: options.log, + }); + } + + static create(options: DeepSeekDriverOptions): DeepSeekDriver { + const driver = new DeepSeekDriver(options); + // The runtime is fetched as the host starts, like the other agents', so + // the first session (and the model list the phone asks for before it) + // finds it ready instead of waiting on a download. + void options.runtime.prepare(); + driver.prepared = driver.prepareProfile(); + return driver; + } + + /** Everything the profile needs before a harness boots (once, at the + * start): the endpoint's catalog, and CodeDeck's own rows in its patch + * layer. */ + private async prepareProfile(): Promise { + await this.syncGateway(); + await this.ownProfile(); + } + + /** + * Put CodeDeck's part of the profile in place: the plugin that carries the + * harness's commands and questions out to the phone, and the tool rows the + * profile's own bundles leave out. Each is independent and each failure + * costs only itself: it is logged, and the session runs on. + */ + private async ownProfile(): Promise { + const profileDir = dshProfileDir(this.options.home); + try { + await installHarnessPlugin(profileDir, this.options.log); + } catch (error) { + this.options.log(`[deepseek] could not install the command bridge: ${error instanceof Error ? error.message : String(error)}`); + } + try { + await installProfileTools(profileDir, this.options.log); + } catch (error) { + this.options.log( + `[deepseek] could not mount ${ASK_USER_TOOL}, so the model has no question tool: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + /** + * Point the harness at the operator's endpoint, with the models that + * endpoint serves (gateway.ts): without this a session on a gateway would + * be sent a DeepSeek model name the gateway does not know, and the phone + * would offer models that are not there. + */ + private async syncGateway(): Promise { + const env = this.options.baseEnv ?? process.env; + try { + await syncGatewayCatalog( + { profileDir: dshProfileDir(this.options.home), log: this.options.log, ...(this.options.httpGet ? { httpGet: this.options.httpGet } : {}) }, + env[DEEPSEEK_BASE_URL_ENV]?.trim() || undefined, + env[DEEPSEEK_API_KEY_ENV]?.trim() || undefined, + ); + } catch (error) { + // The catalog is a convenience, never a reason to refuse to run: the + // harness's own models still work. + this.options.log(`[deepseek] could not write the gateway's catalog: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** Mark the driver unusable (a path the operator named is not a file). */ + setUnavailable(reason: string): void { + this.unavailable = reason; + } + + info(): AgentInfo { + return { + id: DEEPSEEK_AGENT_ID, + displayName: DSH_LABEL, + modes: DEEPSEEK_MODES, + efforts: DEEPSEEK_EFFORTS, + defaultMode: DEFAULT_MODE, + defaultEffort: DEFAULT_EFFORT, + // What the harness's automation profile carries, and what it does not: + // no slash commands, no background tasks, no subscription usage. Its + // plugins and MCP servers are its own configuration files, which this + // driver reads and writes. + supports: { + models: true, + usage: false, + providers: true, + gsd: true, + interrupt: true, + // Through the plugin this driver installs into the profile + // (plugin.ts): the harness's command registry is reachable from + // inside its process, never over ACP. + commands: true, + plugins: true, + mcp: true, + tasks: false, + }, + credentials: [{ id: DEEPSEEK_API_KEY_CREDENTIAL, label: 'DeepSeek API key', envVar: 'DEEPSEEK_API_KEY' }], + ...(this.unavailable ? { unavailableReason: this.unavailable } : {}), + }; + } + + startSession(params: StartSession, ctx: SessionContext): DriverSession { + if (this.unavailable) throw new Error(this.unavailable); + if (params.mode !== undefined && !DEEPSEEK_MODES.some((mode) => mode.id === params.mode)) { + throw new Error(`${DSH_LABEL} has no mode '${params.mode}'`); + } + return new DeepSeekSession(params, ctx, { + runtime: this.options.runtime, + mcp: this.mcp, + baseEnv: this.options.baseEnv ?? process.env, + live: this.live, + prepared: this.prepared, + }); + } + + /** + * The harness's model catalog, from a short-lived probe session: the option + * state a session starts with is where its catalog lives, and reading it + * needs neither a credential nor a reachable endpoint (it is the profile's + * own, declarative list). Cached once it says something, and asked again + * after a failure or an empty answer — an empty catalog is not a catalog. + */ + listModels(): Promise<{ models: ModelEntry[]; defaultModel?: string }> { + this.models ??= this.probeModels().catch((error: unknown) => { + this.models = undefined; + this.options.log(`[deepseek] could not list models: ${error instanceof Error ? error.message : String(error)}`); + return { models: [] }; + }); + return this.models; + } + + private async probeModels(): Promise<{ models: ModelEntry[]; defaultModel?: string }> { + await this.prepared; + const env = buildDeepSeekEnv({}, this.options.baseEnv ?? process.env); + const process_ = await this.options.runtime.acquire(env, { ended: () => {} }); + try { + const opened = await process_.client.request('session/new', { cwd: this.options.home, mcpServers: [] }); + const option = (opened.configOptions ?? []).find((entry) => entry.id === MODEL_CONFIG_ID); + const choices = modelChoices(option); + const models: ModelEntry[] = choices.map(toModelEntry); + if (models.length === 0) this.models = undefined; + // The probe's own conversation is disposed of; nothing of it survives + // but an empty session record in the harness's home. + await process_.client.request('session/close', { sessionId: opened.sessionId }).catch(() => {}); + const current = choices.find((choice) => choice.value === option?.currentValue); + return { + models, + ...(current ? { defaultModel: toModelEntry(current).id } : {}), + }; + } finally { + process_.release(); + } + } + + /** + * Check the key against the endpoint it is for: the operator's gateway when + * one is configured (its `/models` is the one call every gateway has in + * common — the same one this driver reads the catalog from), else the + * DeepSeek API. A refusal is a refusal; anything else says nothing. + */ + async checkCredential(credential: string, value: string): Promise { + if (credential !== DEEPSEEK_API_KEY_CREDENTIAL || !this.options.httpGet) return undefined; + const baseEnv = this.options.baseEnv ?? process.env; + const base = baseEnv[DEEPSEEK_BASE_URL_ENV]?.trim(); + try { + const res = await this.options.httpGet(base ? gatewayModelsUrl(base) : 'https://api.deepseek.com/models', { + authorization: `Bearer ${value}`, + }); + return res.status !== 401 && res.status !== 403; + } catch { + return undefined; + } + } + + /** + * One `dsh plugin` invocation, through the same CLI the sessions run. It + * runs pnpm in the profile, whose `node_modules` also holds CodeDeck's own + * plugin — a package no lockfile lists, which an install is free to prune — + * so CodeDeck's part of the profile is put back after every run. + */ + private async runDsh(args: string[]): Promise { + const entry = await this.options.runtime.entryPoint(); + try { + return this.options.spawnFn === undefined + ? await runDshPlugin(entry, this.options.home, DSH_PROFILE, args) + : await runDshPlugin(entry, this.options.home, DSH_PROFILE, args, this.options.spawnFn); + } finally { + await this.ownProfile(); + } + } + + async shutdown(): Promise { + await this.options.runtime.close(); + } +} diff --git a/packages/agent-host/src/drivers/deepseek/env.ts b/packages/agent-host/src/drivers/deepseek/env.ts new file mode 100644 index 00000000..b66a9a7d --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/env.ts @@ -0,0 +1,124 @@ +/** + * The DeepSeek Harness child's environment for one spawn: the bridge's stored + * credential and the session's provider binding, turned into the variables + * the harness reads. The returned object carries SECRETS — never log it. + * + * The harness takes its endpoint and key from `DEEPSEEK_BASE_URL` and + * `DEEPSEEK_API_KEY` (its DeepSeek route reads exactly this pair when its own + * plugin config names no endpoint), so a custom provider — a gateway such as + * Claude Code Router, a relay — is the same session shape Claude Code's + * `ANTHROPIC_BASE_URL` is: point the base URL at it and hand it the token. + * Its protocol then decides what the gateway must speak; the default here is + * the DeepSeek messages API, which is what a DeepSeek-compatible relay + * serves. + */ +import { isValidProviderBaseUrl, PROVIDER_BASE_URL_ERROR } from '../../provider'; +import type { ProviderBinding, StartSession } from '../../types'; + +export const DEEPSEEK_API_KEY_CREDENTIAL = 'deepseek_api_key'; +/** The key the harness reads. */ +export const DEEPSEEK_API_KEY_ENV = 'DEEPSEEK_API_KEY'; +/** The endpoint the harness reads; unset means its own public API. */ +export const DEEPSEEK_BASE_URL_ENV = 'DEEPSEEK_BASE_URL'; + +/** + * The environment-name namespace a provider-bound session inherits NOTHING + * from. `DEEPSEEK_BASE_URL` is where a session is routed and + * `DEEPSEEK_API_KEY` is what it authenticates with; both are replaced by the + * provider's. A prefix rather than the two names: the harness is a developer + * preview whose plugin set moves, and a routing or credential variable added + * under its vendor prefix must be dropped by default rather than silently + * honoured. + * + * `DSH_` is deliberately NOT in this list: those name the harness's own home + * and profile layout, which a gateway does not change, and dropping + * `DSH_HOME` would silently move a session's state to `~/.dsh`. + */ +const VENDOR_ENV_PREFIXES = ['DEEPSEEK_'] as const; + +/** Whether a variable is in the harness's endpoint/credential namespace. */ +export function isDeepSeekEnvName(name: string): boolean { + const upper = name.toUpperCase(); + return VENDOR_ENV_PREFIXES.some((prefix) => upper.startsWith(prefix)); +} + +/** The base environment for a session bound to a CUSTOM provider: everything + * inherited except the harness's own endpoint/credential namespace. */ +export function sanitizeDeepSeekBaseEnv(baseEnv: Record): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (value === undefined || isDeepSeekEnvName(key)) continue; + env[key] = value; + } + return env; +} + +/** + * Take the harness's endpoint and key out of `env` (the host's own + * environment, which every agent process inherits) and answer the + * environment the harness's driver runs with: the whole of it, those + * included. They are the operator's settings for this one agent, and another + * agent reads its own meaning into them — OpenCode, for one, switches on a + * DeepSeek provider of its own for any `DEEPSEEK_API_KEY` it finds. + */ +export function takeDeepSeekEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const harness = { ...env }; + for (const key of Object.keys(env)) { + if (isDeepSeekEnvName(key)) delete env[key]; + } + return harness; +} + +function inherited(baseEnv: Record): Record { + const env: Record = {}; + for (const [key, value] of Object.entries(baseEnv)) { + if (value !== undefined) env[key] = value; + } + return env; +} + +/** + * The environment for one spawn. Without a provider: the inherited env plus + * `DEEPSEEK_API_KEY` — the operator's own environment key wins over a stored + * one — and the bridge-level `env` (e.g. `GITHUB_TOKEN`, stored wins). + * + * With a provider: the sanitized base env and the provider's own base URL and + * token. Throws for an insecure base URL or a missing token — the session is + * then refused loudly; falling back to the native key would bill the wrong + * account. + */ +export function buildDeepSeekEnv( + params: Pick, + baseEnv: Record = process.env, +): Record { + const extra = params.env ?? {}; + const provider = params.provider ?? undefined; + if (provider) return providerEnv(provider, extra, baseEnv); + + const env = inherited(baseEnv); + const key = baseEnv[DEEPSEEK_API_KEY_ENV] || params.credentials?.[DEEPSEEK_API_KEY_CREDENTIAL]; + if (key) env[DEEPSEEK_API_KEY_ENV] = key; + Object.assign(env, extra); + return env; +} + +function providerEnv( + provider: ProviderBinding, + extra: Record, + baseEnv: Record, +): Record { + // Checked before the token: this decides whether the token may travel on + // this connection at all. The base URL is not a secret. + if (!isValidProviderBaseUrl(provider.baseUrl)) { + throw new Error( + `provider profile '${provider.id}' has an insecure base URL (${provider.baseUrl}) — ` + + `${PROVIDER_BASE_URL_ERROR}. Its API token would travel in cleartext, so the session is refused.`, + ); + } + if (!provider.authToken) throw new Error(`provider profile '${provider.id}' has no stored auth token`); + const env = sanitizeDeepSeekBaseEnv(baseEnv); + env[DEEPSEEK_BASE_URL_ENV] = provider.baseUrl; + env[DEEPSEEK_API_KEY_ENV] = provider.authToken; + Object.assign(env, extra); + return env; +} diff --git a/packages/agent-host/src/drivers/deepseek/gateway.ts b/packages/agent-host/src/drivers/deepseek/gateway.ts new file mode 100644 index 00000000..9e4f2e18 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/gateway.ts @@ -0,0 +1,200 @@ +/** + * A gateway the harness is pointed at, end to end. + * + * An operator who sets `DEEPSEEK_BASE_URL` is saying "run sessions on this + * endpoint". The harness takes that endpoint from the environment (its + * DeepSeek route reads exactly that variable), but its *model catalog* is its + * own, fixed list of DeepSeek model names — so a gateway with its own models + * would be sent a name it does not know, and the phone would offer models the + * gateway cannot serve. What the gateway serves is the one thing only the + * gateway can say, and it says it at `/models` (the OpenAI-shaped list every + * gateway exposes), on the same root the harness itself appends `/v1` to and + * then posts `/messages` to. + * + * So this module asks the gateway for that list and writes it into the + * harness's own profile, as the deployment's catalog for the native route: + * a patch row over the `llm-deepseek` entry, which is how the harness + * documents its catalog as replaceable. From the next harness start, the + * phone offers exactly what the gateway serves and a session can select any + * of it. No gateway configured (or one that does not answer) means no row at + * all: the harness's own catalog stands, and nothing is guessed. + */ +import * as path from 'node:path'; +import { dump } from 'js-yaml'; +import type { HttpGet } from '../../net'; +import { ProfileLayer, type LayerBlock } from './profileLayer'; + +/** Our block in the profile's patch layer. */ +const BLOCK: LayerBlock = { + begin: "# --- CodeDeck+ gateway catalog: written from the gateway's own model list; everything outside this block is yours ---", + end: '# --- end CodeDeck+ gateway catalog ---', +}; + +/** The entry that mounts the native DeepSeek adapter; its config carries the + * endpoint and the catalog. */ +const PROVIDER_ROW = 'llm-deepseek'; +/** The entries naming the provider and model a session starts on. They have + * to move with the catalog: the harness always offers the model it is on, + * so a default the gateway does not serve would appear as one nobody can + * run. `acp` is the profile's own selection (what a new session starts + * with); `agent-default-model` is the agent module's. */ +const SESSION_MODEL_ROW = 'acp'; +const DEFAULT_MODEL_ROW = 'agent-default-model'; +/** The provider the native route is registered under. */ +const NATIVE_PROVIDER = 'deepseek-official'; + +/** How many of a gateway's models are kept: a catalog is for a phone screen. */ +const MAX_MODELS = 200; + +/** One model, as a catalog entry. */ +export interface GatewayModel { + id: string; + name?: string; + contextWindow?: number; +} + +export interface GatewayCatalog { + /** The endpoint as the harness should read it. */ + baseUrl: string; + models: GatewayModel[]; + /** The model sessions start on: the gateway's own first entry, in the + * order it lists them. */ + defaultModel: string; +} + +/** + * The list a gateway serves, or `undefined` when it could not be read (an + * unreachable endpoint, a refusal, an answer that is not a model list). The + * base URL is normalised the way the harness normalises it, so the same + * variable points at the same endpoint for both. + */ +export async function fetchGatewayCatalog( + baseUrl: string, + key: string | undefined, + httpGet: HttpGet, + log: (message: string) => void, +): Promise { + const url = gatewayModelsUrl(baseUrl); + let body: unknown; + try { + const response = await httpGet(url, key ? { authorization: `Bearer ${key}` } : {}); + if (response.status < 200 || response.status >= 300) { + log(`[deepseek] the gateway at ${baseUrl} answered ${response.status} for its model list`); + return undefined; + } + body = readJson(response); + } catch (error) { + log(`[deepseek] could not read the gateway's model list from ${url}: ${error instanceof Error ? error.message : String(error)}`); + return undefined; + } + const models = parseModels(body); + if (models.length === 0) { + log(`[deepseek] the gateway at ${baseUrl} listed no models; leaving the harness's own catalog in place`); + return undefined; + } + return { baseUrl: baseUrl.replace(/\/+$/, ''), models, defaultModel: models[0]!.id }; +} + +/** The harness's own rule for where a provider's list of models lives: the + * root ends in `/v1` (the endpoint `/messages` is posted to lives beside + * it). Mirrored here so one setting configures both. */ +export function gatewayModelsUrl(baseUrl: string): string { + const base = baseUrl.replace(/\/+$/, ''); + const rooted = new URL(base).pathname.endsWith('/v1') ? base : `${base}/v1`; + return `${rooted}/models`; +} + +/** The model ids in a gateway's answer: the OpenAI-shaped `data` array, a + * bare array, or a `models` array. Anything else yields nothing. */ +export function parseModels(body: unknown): GatewayModel[] { + const list = Array.isArray(body) + ? body + : typeof body === 'object' && body !== null + ? ((body as { data?: unknown }).data ?? (body as { models?: unknown }).models) + : undefined; + if (!Array.isArray(list)) return []; + const models: GatewayModel[] = []; + const seen = new Set(); + for (const entry of list) { + const id = + typeof entry === 'string' + ? entry + : typeof entry === 'object' && entry !== null + ? (entry as { id?: unknown; name?: unknown }).id + : undefined; + if (typeof id !== 'string' || id.trim() === '' || seen.has(id)) continue; + seen.add(id); + const context = typeof entry === 'object' && entry !== null ? contextWindowOf(entry as Record) : undefined; + const name = typeof entry === 'object' && entry !== null && typeof (entry as { name?: unknown }).name === 'string' ? (entry as { name: string }).name : undefined; + models.push({ id, ...(name && name !== id ? { name } : {}), ...(context !== undefined ? { contextWindow: context } : {}) }); + if (models.length >= MAX_MODELS) break; + } + return models; +} + +/** A model's context size, when the gateway states one (OpenAI-compatible + * gateways spell it several ways). */ +function contextWindowOf(entry: Record): number | undefined { + for (const field of ['context_window', 'context_length', 'contextWindow', 'max_input_tokens']) { + const value = entry[field]; + if (typeof value === 'number' && Number.isSafeInteger(value) && value > 0) return value; + } + return undefined; +} + +function readJson(response: Awaited>): unknown { + return JSON.parse(response.text ?? '') as unknown; +} + +/** + * The patch rows for a gateway's catalog: the catalog itself, and the model + * a session starts on. + * + * Never the endpoint. The profile is shared by every harness process, and + * the route takes a `baseURL` in its config over `DEEPSEEK_BASE_URL` in the + * environment — so an endpoint written here would also be where a session + * bound to a provider profile sent that profile's token, whatever its own + * environment named. The operator's process finds the gateway in the + * environment it already has. + */ +export function renderCatalogLayer(catalog: GatewayCatalog): string { + const models = catalog.models.map((model) => ({ + id: model.id, + ...(model.name !== undefined ? { name: model.name } : {}), + ...(model.contextWindow !== undefined ? { contextWindow: model.contextWindow } : {}), + })); + const selection = { provider: NATIVE_PROVIDER, model: catalog.defaultModel }; + const rows = [ + { id: PROVIDER_ROW, config: { models } }, + { id: SESSION_MODEL_ROW, config: selection }, + { id: DEFAULT_MODEL_ROW, config: selection }, + ]; + return dump(rows, { lineWidth: -1, noRefs: true, quotingType: "'" }).trimEnd(); +} + +export interface GatewaySyncOptions { + /** The profile directory (`$DSH_HOME/profiles/acp`). */ + profileDir: string; + log: (message: string) => void; + httpGet?: HttpGet; +} + +/** + * Write the gateway's catalog into the harness profile (or take our row out + * when no gateway is configured), and answer whether the layer changed. + */ +export async function syncGatewayCatalog( + options: GatewaySyncOptions, + baseUrl: string | undefined, + key: string | undefined, +): Promise { + const layer = new ProfileLayer(path.join(options.profileDir, 'cordis.patch.yml'), options.log); + if (!baseUrl || !options.httpGet) return layer.set(BLOCK, undefined); + const catalog = await fetchGatewayCatalog(baseUrl, key, options.httpGet, options.log); + if (!catalog) return false; + const changed = await layer.set(BLOCK, renderCatalogLayer(catalog)); + if (changed) { + options.log(`[deepseek] ${catalog.models.length} models from ${catalog.baseUrl} now make up the harness's catalog`); + } + return changed; +} diff --git a/packages/agent-host/src/drivers/deepseek/install.ts b/packages/agent-host/src/drivers/deepseek/install.ts new file mode 100644 index 00000000..4c073d9c --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/install.ts @@ -0,0 +1,33 @@ +/** + * Where the DeepSeek Harness runtime comes from when the machine has none: + * `@deepseek-ai/dsh` and its whole dependency closure, installed on demand + * (agentInstall.installPackageTree) at the version and sha512 pnpm-lock.yaml + * pins (src/generated/dshPackages.ts). The release archives and the default + * image ship no part of it — a driver that finds nothing on the machine + * downloads the tree the first time it needs it. + * + * The harness is a pure-JS CLI: nothing runs until every package of that + * closure sits in one `node_modules`, which is what the tree installer + * reproduces. `lib/bin.js` is the CLI's entry point, run with the host's own + * `node`. + */ +import * as path from 'node:path'; +import { installPackageTree, type InstallOptions } from '../../agentInstall'; +import { DSH_PACKAGES } from '../../generated/dshPackages'; + +/** The npm package the harness ships as. */ +export const DSH_PACKAGE = '@deepseek-ai/dsh'; +/** Its CLI, relative to the installed package directory. */ +const DSH_ENTRY = 'lib/bin.js'; +/** How the driver refers to it in progress lines and errors. */ +export const DSH_LABEL = 'DeepSeek Harness'; + +/** The CLI entry point of a tree the installer has laid down. */ +export function dshEntryPoint(treeRoot: string): string { + return path.join(treeRoot, DSH_ENTRY); +} + +/** Install (or find) the harness tree and return its CLI entry point. */ +export async function installDshTree(options: InstallOptions): Promise { + return dshEntryPoint(await installPackageTree(DSH_PACKAGE, DSH_PACKAGES, { ...options, label: DSH_LABEL })); +} diff --git a/packages/agent-host/src/drivers/deepseek/mcp.ts b/packages/agent-host/src/drivers/deepseek/mcp.ts new file mode 100644 index 00000000..93498170 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/mcp.ts @@ -0,0 +1,245 @@ +/** + * The DeepSeek Harness's MCP servers: the `dsh-mcp-client` rows a profile's + * own patch layer holds. + * + * The harness reads a profile as an ordered stack of patch layers, and + * `$DSH_HOME/profiles//cordis.patch.yml` is the last of them — the + * user's own layer, the file the harness's own documentation says to edit. + * Rows added there start with the harness, so they are part of the harness's + * configuration rather than of this bridge: any `dsh --profile acp` run gets + * them, and so does the next bridge that runs the same home. + * + * Only a delimited block of that file is this bridge's — everything around + * it, comments included, is read, kept and written back untouched. The block + * is a patch list: one `insert` row per server, and a `disabled` row after + * the ones switched off (a row addressed by id later in the same layer wins). + * + * The servers are attached when the harness starts, in the harness's own + * working directory, and a server that fails to start is logged rather than + * fatal (the harness's log is where the status screen reads it from). A + * session therefore shows them but cannot switch one: switching means + * restarting the harness, which is what happens to the whole home's sessions + * when this file changes and none is running. + * + * ACP could carry the same servers per session instead. That path was not + * taken: the harness fails `session/new` outright when one of them cannot + * start, so a single mistyped server would lock the user out of every + * session, and each session would pay its own connections. + */ +import * as path from 'node:path'; +import { load, dump } from 'js-yaml'; +import type { McpManager, McpState } from '../../driver'; +import { serverInfo } from '../../mcp'; +import type { McpAction, McpServerAdd, McpServerInfo } from '../../types'; +import { ProfileLayer, type LayerBlock } from './profileLayer'; + +/** The plugin every managed row mounts. */ +const MCP_CLIENT_PLUGIN = '@deepseek-ai/dsh-mcp-client'; +/** Which servers are this bridge's, among the profile's other rows. */ +const ROW_PREFIX = 'codedeck-mcp-'; +/** This manager's own block in that layer (profileLayer.ts). */ +const BLOCK: LayerBlock = { + begin: '# --- CodeDeck+ MCP servers: managed from the MCP screen; everything outside this block is yours ---', + end: '# --- end CodeDeck+ MCP servers ---', +}; + +/** The harness's own rule for a server namespace, so a name this bridge + * writes is one the harness accepts (`mcp____` names follow). */ +const SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/; + +/** A server as this manager knows it: what the phone shows, and what to write + * back. */ +interface ManagedServer extends McpServerInfo { + /** The row's own configuration, as the harness's plugin takes it. */ + config: Record; +} + +export interface DeepSeekMcpOptions { + /** The profile directory (`$DSH_HOME/profiles/acp`). */ + profileDir: string; + log: (message: string) => void; +} + +/** One patch document of the managed block. */ +type PatchDoc = Record; + +export class DeepSeekMcp implements McpManager { + /** One change at a time: each reads the block, edits it, writes it back. */ + private queue: Promise = Promise.resolve(); + private readonly layer: ProfileLayer; + /** How many times this manager changed the layer. A harness process records + * it as it starts, so a change made afterwards is configuration that + * process has not loaded — no file timestamp, and no clock, involved. */ + private versions = 0; + + constructor(options: DeepSeekMcpOptions) { + this.layer = new ProfileLayer(path.join(options.profileDir, 'cordis.patch.yml'), options.log); + } + + /** How many times the layer has changed since this bridge started. */ + get version(): number { + return this.versions; + } + + list(): Promise { + return this.serial(async () => this.state(await this.servers())); + } + + act(action: McpAction, servers: McpServerAdd[], names: string[]): Promise { + return this.serial(async () => { + const current = await this.servers(); + const next = applyAction(current, action, servers, names); + if (await this.layer.set(BLOCK, next.length === 0 ? undefined : renderRows(next))) this.versions++; + return this.state(next); + }); + } + + private serial(work: () => Promise): Promise { + const next = this.queue.then(work, work); + this.queue = next.catch(() => {}); + return next; + } + + private state(servers: ManagedServer[]): McpState { + return { + servers: servers.map(({ config: _config, ...info }) => info).sort((a, b) => a.name.localeCompare(b.name)), + // A server can be switched off without being removed: its row stays, + // with a `disabled` row after it. + toggles: true, + }; + } + + /** The servers in the managed block, in the order they are written. */ + private async servers(): Promise { + const block = await this.layer.body(BLOCK); + if (block === undefined) return []; + let docs: unknown; + try { + docs = load(block); + } catch (error) { + throw new Error( + `the DeepSeek Harness profile has a CodeDeck MCP block that is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return serversOf(Array.isArray(docs) ? (docs as PatchDoc[]) : []); + } +} + +/** The rows of the managed block, as the patch list the harness loads. */ +function renderRows(servers: ManagedServer[]): string { + const docs: PatchDoc[] = []; + for (const server of servers) { + docs.push({ insert: [{ id: `${ROW_PREFIX}${server.name}`, name: MCP_CLIENT_PLUGIN, config: server.config }] }); + if (!server.enabled) docs.push({ id: `${ROW_PREFIX}${server.name}`, disabled: true }); + } + return dump(docs, { lineWidth: -1, noRefs: true, quotingType: "'" }).trimEnd(); +} + +/** The servers a managed block describes, the last row per id winning. */ +function serversOf(docs: PatchDoc[]): ManagedServer[] { + const byName = new Map(); + for (const doc of docs) { + const insert = doc.insert; + if (Array.isArray(insert)) { + for (const row of insert as PatchDoc[]) { + if (row.name !== MCP_CLIENT_PLUGIN) continue; + const config = record(row.config); + const name = typeof config.serverName === 'string' ? config.serverName : undefined; + if (!name) continue; + byName.set(name, { ...infoOf(name, config), config, enabled: true }); + } + continue; + } + const id = typeof doc.id === 'string' ? doc.id : ''; + if (doc.disabled === true && id.startsWith(ROW_PREFIX)) { + const server = byName.get(id.slice(ROW_PREFIX.length)); + if (server) server.enabled = false; + } + } + return [...byName.values()]; +} + +/** A server's row, as the phone shows it — never a secret value. */ +function infoOf(name: string, config: Record): McpServerInfo { + const keys = (value: unknown) => (Object.keys(record(value)).length > 0 ? Object.keys(record(value)) : []); + if (config.transport === 'stdio') { + return serverInfo({ + name, + transport: 'stdio', + command: typeof config.command === 'string' ? config.command : '', + envKeys: keys(config.env), + enabled: true, + }); + } + return serverInfo({ + name, + transport: 'http', + url: typeof config.url === 'string' ? config.url : '', + headerKeys: keys(config.headers), + enabled: true, + }); +} + +/** The managed rows for one added server, as the harness's plugin takes it. */ +function serverConfig(add: McpServerAdd): Record { + const setup = add.setup; + const name = add.name.trim(); + if (!SERVER_NAME.test(name)) { + throw new Error( + `'${add.name}' is not a usable MCP server name for the DeepSeek Harness: it takes 1–32 letters, digits, ` + + 'underscores or hyphens (the name becomes part of every tool the server contributes).', + ); + } + const common = { serverName: name, failOnStartupError: true }; + if (setup.type === 'stdio') { + return { + ...common, + transport: 'stdio', + command: setup.command, + ...(setup.args?.length ? { args: [...setup.args] } : {}), + ...(setup.env && Object.keys(setup.env).length > 0 ? { env: { ...setup.env } } : {}), + }; + } + if (setup.type === 'sse') { + throw new Error('The DeepSeek Harness reaches MCP servers over stdio or streamable HTTP, not SSE.'); + } + return { + ...common, + transport: 'streamable-http', + url: setup.url, + ...(setup.headers && Object.keys(setup.headers).length > 0 ? { headers: { ...setup.headers } } : {}), + }; +} + +/** The list after one change. Unknown names are refused with the reason. */ +function applyAction( + current: ManagedServer[], + action: McpAction, + servers: McpServerAdd[], + names: string[], +): ManagedServer[] { + if (action === 'add') { + const next = [...current]; + for (const add of servers) { + const config = serverConfig(add); + const name = add.name.trim(); + const at = next.findIndex((server) => server.name === name); + const entry: ManagedServer = { ...infoOf(name, config), config, enabled: true }; + // Adding a server that is already there replaces it, keeping whatever + // the user had switched it to. + if (at >= 0) entry.enabled = next[at]!.enabled; + if (at >= 0) next[at] = entry; + else next.push(entry); + } + return next; + } + const wanted = new Set(names); + const missing = names.find((name) => !current.some((server) => server.name === name)); + if (missing) throw new Error(`The DeepSeek Harness has no MCP server named '${missing}'.`); + if (action === 'remove') return current.filter((server) => !wanted.has(server.name)); + return current.map((server) => (wanted.has(server.name) ? { ...server, enabled: action === 'enable' } : server)); +} + +function record(value: unknown): Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : {}; +} diff --git a/packages/agent-host/src/drivers/deepseek/plugin.ts b/packages/agent-host/src/drivers/deepseek/plugin.ts new file mode 100644 index 00000000..006f00fc --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/plugin.ts @@ -0,0 +1,302 @@ +/** + * The plugin that carries what the harness's ACP surface does not: its slash + * commands, and the questions its model asks the user. + * + * Both exist in this profile and neither is reachable from outside it. ACP has + * no command list and no way to invoke one, and the harness keeps commands and + * human-interaction for its own UI modules; the same is true of the + * `user-questions` service, whose answerer is a browser panel and which every + * ask goes through — the question tool, the timed one, and the plan review + * `exit_plan_mode` presents. Inside the process both are ordinary services. + * + * So CodeDeck brings its own transport: this plugin, installed into the + * profile, holds the command registry and composes the questions answerer, and + * talks to the agent host over a local socket. It is ours, which is the point: + * no harness API is patched, and a harness that changes under it leaves + * commands or questions unanswered (the driver treats every failure that way) + * rather than breaking a session. + * + * The plugin is a package in the profile's own `node_modules`, written from + * the source below — dsh resolves a row's plugin by name from there, so no + * package manager is involved (and installing one later can prune the + * directory, which is why it is rewritten when the host starts). + * + * Two channels, deliberately: questions are *pushed* to the host as + * marker-prefixed lines on stderr (the one stream the harness leaves free — + * stdout is ACP), and every answer, like every command asked for, is a request + * on the socket. + */ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import { ProfileLayer, type LayerBlock } from './profileLayer'; + +/** The package name the profile's row refers to. */ +export const HARNESS_PLUGIN = 'codedeck-dsh-bridge'; + +/** This plugin's block in the profile's patch layer. */ +const BLOCK: LayerBlock = { + begin: '# --- CodeDeck+ bridge: brings this profile its slash commands and its questions, over a local socket; everything outside this block is yours ---', + end: '# --- end CodeDeck+ bridge ---', +}; + +/** Where a pushed question starts on the harness's stderr (the host reads + * these lines and never logs them as harness output). */ +export const QUESTION_MARKER = 'codedeck-question:'; + +/** The variable that names a harness process's own socket. The profile is + * shared by every process the runtime starts, so the path cannot live in + * the plugin's row; the runtime sets it per spawn. */ +export const BRIDGE_SOCKET_ENV = 'CODEDECK_DSH_BRIDGE_SOCKET'; + +/** + * The plugin, as it is written to disk. Plain JavaScript: the harness imports + * it as it is, so nothing here may need a build step, and it must not import + * anything of CodeDeck's — the profile has no idea this host exists. + */ +const SOURCE = `import { randomUUID } from 'node:crypto'; +import { mkdirSync, realpathSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import net from 'node:net'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const name = 'codedeck-bridge'; +export const inject = ['agents', 'commands', 'userQuestions']; + +const QUESTION_MARKER = 'codedeck-question:'; +const SOCKET_ENV = '${BRIDGE_SOCKET_ENV}'; + +/** + * The harness's own message constructor, out of the installation this + * process runs (its CLI is the script node was started with): a message the + * agent takes must be the harness's own shape, and this package has no + * dependencies to bring one. Undefined when it cannot be found, which only + * means a message waits for the turn instead of steering it. + */ +let userMessageFactory; +function userMessageConstructor() { + userMessageFactory ??= (async () => { + // Through its real path, as node resolved the CLI itself: an installed + // CLI is often a link into a package store, whose neighbours are there. + const harness = createRequire(realpathSync(path.resolve(process.argv[1] ?? '.'))); + const llm = await import(pathToFileURL(harness.resolve('@deepseek-ai/dsh-llm')).href); + return typeof llm.createUserMessage === 'function' ? llm.createUserMessage : undefined; + })().catch(() => undefined); + return userMessageFactory; +} + +/** + * Serve the host's socket, and answer the harness's questions over it. + * @param ctx - the profile's plugin context. + */ +export function apply(ctx) { + // The socket is this process's own, named by the host that spawned it. A + // harness started any other way — the plugin CLI, a person running the + // profile by hand — has no host to answer, and putting its questions on + // stderr would leave them waiting forever: the harness's own "no answerer" + // is the honest outcome there. + const socket = process.env[SOCKET_ENV]; + if (typeof socket !== 'string' || socket === '') return; + + /** Questions waiting for the host's answer, by call id. */ + const pending = new Map(); + /** Whether the host can reach this process. Until it can — and if it never + * can — a question is not ours to take. */ + let listening = false; + + ctx.on('user-questions/request', async (request, next) => { + const questions = Array.isArray(request?.questions) ? request.questions : []; + const sessionId = request?.agent?.session?.id; + // A request this bridge cannot show — nothing asked, no live session to + // put it to, no socket the answer could come back on — goes on down the + // chain, where the harness's own answerer or its "no answerer" error has + // it. That hand-off is next(): a listener that simply returns has *vetoed* + // the chain, which leaves the caller with undefined where an answer batch + // belongs. + if (!listening || questions.length === 0 || typeof sessionId !== 'string') return next(); + // The card is keyed by the tool call. An ask usually names it (in wait; + // the timed one adds the deadline), a plan review names it on the + // question's intent instead, and anything else gets a key of ours — it + // only has to come back with the answer. + const callId = + [request?.wait?.callId, ...questions.map((question) => question?.intent?.callId)].find( + (id) => typeof id === 'string' && id !== '', + ) ?? randomUUID(); + const job = {}; + job.promise = new Promise((resolve, reject) => { + job.resolve = resolve; + job.reject = reject; + }); + pending.set(callId, job); + // The host learns about the question on stderr — the one stream that is + // not ACP's — and answers on the socket. + process.stderr.write(QUESTION_MARKER + JSON.stringify({ + sessionId, + callId, + questions: request.questions, + }) + '\\n'); + const onAbort = () => { + pending.delete(callId); + job.reject(new Error('the question was cancelled')); + }; + request.signal?.addEventListener('abort', onAbort, { once: true }); + try { + return await job.promise; + } finally { + pending.delete(callId); + request.signal?.removeEventListener('abort', onAbort); + } + }); + + const server = net.createServer((connection) => { + connection.setEncoding('utf8'); + let buffered = ''; + connection.on('data', (chunk) => { + buffered += chunk; + for (;;) { + const end = buffered.indexOf('\\n'); + if (end < 0) break; + const line = buffered.slice(0, end); + buffered = buffered.slice(end + 1); + if (line.trim() !== '') void respond(ctx, connection, line, pending); + } + }); + connection.on('error', () => {}); + }); + // A named pipe is not a file; only a socket path has a directory to make + // and a stale file (a process that was killed) to clear. + const isPipe = socket.startsWith('\\\\\\\\.\\\\pipe\\\\'); + if (!isPipe) { + mkdirSync(path.dirname(socket), { recursive: true }); + rmSync(socket, { force: true }); + } + server.on('listening', () => { + listening = true; + }); + server.on('error', (error) => { + listening = false; + process.stderr.write('codedeck-bridge: cannot listen on ' + socket + ': ' + error.message + '\\n'); + }); + server.listen(socket); + ctx.effect(() => () => { + listening = false; + server.close(); + if (!isPipe) rmSync(socket, { force: true }); + }, 'codedeck.bridge'); +} + +/** One socket request: what a session can run, running a line of it, a + * message for the running turn, or the answer to a question. */ +async function respond(ctx, connection, line, pending) { + const reply = (body) => connection.write(JSON.stringify(body) + '\\n'); + let request; + try { + request = JSON.parse(line); + } catch { + reply({ ok: false, error: 'malformed request' }); + return; + } + const id = request?.id; + try { + if (request.method === 'answer') { + const job = pending.get(request.callId); + if (job === undefined) { + reply({ id, ok: false, error: 'that question is no longer waiting' }); + return; + } + pending.delete(request.callId); + if (request.answer === undefined) job.reject(new Error('the user did not answer')); + else job.resolve({ answers: request.answer }); + reply({ id, ok: true }); + return; + } + const agent = ctx.get('agents')?.get(request?.sessionId); + if (agent === undefined) { + reply({ id, ok: false, error: 'no live session with that id' }); + return; + } + if (request.method === 'steer') { + // Into the turn that is running, at its next step — what the harness's + // own apps do with a message sent while the agent works. An agent that + // is not running has no turn to steer; the host prompts it instead. + const create = await userMessageConstructor(); + if (agent.status !== 'running' || typeof agent.steer !== 'function' || create === undefined) { + reply({ id, ok: true, steered: false }); + return; + } + agent.steer(create({ content: [{ type: 'text', text: String(request.text ?? '') }], source: { kind: 'user' } })); + reply({ id, ok: true, steered: true }); + return; + } + const commands = ctx.get('commands'); + if (request.method === 'list') { + reply({ + id, + ok: true, + commands: commands.list(agent).map((command) => ({ + name: command.name, + description: command.description, + ...(command.input?.hint === undefined ? {} : { hint: command.input.hint }), + })), + }); + return; + } + if (request.method === 'run') { + // An empty attachment list: the phone sends the line, and a command + // that wants files is out of scope for this bridge. + const execution = await commands.execute(agent, String(request.line ?? ''), [], new AbortController().signal); + if (execution === undefined) { + reply({ id, ok: false, error: 'not a command this session has' }); + return; + } + reply({ id, ok: true, result: { kind: execution.result.kind, text: execution.result.text ?? '' } }); + return; + } + reply({ id, ok: false, error: 'unknown method ' + String(request.method) }); + } catch (error) { + reply({ id, ok: false, error: error instanceof Error ? error.message : String(error) }); + } +} +`; + +/** The package manifest the loader reads beside the source. */ +function manifest(): string { + return `${JSON.stringify( + { + name: HARNESS_PLUGIN, + version: '1.0.0', + private: true, + type: 'module', + main: 'index.js', + description: "CodeDeck+'s side channel into the harness's automation profile", + }, + null, + 2, + )}\n`; +} + +/** + * Put the plugin where the harness loads it from — its package in the + * profile's `node_modules`, and a row naming it in the profile's patch layer. + */ +export async function installHarnessPlugin(profileDir: string, log: (message: string) => void): Promise { + const dir = path.join(profileDir, 'node_modules', HARNESS_PLUGIN); + await mkdir(dir, { recursive: true }); + await writeIfChanged(path.join(dir, 'package.json'), manifest()); + await writeIfChanged(path.join(dir, 'index.js'), SOURCE); + await new ProfileLayer(path.join(profileDir, 'cordis.patch.yml'), log).set(BLOCK, ROWS); +} + +/** The row that mounts it. It carries no socket: that is each process's own + * (BRIDGE_SOCKET_ENV). */ +const ROWS = ['- insert:', ' - id: codedeck-bridge', ` name: '${HARNESS_PLUGIN}'`].join('\n'); + +/** A file the harness may be running: rewritten only when its content is. */ +async function writeIfChanged(file: string, content: string): Promise { + try { + if ((await readFile(file, 'utf8')) === content) return; + } catch { + // Not written yet. + } + await writeFile(file, content); +} diff --git a/packages/agent-host/src/drivers/deepseek/plugins.ts b/packages/agent-host/src/drivers/deepseek/plugins.ts new file mode 100644 index 00000000..982b5ab4 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/plugins.ts @@ -0,0 +1,286 @@ +/** + * The DeepSeek Harness's plugins: npm packages installed into a profile and + * mounted as patch layers. + * + * A profile is a directory with a `package.json` and a `cordis.yml`; its + * `dsh.profile.bundles` list is the ordered stack of layers it composes, and + * `dsh plugin --profile ` forwards to pnpm in that + * directory. Installing a package that declares a `dsh.bundle` adds it to the + * bundles list — it becomes a layer from then on. A package that declares + * none is installed as a plain dependency (the harness says so itself), which + * is why this manager shows what is installed and which of it is a layer. + * + * There are no marketplaces: a plugin is an npm package. Switching one off + * means taking it out of the bundles list, which is exactly what the list + * means — the package stays installed, so it can be switched back on. The + * profile's own composition (the shared core and the ACP application) is + * never touched: switching it off would leave a harness that cannot run. + * + * The commands run through the harness's own CLI, so its pnpm, its locks and + * its version-compatibility gate are the ones that apply, and a failure is + * whatever it printed. + */ +import { spawn } from 'node:child_process'; +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; +import type { PluginManager, PluginState } from '../../driver'; +import type { InstalledPlugin, PluginAction } from '../../types'; +import { dshCommand, type SpawnFn } from './runtime'; + +/** The layers a profile composes without any plugin: the shared core and the + * ACP application. */ +const SHIPPED_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app']; + +/** How long one `dsh plugin` run may take (a pnpm install, or a registry + * lookup before it). */ +const RUN_TIMEOUT_MS = 300_000; + +/** An npm package name, optionally versioned — the shape pnpm's `add` takes. + * Deliberately plain: a version may be a number (`1.2.3`) or a tag + * (`latest`), not a range or anything a shell would read as syntax. */ +const PACKAGE_SPEC = /^(@[a-z0-9][\w.-]*\/)?[a-z0-9][\w.-]*(@[\w.-]+)?$/i; + +export interface DshRun { + code: number; + stdout: string; + stderr: string; +} + +/** + * Run `dsh plugin --profile ` in the harness home `home` and + * answer with its output. The home is set as the sessions' processes have it + * set: without it the CLI works on the profile of the default home, which + * is not the one any session runs. + */ +export function runDshPlugin( + entry: string, + home: string, + profile: string, + args: string[], + spawnFn: SpawnFn = spawn, +): Promise { + const command = dshCommand(entry, ['plugin', '--profile', profile, ...args]); + return new Promise((resolve, reject) => { + const child = spawnFn(command.command, command.args, { + env: { ...process.env, DSH_HOME: home }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error(`the harness's plugin command did not finish within ${RUN_TIMEOUT_MS / 1000}s`)); + }, RUN_TIMEOUT_MS); + timer.unref?.(); + child.stdout?.on('data', (chunk: Buffer) => { + stdout += chunk.toString(); + }); + child.stderr?.on('data', (chunk: Buffer) => { + stderr += chunk.toString(); + }); + child.on('error', (error) => { + clearTimeout(timer); + reject(error); + }); + child.on('close', (code) => { + clearTimeout(timer); + resolve({ code: code ?? -1, stdout, stderr }); + }); + }); +} + +export interface DeepSeekPluginsOptions { + /** The profile directory (`$DSH_HOME/profiles/acp`). */ + profileDir: string; + /** Run one `dsh plugin` invocation (runDshPlugin, bound to the runtime's + * entry point by the driver). */ + run: (args: string[]) => Promise; + /** The runtime's own packages directory, for the versions of the bundles a + * profile does not install itself (the harness ships them). */ + packagesDir?: () => Promise; + log: (message: string) => void; +} + +/** A profile's `package.json`, as far as a plugin manager cares. */ +interface Manifest { + dependencies?: Record; + dsh?: { profile?: { bundles?: string[] } }; +} + +export class DeepSeekPlugins implements PluginManager { + private message: string | undefined; + /** One change at a time: each may rewrite the manifest. */ + private queue: Promise = Promise.resolve(); + + constructor(private readonly options: DeepSeekPluginsOptions) {} + + list(available: boolean): Promise { + void available; // No marketplaces to ask about. + return this.serial(async () => this.state(await this.manifest(), undefined)); + } + + act(action: PluginAction, target: string): Promise { + return this.serial(async () => { + if (action === 'add-marketplace' || action === 'remove-marketplace' || action === 'update-marketplace') { + throw new Error('The DeepSeek Harness installs plugins from npm by package name; it has no plugin marketplaces.'); + } + if (action === 'install' && !PACKAGE_SPEC.test(target)) { + throw new Error(`'${target}' is not a package name — give an npm package, optionally with a version ('name@1.2.3').`); + } + const manifest = await this.manifest(); + const bundles = manifest.dsh?.profile?.bundles ?? []; + const dependencies = manifest.dependencies ?? {}; + + if (action === 'enable' || action === 'disable') { + const next = await this.setBundle(manifest, bundles, action, target, dependencies); + return this.state(next, action === 'enable' ? `Enabled ${target}.` : `Disabled ${target}.`); + } + + // The profile's own composition first: it is not in its dependency list + // (the CLI keeps it in the bundles list), and the reason matters more + // than the lookup. + if (action === 'install' && SHIPPED_BUNDLES.includes(target)) { + throw new Error(`${target} is part of the profile's own composition; it is already installed.`); + } + if (action === 'uninstall' && SHIPPED_BUNDLES.includes(target)) { + throw new Error( + `${target} is part of the profile's own composition — the harness does not run without it, so it is not removed from here.`, + ); + } + const installed = Object.prototype.hasOwnProperty.call(dependencies, target); + if (action !== 'install' && !installed) throw new Error(`The DeepSeek Harness profile has no plugin '${target}'.`); + const args = action === 'install' ? ['add', target] : action === 'uninstall' ? ['remove', target] : ['update', target]; + const run = await this.options.run(args); + const output = lastLines(`${run.stdout}\n${run.stderr}`); + if (run.code !== 0) throw new Error(`The harness refused the plugin command: ${output || `exit code ${run.code}`}`); + this.options.log(`[deepseek] dsh plugin ${args.join(' ')}: ${output}`); + return this.state(await this.manifest(), output); + }); + } + + /** Switch one bundle on or off by editing the profile's layer list — the + * list dsh itself keeps and the profile's `cordis.yml` points at. */ + private async setBundle( + manifest: Manifest, + bundles: string[], + action: 'enable' | 'disable', + target: string, + dependencies: Record, + ): Promise { + if (SHIPPED_BUNDLES.includes(target)) { + throw new Error(`${target} is part of the profile's own composition and cannot be switched off.`); + } + const present = bundles.includes(target); + if (action === 'enable' && present) return manifest; + if (action === 'disable' && !present) return manifest; + if (action === 'enable') { + if (!Object.prototype.hasOwnProperty.call(dependencies, target)) { + throw new Error(`The DeepSeek Harness profile has no plugin '${target}' — install it first.`); + } + if (!(await this.declaresBundle(target))) { + throw new Error( + `${target} declares no dsh.bundle, so it cannot be a profile layer — the harness installed it as a plain ` + + 'dependency. Plugins that add a layer are packages whose own manifest declares one.', + ); + } + } + // The list is an ordered stack: a re-enabled plugin goes last, after + // everything already composed. + const next = action === 'enable' ? [...bundles, target] : bundles.filter((name) => name !== target); + const updated: Manifest = { ...manifest, dsh: { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles: next } } }; + await this.writeManifest(updated); + return updated; + } + + /** Whether a package in the profile's own node_modules declares a bundle + * (i.e. it ships a patch layer rather than a plain module). */ + private async declaresBundle(name: string): Promise { + try { + const text = await readFile(path.join(this.options.profileDir, 'node_modules', ...name.split('/'), 'package.json'), 'utf8'); + const manifest = JSON.parse(text) as { dsh?: { bundle?: unknown } }; + return typeof manifest.dsh?.bundle === 'object' && manifest.dsh.bundle !== null; + } catch { + return false; + } + } + + private serial(work: () => Promise): Promise { + const next = this.queue.then(work, work); + this.queue = next.catch(() => {}); + return next; + } + + private async manifest(): Promise { + try { + const text = await readFile(this.manifestPath(), 'utf8'); + const parsed: unknown = JSON.parse(text); + return typeof parsed === 'object' && parsed !== null ? (parsed as Manifest) : {}; + } catch { + // A profile nobody has initialized yet has no manifest: it composes its + // own bundles and nothing else. + return {}; + } + } + + private manifestPath(): string { + return path.join(this.options.profileDir, 'package.json'); + } + + private async writeManifest(manifest: Manifest): Promise { + const file = this.manifestPath(); + await mkdir(path.dirname(file), { recursive: true }); + const temporary = `${file}.codedeck-${process.pid}`; + await writeFile(temporary, `${JSON.stringify(manifest, null, 2)}\n`); + await rename(temporary, file); + this.options.log(`[deepseek] profile layers updated in ${file}`); + } + + private async state(manifest: Manifest, message: string | undefined): Promise { + const bundles = manifest.dsh?.profile?.bundles ?? []; + const dependencies = manifest.dependencies ?? {}; + const names = [...new Set([...bundles, ...Object.keys(dependencies)])].sort(); + const installed: InstalledPlugin[] = []; + for (const name of names) { + installed.push({ + id: name, + name, + version: await this.versionOf(name), + enabled: bundles.includes(name), + }); + } + if (message !== undefined) this.message = message; + return { installed, toggles: true, ...(this.message !== undefined ? { message: this.message } : {}) }; + } + + /** + * The version a plugin is at: the profile's own copy when it has one (what + * a user installed), else the harness's — a profile composes the bundles + * the harness ships without copying them in, so their version lives in the + * runtime's tree. A plugin that shows no version is one neither has. + */ + private async versionOf(name: string): Promise { + const shipped = await this.options.packagesDir?.(); + const roots = [path.join(this.options.profileDir, 'node_modules'), ...(shipped === undefined ? [] : [shipped])]; + for (const root of roots) { + try { + const text = await readFile(path.join(root, ...name.split('/'), 'package.json'), 'utf8'); + const manifest = JSON.parse(text) as { version?: unknown }; + if (typeof manifest.version === 'string') return manifest.version; + } catch { + // Not here; the next root may have it. + } + } + return undefined; + } +} + +/** The last few non-empty lines of a command's output — what says what + * happened, rather than a whole pnpm progress log. */ +function lastLines(output: string, count = 3): string { + const lines = output + .split('\n') + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== ''); + return lines.slice(-count).join('\n'); +} diff --git a/packages/agent-host/src/drivers/deepseek/profileLayer.ts b/packages/agent-host/src/drivers/deepseek/profileLayer.ts new file mode 100644 index 00000000..9e80b667 --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/profileLayer.ts @@ -0,0 +1,163 @@ +/** + * The harness's profile patch layer, edited in delimited blocks. + * + * A profile is an ordered stack of patch layers, and + * `$DSH_HOME/profiles//cordis.patch.yml` is the user's own — the + * file the harness's own documentation says to edit, applied after every + * bundle. More than one thing this bridge keeps lives there (its MCP server + * rows, the model catalog it writes for a gateway), so each of them owns a + * block: exactly the lines between its own two marker comments. Everything + * else — the profile's comments, a row a person added — is read, kept and + * written back as it was. + * + * Writes are serialised per file (one editor at a time, whichever block it + * owns) and atomic: written beside and renamed, because a torn write leaves a + * profile the harness cannot read at all. + */ +import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'; +import * as path from 'node:path'; + +/** A block's markers: the lines that bound the text this bridge owns. */ +export interface LayerBlock { + /** The line that opens it — what identifies the block in the file. */ + readonly begin: string; + /** The line that closes it. */ + readonly end: string; +} + +/** One file's editors, so two blocks never write over each other. */ +const queues = new Map>(); + +export class ProfileLayer { + constructor( + private readonly file: string, + private readonly log?: (message: string) => void, + ) {} + + /** The layer as it is on disk (`''` for a profile nobody has started). */ + async text(): Promise { + try { + return await readFile(this.file, 'utf8'); + } catch { + return ''; + } + } + + /** One block's own rows, without its markers. */ + async body(block: LayerBlock): Promise { + const text = await this.text(); + const start = text.indexOf(block.begin); + if (start < 0) return undefined; + const end = text.indexOf(block.end, start); + if (end < 0) return undefined; + return text.slice(start + block.begin.length, end).trim(); + } + + /** + * Write one block — its rows, with the markers around them — leaving every + * other byte of the layer alone. `undefined` takes the block out. Answers + * whether the file changed. + */ + set(block: LayerBlock, body: string | undefined): Promise { + return this.serial(async () => { + const text = await this.text(); + const content = nextLayer(text, block, body); + if (content === text) return false; + await mkdir(path.dirname(this.file), { recursive: true }); + const temporary = `${this.file}.codedeck-${process.pid}`; + await writeFile(temporary, content); + await rename(temporary, this.file); + this.log?.(`[deepseek] profile layer updated in ${this.file}`); + return true; + }); + } + + private serial(work: () => Promise): Promise { + const key = path.resolve(this.file); + const next = (queues.get(key) ?? Promise.resolve()).then(work, work); + queues.set( + key, + next.catch(() => {}), + ); + return next; + } +} + +/** + * The layer after one write: the block replaced where it already stands — so + * a start that changes nothing leaves the file byte for byte as it was, and + * the blocks do not drift through a file a person is reading — or appended + * when the layer has none yet. + * + * Taking a block out leaves one blank line behind, never a growing pile: a + * layer rewritten at every start must not grow on every start. + */ +function nextLayer(text: string, block: LayerBlock, body: string | undefined): string { + const rendered = body === undefined || body.trim() === '' ? undefined : `${block.begin}\n${body.trim()}\n${block.end}`; + const start = text.indexOf(block.begin); + const end = start < 0 ? -1 : text.indexOf(block.end, start); + if (rendered === undefined) { + if (start < 0 || end < 0) return text; + const before = text.slice(0, start).replace(/\s+$/, ''); + const after = text.slice(end + block.end.length).replace(/^\s+/, ''); + const joined = before === '' ? after : after === '' ? before : `${before}\n\n${after}`; + return document(joined, false); + } + if (start >= 0 && end >= 0) { + return document(`${text.slice(0, start)}${rendered}${text.slice(end + block.end.length)}`, true); + } + return document(addRows(text, rendered), true); +} + +/** One document, ending in a newline. Rows live in a sequence, so the + * harness's empty-list line cannot stay beside them — and comes back when + * nothing but the layer's own comments is left. */ +function document(text: string, hasRows: boolean): string { + const trimmed = text.trimEnd(); + const body = !hasRows && isEmptyLayer(trimmed) ? restoreEmptyList(trimmed) : hasRows ? withoutEmptyList(trimmed) : trimmed; + return `${body}\n`; +} + +/** Whether a profile patch layer holds anything but comments and blanks. */ +function isEmptyLayer(text: string): boolean { + for (const line of text.split('\n')) { + const trimmed = line.trim(); + if (trimmed === '' || trimmed.startsWith('#')) continue; + // The profile's initial layer: an empty list, and nothing else. + if (trimmed === '[]' || trimmed === '---') continue; + return false; + } + return true; +} + +/** + * The layer's own rows, then this block, as one document. The harness's + * initial layer is an empty list (`[]`) on its own, and an empty sequence + * cannot share a document with a block sequence — nor can a `[]` a user left + * after their own rows — so those lines go. + */ +function addRows(rest: string, block: string): string { + const head = withoutEmptyList(rest).trimEnd(); + return head === '' ? block : `${head}\n\n${block}`; +} + +/** The layer with no block of ours in it: the profile's own comments and its + * empty list, which is the shape the harness starts from. */ +function restoreEmptyList(rest: string): string { + if (!isEmptyLayer(rest)) return rest.trimEnd(); + const head = rest + .split('\n') + .filter((line) => line.trim().startsWith('#')) + .join('\n') + .trimEnd(); + return head === '' ? '[]' : `${head}\n[]`; +} + +/** `text` without a line that is an empty-list document (`[]` at the left + * margin; an argument's own `[]` is indented and kept). */ +function withoutEmptyList(text: string): string { + return text + .split('\n') + .filter((line) => line.replace(/\r$/, '') !== '[]') + .join('\n'); +} diff --git a/packages/agent-host/src/drivers/deepseek/profileTools.ts b/packages/agent-host/src/drivers/deepseek/profileTools.ts new file mode 100644 index 00000000..692cc4ab --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/profileTools.ts @@ -0,0 +1,40 @@ +/** + * The model-facing tools an automation profile does not mount by itself. + * + * The harness's shared core registers the `user-questions` service and the + * plan-mode tool that presents a plan through it, but the tool that *asks* — + * `ask_user_question`, out of `@deepseek-ai/dsh-tool-ask-user` — belongs to the + * web app's agent presets. A headless profile therefore runs a model that plan + * mode's own instructions tell to ask the user with a tool it does not have, + * which is exactly what a model reports when it is asked about its tools. + * CodeDeck mounts it here, bare, the way the harness's own presets do: one row, + * resolved out of the harness's own installation. + * + * One tool is deliberately left out. `present` (`@deepseek-ai/dsh-tool-present`) + * declares deliverable files for the web app's files pane; nothing on the phone + * would show what it declares, so mounting it would give the model a way to + * report something the user never sees. + * + * The row lives in its own block of the profile's patch layer. A harness that + * changes under it costs the tool and nothing else — an entry that cannot be + * imported is a warning at boot, not a failure. + */ +import * as path from 'node:path'; +import { ProfileLayer, type LayerBlock } from './profileLayer'; + +/** The question tool, as the harness's own agent presets mount it. */ +export const ASK_USER_TOOL = '@deepseek-ai/dsh-tool-ask-user'; + +/** This block's markers in the profile's patch layer. */ +const BLOCK: LayerBlock = { + begin: "# --- CodeDeck+ tools: the model-facing tools this profile's bundles leave out; everything outside this block is yours ---", + end: '# --- end CodeDeck+ tools ---', +}; + +/** Mount them in the profile's own patch layer. */ +export async function installProfileTools(profileDir: string, log: (message: string) => void): Promise { + await new ProfileLayer(path.join(profileDir, 'cordis.patch.yml'), log).set( + BLOCK, + ['- insert:', ' - id: tool-ask-user', ` name: '${ASK_USER_TOOL}'`].join('\n'), + ); +} diff --git a/packages/agent-host/src/drivers/deepseek/questions.ts b/packages/agent-host/src/drivers/deepseek/questions.ts new file mode 100644 index 00000000..244ca5ed --- /dev/null +++ b/packages/agent-host/src/drivers/deepseek/questions.ts @@ -0,0 +1,179 @@ +/** + * The questions the harness's model asks, on their way to the phone and back. + * + * The harness has a `user-questions` service, and everything that needs the + * user asks through it: the model's question tool, its timed form, and the + * plan review `exit_plan_mode` presents. Its answerer is a UI panel in the + * harness's own apps; in the automation profile there is none, so the ask + * fails with "no user-questions answerer configured". Our plugin composes one + * (plugin.ts), which pushes the ask to the host over stderr and waits for the + * answer on its socket. + * + * This module is the host's half: what a pushed ask becomes on the phone + * (`QuestionSpec` for a question, a plan and an approval for a plan review), + * and what the phone's answer becomes for the harness. Both are the harness's + * own shapes (its `AskUserQuestionItem` and `AskUserQuestionAnswer`), which is + * what makes the round trip exact: option labels are what a selected answer + * carries, and free text is what a typed one does. + */ +import type { QuestionSpec } from '../../types'; + +/** One question as the plugin pushes it. */ +export interface PushedQuestion { + id: string; + question: string; + header?: string; + detail?: string; + options?: Array<{ label: string; description?: string }>; + multiSelect?: boolean; + /** What the ask is for, when the harness says. A plan review declares + * `{kind: 'plan-review', approve: