From 511181684c4bd00f82f2c1c61d2d87ebd550d675 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:52:27 +0800 Subject: [PATCH] feat(acp): complete standard v1 automation controls --- ...ed-persistence-write-coordinator.i18n.yaml | 4 +- ...18-shared-persistence-write-coordinator.md | 5 +- ...shared-persistence-write-coordinator.zh.md | 5 +- ...-model-catalog-and-acp-selection.i18n.yaml | 4 +- ...-15-llm-model-catalog-and-acp-selection.md | 10 +- ...-llm-model-catalog-and-acp-selection.zh.md | 10 +- ...-followup-enqueue-and-owned-runs.i18n.yaml | 4 +- ...6-07-30-followup-enqueue-and-owned-runs.md | 4 +- ...7-30-followup-enqueue-and-owned-runs.zh.md | 4 +- .../2026-06-14-acp-multi-session.i18n.yaml | 4 +- .../feature/2026-06-14-acp-multi-session.md | 8 +- .../2026-06-14-acp-multi-session.zh.md | 8 +- ...standard-acp-automation-controls.i18n.yaml | 6 + ...-08-22-standard-acp-automation-controls.md | 75 ++ ...-22-standard-acp-automation-controls.zh.md | 75 ++ ...-23-acp-automation-only-protocol.i18n.yaml | 4 +- ...2026-07-23-acp-automation-only-protocol.md | 14 +- ...6-07-23-acp-automation-only-protocol.zh.md | 14 +- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 8 +- docs/config-catalog.zh.md | 8 +- docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 2 +- docs/event-producer-consumer.zh.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 10 +- docs/subsystems/persistence.zh.md | 10 +- .../agent-instructions.cordis.snapshot.yml | 7 + ...mode-workspace-context.cordis.snapshot.yml | 7 + examples/acp-agent/tests/acp.e2e.ts | 11 +- .../acp-agent/tests/lsp.cordis.snapshot.yml | 7 + .../advanced-toolchain/stdout.expected.jsonl | 16 +- .../agent-instructions/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 14 +- .../bash-spill/stdout.expected.jsonl | 8 +- .../bash-tool-turn/stdout.expected.jsonl | 10 +- .../both-mode-turn/stdout.expected.jsonl | 10 +- .../cancel-tool-calls/stdout.expected.jsonl | 8 +- .../snapshots/cancel/stdout.expected.jsonl | 6 +- .../stdout.expected.jsonl | 8 +- .../code-mode-turn/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 8 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 6 +- .../error-finish/stdout.expected.jsonl | 4 +- .../escalation-approved/stdout.expected.jsonl | 10 +- .../escalation-rejected/stdout.expected.jsonl | 10 +- .../fs-delete-recreate/stdout.expected.jsonl | 15 +- .../snapshots/fs-edit/stdout.expected.jsonl | 13 +- .../stdout.expected.jsonl | 10 +- .../fs-glob-sampling/stdout.expected.jsonl | 10 +- .../fs-policy-reject/stdout.expected.jsonl | 16 +- .../fs-read-window/stdout.expected.jsonl | 10 +- .../snapshots/fs-read/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 14 +- .../fs-write-overwrite/stdout.expected.jsonl | 13 +- .../snapshots/fs-write/stdout.expected.jsonl | 10 +- .../snapshots/handshake/stdout.expected.jsonl | 4 +- .../stdout.expected.jsonl | 7 +- .../stdout.expected.jsonl | 13 +- .../stdout.expected.jsonl | 10 +- .../hook-cc-pretool-ask/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 4 +- .../stdout.expected.jsonl | 7 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 7 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 4 +- .../stdout.expected.jsonl | 7 +- .../stdout.expected.jsonl | 10 +- .../inline-image-prompt/stdout.expected.jsonl | 6 +- .../lsp-definition/stdout.expected.jsonl | 8 +- .../max-tokens-continue/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 12 +- .../multi-turn/stdout.expected.jsonl | 10 +- .../packed-chunks/stdout.expected.jsonl | 10 +- .../parallel-tool-calls/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 8 +- .../stdout.expected.jsonl | 7 +- .../stdout.expected.jsonl | 7 +- .../stdout.expected.jsonl | 18 +- .../snapshots/pty-tools/stdout.expected.jsonl | 18 +- .../stdout.expected.jsonl | 8 +- .../stdout.expected.jsonl | 8 +- .../read-image/stdout.expected.jsonl | 8 +- .../reject-extra-dirs/stdout.expected.jsonl | 2 +- .../stdout.expected.jsonl | 16 +- .../session-query-spill/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 8 +- .../stdout.expected.jsonl | 6 +- .../skill-load/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 8 +- .../stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 16 +- .../stdout.expected.jsonl | 8 +- .../stdout.expected.jsonl | 13 +- .../stdout.expected.jsonl | 14 +- .../stdout.expected.jsonl | 8 +- .../subagent-mixed/stdout.expected.jsonl | 16 +- .../subagent-multi/stdout.expected.jsonl | 13 +- .../subagent-parallel/stdout.expected.jsonl | 10 +- .../stdout.expected.jsonl | 8 +- .../subagent-report/stdout.expected.jsonl | 12 +- .../stdout.expected.jsonl | 10 +- .../snapshots/text-turn/stdout.expected.jsonl | 7 +- .../todo-write/stdout.expected.jsonl | 10 +- .../tool-call-turn/stdout.expected.jsonl | 10 +- .../snapshots/web-fetch/stdout.expected.jsonl | 10 +- .../workflow-run/stdout.expected.jsonl | 10 +- .../workspace-edit/stdout.expected.jsonl | 16 +- knip.json | 3 +- package.json | 2 +- packages/acp/acp/README.i18n.yaml | 4 +- packages/acp/acp/README.md | 110 ++- packages/acp/acp/README.zh.md | 112 +-- packages/acp/acp/package.json | 14 +- packages/acp/acp/src/content.ts | 15 +- packages/acp/acp/src/index.ts | 695 ++++++++--------- packages/acp/acp/src/mcp.ts | 137 ++++ packages/acp/acp/src/model-control.ts | 223 ++++++ packages/acp/acp/src/session.ts | 518 +++++++++++++ packages/acp/acp/src/updates.ts | 111 +++ packages/acp/acp/tests/approval.spec.ts | 10 +- packages/acp/acp/tests/bridge.spec.ts | 729 +++++++++++++++++- packages/acp/acp/tests/content.spec.ts | 60 +- packages/acp/acp/tests/edges.spec.ts | 58 +- packages/acp/acp/tests/harness.ts | 112 ++- packages/acp/acp/tests/mcp.spec.ts | 92 +++ packages/acp/acp/tests/model-control.spec.ts | 96 +++ packages/acp/acp/tests/turns.spec.ts | 51 +- packages/acp/acp/tests/updates.spec.ts | 93 +++ packages/acp/acp/tsconfig.json | 15 + packages/examples/acp-demo/README.i18n.yaml | 4 +- packages/examples/acp-demo/README.md | 10 +- packages/examples/acp-demo/README.zh.md | 10 +- packages/examples/acp-demo/package.json | 3 + .../examples/acp-demo/tests/built-bin.e2e.ts | 53 +- .../acp-demo/tests/control-surface-llm.ts | 106 +++ .../acp-demo/tests/control-surface.cordis.yml | 15 + .../acp-demo/tests/control-surface.e2e.ts | 117 +++ .../examples/acp-demo/tests/load-path.e2e.ts | 34 +- .../extensions/tool-cordis/src/api-catalog.ts | 5 + packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 4 +- packages/mcp/mcp-client/README.zh.md | 4 +- packages/mcp/mcp-client/package.json | 2 + packages/mcp/mcp-client/src/index.ts | 23 +- packages/mcp/mcp-client/tests/apply.spec.ts | 11 + packages/mcp/mcp-client/tests/http-fixture.ts | 52 ++ packages/mcp/mcp-client/tsconfig.json | 3 + .../README.i18n.yaml | 4 +- .../session-persistence-jsonl/README.md | 2 +- .../session-persistence-jsonl/README.zh.md | 2 +- .../session-persistence-jsonl/src/index.ts | 14 +- .../tests/jsonl.spec.ts | 21 + .../tests/zstd.spec.ts | 13 + .../README.i18n.yaml | 4 +- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/README.zh.md | 2 +- .../session-persistence-sqlite/src/index.ts | 5 + .../session-persistence-sqlite/src/store.ts | 13 + .../tests/sqlite.spec.ts | 14 + .../session-persistence/README.i18n.yaml | 4 +- .../session/session-persistence/README.md | 4 +- .../session/session-persistence/README.zh.md | 4 +- .../session-persistence/src/coordinator.ts | 25 +- .../session/session-persistence/src/index.ts | 12 +- .../tests/persistence.spec.ts | 75 ++ packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-acp/src/run.ts | 45 +- .../subagent-acp/tests/mock-acp-server.ts | 37 +- .../test-support/acp-snapshot/package.json | 2 +- .../test-support/acp-snapshot/src/harness.ts | 12 +- .../test-support/acp-snapshot/src/launcher.ts | 68 +- .../acp-snapshot/src/normalize.ts | 2 + .../acp-snapshot/tests/normalize.spec.ts | 20 + pnpm-lock.yaml | 46 +- 180 files changed, 4326 insertions(+), 1001 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md create mode 100644 .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md create mode 100644 packages/acp/acp/src/mcp.ts create mode 100644 packages/acp/acp/src/model-control.ts create mode 100644 packages/acp/acp/src/session.ts create mode 100644 packages/acp/acp/src/updates.ts create mode 100644 packages/acp/acp/tests/mcp.spec.ts create mode 100644 packages/acp/acp/tests/model-control.spec.ts create mode 100644 packages/acp/acp/tests/updates.spec.ts create mode 100644 packages/examples/acp-demo/tests/control-surface-llm.ts create mode 100644 packages/examples/acp-demo/tests/control-surface.cordis.yml create mode 100644 packages/examples/acp-demo/tests/control-surface.e2e.ts create mode 100644 packages/mcp/mcp-client/tests/http-fixture.ts diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 8097b8cbb0..5148c8a648 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 286bbb7d5cd3720109db0d0abc0bb72ddbfcbdcd -2026-06-18-shared-persistence-write-coordinator.zh.md: 70db616b0a71826c648072228fff936ad423ad8f +2026-06-18-shared-persistence-write-coordinator.md: 8392ec726ff44e8a7173f48ef7d5cc4826b7e882 +2026-06-18-shared-persistence-write-coordinator.zh.md: e160f29247ae5cd02aaa8388c141faec64001857 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 286bbb7d5c..8392ec726f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -24,11 +24,12 @@ The coordinator retires a session from `session/disposed`: it waits for the cont ### The hook interface (`PersistenceBackend`) -Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: +Five required members plus optional empty-materialization and lifecycle hooks form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. - `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Preparation, logical load/inspection, physical suffix reads, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. -- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). +- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized. Ordinary creation therefore cannot leave an abandoned materialized-but-empty session. +- `materializeHeader?(meta)` — explicitly persist a header-only session for `SessionPersistence.ensureMaterialized(session)`. This is reserved for a lifecycle frontend that treats an empty session itself as a resumable durable resource; [standard ACP automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) are the first consumer. Backends that support that lifecycle implement the hook; lazy creation remains the default. - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `prepare`/`load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. - `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 70db616b0a..e160f29247 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -24,11 +24,12 @@ Status: implemented ### 钩子接口(`PersistenceBackend`) -五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: +五个必需成员加可选的空会话实体化与生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 - `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 -- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——二者之间发生崩溃时,不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话。因此,普通创建不会留下被放弃的已物化空会话。 +- `materializeHeader?(meta)`——为 `SessionPersistence.ensureMaterialized(session)` 显式持久化仅含 header 的会话。它只供把空会话本身视为可恢复持久资源的生命周期前端使用;[标准 ACP 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)是第一个 consumer。支持该生命周期的后端实现此钩子;惰性创建仍是默认行为。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成收尾事件)和存活会话接管(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 - `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml index 11c83a197b..eb7a9adfab 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 55d6f58ce05e72fc28d55320695f67216f3f8061 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: b1a43a0092bc2f71d058a69dca28aed4f18380e7 +2026-07-15-llm-model-catalog-and-acp-selection.md: fef23711a9214eae414809833bcd9ee9e26105ae +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 85e9fa35ac99b72a7df207fdb4971b57cf6dc525 diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md index 55d6f58ce0..fef23711a9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) -> The catalog decision remains current. Per-session ACP model selection is superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). +> The catalog and scoped-selection decisions remain current. The temporary removal of ACP selection is superseded by [standard ACP v1 automation controls](../feature/2026-08-22-standard-acp-automation-controls.md), which exposes the catalog through standard session configuration without restoring UI projections. ## Problem @@ -30,11 +30,11 @@ Catalog membership is advisory. It drives selectors and diagnostics but never ch A selection is owned by the front end that offers it, never by `LlmRuntime` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. -The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. +The ACP automation transport consumes the advisory catalog through standard session configuration options. Its deployment config still supplies the initial provider/model target; each session owns an opaque provider/model choice and a dependent exact-model reasoning-effort choice. Adapter topology changes publish the complete option state. Catalog absence never invalidates the configured route: the current unlisted route is synthesized into the choices. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Ordinary consumers snapshot the selection once per step. ACP associates its admission snapshot with the identified message in the per-session module until inbox claim, then pins that selection for the complete admitted turn, so asynchronous image admission, prompt variables, and every request step remain aligned without changing the durable user source. A concurrent selection starts on the next ACP turn. Other call-config fields remain untouched. The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. @@ -53,10 +53,10 @@ The request header remains the durable source of truth. When a selection is actu - Any adapter can expose a dynamic model list without leaking provider-library types into the LLM Service Definition. - Catalog consumers must treat absence as “not advertised,” never “invalid request.” - pi-ai adapters expose their installed provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support. -- Human-facing catalog consumers own their selection interaction. ACP uses its fixed deployment target and does not widen the protocol with model discovery. +- Each catalog consumer owns its selection interaction. ACP uses standard session configuration options and emits no DSH-specific selector or UI metadata. - Request headers remain compatible with the provider-routed session shape; no new JSONL event or format version is required. - A catalog read can be asynchronous, and every caller receives detached values. ## Testing -Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP transport tests validate fixed provider/model forwarding independently of catalog discovery; the TUI suite covers selector interaction and header-based restoration. +Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP tests validate grouped discovery, invalid and concurrent changes, topology updates, header-based restoration, per-turn route pinning, and image-route consistency; human clients test their own selector presentation. diff --git a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md index b1a43a0092..85e9fa35ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文 -> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 +> Catalog 和 scoped selection 决策仍然有效。ACP selection 的暂时移除已由[标准 ACP v1 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)取代;后者通过标准会话配置公开 catalog,但不会恢复 UI 投影。 ## 问题 @@ -30,11 +30,11 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 选择由提供它的前端拥有,而不由 `LlmRuntime` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 -ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 +ACP 自动化传输层通过标准会话配置选项消费建议性 catalog。部署配置仍提供初始提供方/模型目标;每个会话拥有一个不透明的提供方/模型选择,以及一个依赖确切模型的 reasoning-effort 选择。Adapter 拓扑变化会公布完整选项状态。Catalog 中缺少条目不会使配置路由失效:当前未列出的路由会合成到选项中。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。普通 consumer 每个步骤快照一次选择。ACP 会在 per-session 模块中把准入快照与已识别消息关联到 inbox claim 时刻,再在完整已准入轮次中固定该选择,使异步图片准入、提示词变量和每个请求步骤保持一致,同时不改变持久用户 source。并发选择变更从下一个 ACP 轮次开始。其他调用配置字段保持不变。 请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 @@ -53,10 +53,10 @@ ACP 自动化传输层不是目录消费方。它通过部署配置为新创建 - 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到 LLM Service Definition。 - 目录消费方必须把缺失理解为「未展示」,而不是「请求无效」。 - pi-ai 适配器会暴露其已安装的提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留对任意模型的支持。 -- 面向人类的目录消费方拥有各自的选择交互。ACP 使用固定部署目标,不会为模型发现扩大协议范围。 +- 每个 catalog consumer 拥有自己的选择交互。ACP 使用标准会话配置选项,不发出 DSH 专用 selector 或 UI 元数据。 - 请求头与基于提供方路由的会话形态保持兼容;不需要新的 JSONL 事件或格式版本。 - 目录读取可以是异步的,且每个调用方都会收到值的独立副本。 ## 测试 -单元测试覆盖目录值副本与格式错误的元数据、pi-ai 和 DeepSeek 目录投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 传输测试独立验证固定提供方/模型的转发行为;TUI 套件覆盖选择器交互与基于请求头的恢复。 +单元测试覆盖 catalog 值副本与格式错误的元数据、pi-ai 和 DeepSeek catalog 投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 测试覆盖分组发现、无效和并发变更、拓扑更新、基于请求 header 的恢复、逐轮路由固定以及图片路由一致性;人工客户端测试自己的 selector 展示。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml index 6a93aa5014..41be5b3340 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md -2026-07-30-followup-enqueue-and-owned-runs.md: 9978b3a8ab8678fe98e000476505cee9dcaa1bc6 -2026-07-30-followup-enqueue-and-owned-runs.zh.md: 3b33a8dcf550edb036f07f40bab86ca3d0171bd7 +2026-07-30-followup-enqueue-and-owned-runs.md: e056d23d72121053c2aeaec44ff307c514c1ae49 +2026-07-30-followup-enqueue-and-owned-runs.zh.md: 4200c6b480298a2e487667cf953f6eea7d553736 diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md index 9978b3a8ab..e056d23d72 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.md @@ -18,7 +18,7 @@ The low-level SDK protocol answers `session/prompt` as soon as enqueue succeeds High-level automation APIs return a `RunResult` only when they explicitly own an activity interval. The TypeScript and Python SDK `run()` methods collect from the submitted message's durable inbox receipt through the next whole-agent `idle`; their final response is the last committed assistant message in that interval, not a response causally attributed to the submitted prompt. The Python SDK also reports the last root turn's reason kind as the run-level [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.md), without attributing it to the submitted prompt. The one-shot CLI owns the analogous idle-to-idle interval. An isolated child-agent run may report a result because its caller owns the complete child lifecycle and any steering belongs to that run. -ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session, waits for whole-agent idle, and otherwise reports the generic `end_turn`. Token-limit endings are not attributed to the prompt: they settle as `end_turn`. A model error on the prompt's correlated turn does reject the prompt immediately (the error is attributed by its owning turn), and a turnless slot (admission discarded the prompt) settles as `cancelled` at idle alongside explicit ACP cancellation or disposal. +ACP must return a protocol `stopReason`. Its bridge serializes one in-flight prompt per ACP session and owns the interval from admission through whole-Agent idle and ordered update delivery. It correlates the turn that admits the identified ACP message without claiming that every activity in the interval was caused only by that message. A correlated token-limit ending maps to standard `max_tokens`; a correlated model error rejects at the same quiescence boundary; a turnless slot settles as `cancelled` alongside explicit ACP cancellation or disposal. Other normal quiescence reports `end_turn`. Goal continuation retains `MessageId` only to recognize its durable queued and admitted goal message. It advances from durable goal state at whole-agent idle, without mapping the message to a turn result. @@ -39,4 +39,4 @@ Goal continuation retains `MessageId` only to recognize its durable queued and a ## Consequences -An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from SDK and ACP results; callers may inspect run-level or durable event facts without claiming causal attribution. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-prompt result. +An owned activity interval can include steering, injected context, or other work submitted before idleness, so its final response, finish reason, and events are deliberately broader than the initiating message. Prompt-level model error and token-limit classifications remain absent from the low-level DSH SDK result. ACP projects the correlated turn into its required standard error or `max_tokens` stop reason at interval quiescence, without adding a DSH-specific result or claiming exclusive causality. Concurrent automation on one session requires an explicit serialization or ownership policy rather than an implicit per-follow-up result. diff --git a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md index 3b33a8dcf5..4200c6b480 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-30-followup-enqueue-and-owned-runs.zh.md @@ -18,7 +18,7 @@ Status: implemented 只有明确拥有一个活动区间时,高层自动化 API 才返回 `RunResult`。TypeScript 和 Python SDK 的 `run()` 方法从已提交消息的持久 inbox 回执开始收集,直至整个 agent 下一次进入 `idle`;其最终响应是该区间内最后一条已提交的 assistant 消息,而不是按因果关系归属于已提交提示词的响应。Python SDK 还把根会话最后一个轮次的结束原因 kind 作为运行级 [`finish_reason`](../bug-fix/2026-08-11-owned-run-finish-reason.zh.md) 返回,但不会将其归因于已提交的提示词。单次 CLI(命令行界面)拥有相应的 idle 到 idle 区间。隔离的子 agent 运行可以报告结果,因为调用方拥有完整的子级生命周期,任何 steering 都属于该运行。 -ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层对每个 ACP 会话中的提示词进行串行处理,确保一次只有一个提示词正在处理,等待整个 agent 进入 idle,其他情况均报告通用的 `end_turn`。token 上限的轮次结束不归因于提示词:它们以 `end_turn` 结算。与该提示词关联的轮次上的模型错误会立即以该错误拒绝提示词(错误按其所属轮次归因),而无轮次的 slot(准入已丢弃提示词)会在 idle 时以 `cancelled` 结算,与显式 ACP 取消或 dispose(资源释放)并列。 +ACP(Agent Client Protocol)必须返回协议规定的 `stopReason`。其桥接层对每个 ACP 会话中的提示词进行串行处理,并拥有从准入到整个 Agent idle 和有序更新交付的区间。它会关联准入该已识别 ACP 消息的轮次,但不会声称区间内所有活动都只由该消息引起。关联的 token 上限结尾映射为标准 `max_tokens`;关联模型错误在同一个完全停稳边界拒绝;无轮次 slot 与显式 ACP 取消或 dispose 一样以 `cancelled` 结算。其他正常完全停稳报告 `end_turn`。 Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal 消息。它在整个 agent 进入 idle 时根据持久 goal 状态推进,不把消息映射到轮次结果。 @@ -39,4 +39,4 @@ Goal 续行只保留 `MessageId`,用于识别持久排队和已准入的 goal ## 后果 -自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应、结束原因和事件有意比初始消息涵盖更广。SDK 和 ACP 结果仍不包含提示词级模型错误和 token 上限分类;调用方可以检查运行级或持久事件事实,但不能声称这些事实具有因果归属。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的按提示词结果。 +自有活动区间可以包含进入 idle 前提交的 steering、注入上下文或其他工作,因此其最终响应、结束原因和事件有意比初始消息涵盖更广。底层 DSH SDK 结果仍不包含提示词级模型错误和 token 上限分类。ACP 会在区间完全停稳时把关联轮次投影成其必需的标准 error 或 `max_tokens` stop reason,但不增加 DSH 专用结果,也不声称排他因果关系。在同一会话上并发执行自动化操作时,必须采用显式串行或所有权策略,不能依赖隐式的逐 follow-up 结果。 diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml index 7b22ab0b47..46d8b1a8ef 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-06-14-acp-multi-session.md -2026-06-14-acp-multi-session.md: 7efdf968d1dc71e727c21b0c0ce4a6a51f3e6b42 -2026-06-14-acp-multi-session.zh.md: b713d5bd756d739a8876c7720042a34e81c25209 +2026-06-14-acp-multi-session.md: 540bc0ee798b8e578fd8da293f4319285ec27beb +2026-06-14-acp-multi-session.zh.md: 814f28bb677169d1d65b2c954c28c21c2e2851fb diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md index 7efdf968d1..540bc0ee79 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md @@ -12,7 +12,7 @@ An ACP automation client can keep several conversations alive over one agent sub ## Decision -The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent, exact disposer, and optional in-flight prompt with the durable turn number that eventually settles it. The session header owns its cwd; the bridge keeps no parallel workspace or client-capability state. +The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the module only when it owns the exact agent object, so a foreign same-id object cannot claim the session. The module owns its Agent handle, MCP mounts, model selection, ordered updates, memoized close, and optional in-flight prompt with the durable turn number that eventually settles it. The session header owns its cwd; the bridge keeps no parallel workspace or client-capability state. Every `session/event` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt captures its own user-sourced message `turn/start` and settles only on the matching `turn/end`; injection turns, autonomous plugin or goal turns, and a late end from a cancelled prior turn cannot resolve it. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path. @@ -20,13 +20,13 @@ Permission ownership uses the same exact-agent check against the forward map. Th Background bash tasks carry an opaque owner token equal to the owning session id. `job_output` and `job_kill` compare the caller's token with the executor's job ownership before reading or killing; a predictable job id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it. -Connection teardown clears the live map, settles each pending prompt as cancelled, and disposes all `AgentHandle`s in parallel. Each handle stops and awaits its loop, flushes the session while attached, unregisters the agent, and removes the session. Teardown is memoized and shared by client disconnect and plugin disposal. +Per-session close, connection teardown, and plugin disposal share each `AcpSession`'s memoized close. The bridge retains the live map while events and updates drain, settles each pending prompt as cancelled, drains continuable descendants, flushes the session while attached, and disposes Agent scopes in parallel. Exact records leave the map only after their close operation settles. ## Protocol and workspace scope [ACP v1 expressly permits several concurrent sessions on one connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24), and each new session carries its own primary `cwd`. This bridge implements that session-level multiplexing, including different primary workspaces as recorded by the [per-session cwd decision](../architecture/2026-07-02-fs-per-session-cwd.md); it does not create one agent subprocess per session. -A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). The automation bridge advertises no multi-root capability and rejects non-empty `additionalDirectories`; each fresh session has exactly one workspace, as recorded in the [package contract](../../../../packages/acp/acp/README.md#protocol-contract). +A multi-root project inside one session is a separate optional capability: ACP defines the [effective roots as the primary `cwd` plus `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367). The automation bridge advertises no multi-root capability and rejects non-empty `additionalDirectories`; each session has exactly one workspace, as recorded in the [package contract](../../../../packages/acp/acp/README.md#standard-acp-v1-surface). [The standard transport is one agent subprocess per stdio connection](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42); multiple connections therefore require multiple subprocesses or a custom transport, while this decision guarantees multiple sessions within one connection. Within that connection, `ctx.sandboxPolicy` resolves every session's `cwd` as its own `workspace-write` root, so the shared bash and filesystem services can serve concurrent projects without granting cross-project writes. This does not add ACP `additionalDirectories`; it removes the process-wide root limit from the already-supported one-primary-root-per-session path. @@ -42,7 +42,7 @@ A multi-root project inside one session is a separate optional capability: ACP d N sessions can return committed answers, prompt, request permission, and run background jobs concurrently without interleaving or cross-settling. A cancel in one session does not affect its neighbors. The bridge pays for explicit maps and isolation tests, but it does not add one listener set per session and therefore avoids listener fan-out during long-lived connections. -The bridge exposes no protocol method to close one live session independently. Records leave together on connection teardown; navigation and resume belong to host APIs rather than this automation protocol. +Standard `session/close` independently quiesces one live session and leaves its durable state resumable. `session/list` omits active records, and `session/resume` rejects an id that is still live, so an automation client cannot create two Agent objects for one durable session. ## Verification diff --git a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md index b713d5bd75..814f28bb67 100644 --- a/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md +++ b/.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -ACP 桥接层将活跃会话存储在 `Map` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该记录拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。一条记录拥有其 agent、精确的释放器,以及可选的进行中提示词和最终结算它的持久轮次号。会话 header 拥有其 cwd;桥接层不保留平行的工作区或客户端能力状态。 +ACP 桥接层将活跃会话存储在 `Map` 中。agent 作用域的回调使用 `ownedRecord`:在正向 map 中查找 `agent.session.id`,且仅当该模块拥有精确的 agent 对象时才接纳它,使外部的同 id 对象无法冒领会话。模块拥有 Agent handle、MCP 挂载、模型选择、有序更新、记忆化关闭,以及可选的进行中提示词和最终结算它的持久轮次号。会话 header 拥有其 cwd;桥接层不保留平行的工作区或客户端能力状态。 每个 `session/event` 回调在发送或结算任何内容之前,先解析出所属记录。每个会话独立允许一个进行中的提示词。提示词捕获自己源自用户消息的 `turn/start`,并仅在匹配的 `turn/end` 到达时结算;注入轮次、插件或 goal 的自主轮次,以及来自已取消的前一轮次的迟到 end 都不能 resolve 它。`session/cancel` 定位到一条记录,只调用该 agent 的队列感知取消路径。 @@ -20,13 +20,13 @@ ACP 桥接层将活跃会话存储在 `Map` 中。agen 后台 bash 任务携带一个不透明的 owner token,其值等于所属会话 id。`job_output` 和 `job_kill` 在读取或终止之前,将调用方的 token 与执行器的任务归属进行比较;仅凭可预测的 job id 不能获得访问权。归属信息与执行器任务一起存储,因此工具插件重载不会擦除它。 -连接拆除时清空活跃 map,将每个待处理的提示词以取消状态结算,并并行 dispose(资源释放)所有 `AgentHandle`。每个句柄停止并等待其循环完成、在仍然附着时刷新会话、注销 agent 并移除会话。拆除操作被 memoize 化,由客户端断连和插件 dispose 共享。 +逐会话关闭、连接拆除和插件释放共享每个 `AcpSession` 的记忆化关闭。桥接层在事件和更新 drain 期间保留活跃 map,把每个待处理提示词结算为已取消,drain 可继续后代,在会话仍挂载时 flush,并行释放 Agent scope。确切记录只在关闭操作结算后离开 map。 ## 协议与工作区作用域 [ACP v1 明确允许一个连接上存在多个并发会话](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/get-started/architecture.mdx#L16-L24),每个新会话都携带自己的主 `cwd`。本桥实现该会话级多路复用,其中包括[按会话 cwd 决策](../architecture/2026-07-02-fs-per-session-cwd.zh.md)所记录的不同主工作区;它不会为每个会话创建一个 agent 子进程。 -一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。自动化桥接层不公布任何多根能力,并拒绝非空的 `additionalDirectories`;如[包约定](../../../../packages/acp/acp/README.zh.md#protocol-contract)所记录,每个全新会话恰好有一个工作区。 +一个会话内部的多根项目是另一项可选能力:ACP 把[有效根目录定义为主 `cwd` 加 `additionalDirectories`](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/session-setup.mdx#L313-L367)。自动化桥接层不公布任何多根能力,并拒绝非空的 `additionalDirectories`;如[包约定](../../../../packages/acp/acp/README.zh.md#standard-acp-v1-surface)所记录,每个会话恰好有一个工作区。 [标准传输是每个 stdio 连接一个 agent 子进程](https://github.com/agentclientprotocol/agent-client-protocol/blob/01beb5fb5eec60e9f516a80d85eb03594bac61e3/docs/protocol/v1/transports.mdx#L17-L42);多个连接因此需要多个子进程或自定义传输,而本决策保证的是一个连接内部存在多个会话。在该连接内,`ctx.sandboxPolicy` 把每个会话的 `cwd` 解析为其自己的 `workspace-write` 根目录,因此共享的 bash 和文件系统服务可以服务并发项目而不授予跨项目写入。这不会添加 ACP `additionalDirectories`;它只是从已经支持的「每会话一个主根目录」路径中移除了进程级根目录限制。 @@ -42,7 +42,7 @@ ACP 桥接层将活跃会话存储在 `Map` 中。agen N 个会话可以并发地返回已提交的回答、提交提示词、请求权限和运行后台任务,而不会交错或跨会话结算。一个会话中的取消不影响相邻会话。桥接层为此付出了显式 map 和隔离测试的代价,但它不会为每个会话添加一组监听器,从而避免了长连接期间的监听器扇出。 -桥接层不暴露独立关闭单个活跃会话的协议方法。所有记录会在连接拆除时一并移除;会话导航与恢复属于 host API,而非这个自动化协议。 +标准 `session/close` 会独立停稳一个活跃会话,并保留其可恢复持久状态。`session/list` 省略活动记录,`session/resume` 拒绝仍存活的 id,因此自动化客户端不能为同一持久会话创建两个 Agent 对象。 ## 验证 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml new file mode 100644 index 0000000000..4c834dfdcc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md +2026-08-22-standard-acp-automation-controls.md: 8abbd11a5f63ed3fe01506def6ee1d34519ecbab +2026-08-22-standard-acp-automation-controls.zh.md: 03b7b5c9c3a90295dc33124f2e647b96d15d6c90 diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md new file mode 100644 index 0000000000..8abbd11a5f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.md @@ -0,0 +1,75 @@ +# Agent Note: Standard ACP v1 automation controls + +Status: implemented + +English | [中文](2026-08-22-standard-acp-automation-controls.zh.md) + +> This note supersedes only the prompt-only protocol inventory in [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). That decision's prohibition on ACP becoming a second product UI remains authoritative. + +## Problem + +The automation-only ACP bridge could create a fresh session, submit one prompt at a time, cancel it, receive committed assistant messages, and answer one-shot permission requests. A generic external automation controller still needed private process knowledge to discover models, attach MCP servers, find durable sessions after restart, resume them, close one session independently, and observe reasoning, tool, or context-pressure progress. Reproducing those controls in an integration-specific runtime would make ACP nominally interoperable while leaving DSH automation dependent on a private side protocol. + +The stable ACP v1 protocol already defines the required control vocabulary. Adding private `_meta`, custom methods, use-case-specific environment handling, or presentation projections would fragment that vocabulary and revive the UI coupling removed by the automation-only decision. + +## Decision + +`@deepseek-ai/dsh-acp` implements the complete standard ACP v1 automation subset needed by a generic controller: `session/new`, `session/list`, `session/resume`, `session/close`, `session/prompt`, `session/cancel`, `session/set_config_option`, JSON-RPC `$/cancel_request`, `session/update`, and `session/request_permission`. It uses `@agentclientprotocol/sdk` 1.4's app/context interface on both sides of every in-repository connection. + +Capabilities omit unsupported methods and features. DSH adds no custom method, capability flag, or `_meta`, and assigns no private meaning to client metadata. `session/load`, `session/delete`, `session/fork`, additional directories, SSE and ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation remain unsupported. Session controls and semantic updates are protocol data for automation; they do not make ACP a human UI. + +## Per-session ownership + +One `AcpSession` module owns each published Agent handle, selected model state, request MCP mounts, single prompt slot, ordered update chain, and memoized close operation. Global event listeners only identify the exact Agent or Session and delegate to that module. The module associates the admission snapshot with the identified message in memory until inbox claim, then pins it to the admitted turn. Image capability checks, prompt variables, request headers, and every model step therefore use one provider/model/reasoning tuple, while the ordinary durable user source remains unchanged. A concurrent configuration change affects the next ACP turn. + +Explicit `session/close`, connection loss, and plugin disposal call the same close operation. It cancels admission and Agent work before waiting, drains committed updates and continuable descendants, flushes persistence, and releases the Agent scope and its MCP clients. Close retains event routing until the drain completes. Failure reporting waits for all owned session teardowns, and other frontends' Agents and descendants remain untouched. + +## Persistent session controls + +Complete ACP lifecycle support requires session persistence. `session/list` reads materialized top-level headers, excludes active and descendant sessions, filters by canonical physical `cwd`, sorts by creation time and id, and returns bounded pages using opaque keyset cursors. Summaries deliberately omit titles and presentation metadata. + +`session/new` explicitly asks persistence to materialize the live session header without inventing a session event, so even an empty session can be closed, listed, and resumed. Other frontends retain the persistence seam's lazy default and leave abandoned empty sessions unmaterialized. `session/resume` rejects active ids and non-top-level or unknown persisted ids, verifies the requested canonical `cwd` before Agent composition, restores the durable session without replaying it to the client, and mounts the MCP declarations supplied by that request. `session/close` leaves the durable log available for a later process. + +## Standard configuration options + +The advisory LLM catalog now serves another automation consumer without becoming request validation. ACP exposes a provider-grouped `model` select whose opaque values retain the provider/model pair, plus a dependent `reasoning_effort` select from the resolved exact model. New, resume, and set responses return the complete state. Adapter topology events emit `config_option_update`; per-session mutations serialize in receive order. The configured ACP provider/model remains the initial selection, and unlisted configured routes are synthesized into the returned choices instead of being rejected. + +## Standard MCP mapping + +`session/new` and `session/resume` accept standard stdio and Streamable HTTP MCP declarations. Stdio uses the session `cwd`; HTTP uses the declared URL and headers; both retain `dsh-mcp-client` timeout and reconnect defaults. Names, commands, URLs, environment entries, headers, and duplicate normalized namespaces are validated before Agent publication. Initial connection or discovery failure rolls the unpublished Agent back. + +MCP namespace reservations follow the nearest DSH registration scope rather than the process root. Independent Agent scopes may use the same server name, while duplicate names inside one Agent still fail. Scoped disposal releases tools, transports, and reservations. + +ACP clients are trusted controllers: a stdio declaration authorizes process execution and an HTTP declaration authorizes requests with its headers. DSH does not add per-server private cwd or timeout fields. Ordinary DSH tool policy still governs calls after tools are mounted. + +## Semantic update projection + +Only committed durable facts reach `session/update`. Assistant text/images become `agent_message_chunk`; reasoning becomes `agent_thought_chunk`; tool calls/results become generic `tool_call` and `tool_call_update`; known measured context pressure and capacity become `usage_update`; adapter topology changes become `config_option_update`. Durable message ids and tool-call ids preserve correlation. The canonical DSH tool name is the standard tool-call title. + +The per-session chain serializes all updates and drains before prompt completion. A tool-call notification drains before a permission request refers to it. Raw model deltas, retry attempts, cards, terminal state, diffs, locations, plans, titles, todos, and unsupported content stay off the wire. + +`session/cancel` and `$/cancel_request` enter the same prompt-owned cancellation path. Correlated endings map only to standard stop reasons and JSON-RPC errors; a model output limit reports `max_tokens`. ACP returns no additional DSH result structure. + +## Alternatives considered + +**Add a private controller extension.** Rejected because standard ACP v1 already carries the required lifecycle, configuration, MCP, cancellation, permission, and semantic-update concepts. A private extension would make generic SDK clients incomplete. + +**Restore the former editor projection.** Rejected because plans, terminals, diffs, cards, navigation, and human elicitation are presentation responsibilities. Semantic tool and reasoning facts are useful automation telemetry without importing presentation modules. + +**Implement every ACP session method.** Rejected. List, resume, and close complete the durable automation lifecycle. Load/replay, delete, and fork introduce separate transcript, destructive-storage, and lineage semantics that this use case does not require. + +**Use unstable provider methods for model discovery.** Rejected because standard session configuration options express the choice and remain scoped to the session. + +**Copy every DSH runtime field to ACP metadata.** Rejected because exact token breakdowns, private result statuses, programmatic display names, and per-MCP tunables have no stable ACP v1 equivalent. + +## Verification + +Focused tests cover exact capability advertisement without private metadata; model/reasoning choices, invalid and concurrent mutation, topology updates, and image-route pinning; stdio/HTTP MCP setup, declaration rollback, scope isolation, resume, and disposal; list pagination, canonical workspace checks, active conflicts, close/resume, and restart recovery; message/thought/tool/usage order and ids; tool-before-permission order; standard stop reasons; request and session cancellation; and connection-loss teardown. + +A generic keyless conformance test boots the real ACP demo twice and uses only the public ACP SDK to select a model and reasoning effort, attach an MCP server, execute a tool turn, observe standard updates, close, restart, list, resume, and cancel. It contains no integration-specific names, dependencies, metadata, or environment behavior. + +## Consequences + +External automation projects can use DSH through stable ACP v1 instead of maintaining a DSH-specific runtime protocol. The bridge is a larger control surface but remains smaller than a UI: it owns lifecycle and semantic interoperability, while human presentation and interaction stay in product clients. + +Persistent lifecycle and request MCP mounting make session creation stricter. Misconfiguration and initial MCP failure reject before publication, and close waits for real quiescence and persistence. This cost is the ownership proof required to avoid partial Agents, leaked tools, or orphaned processes. diff --git a/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md new file mode 100644 index 0000000000..03b7b5c9c3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-22-standard-acp-automation-controls.zh.md @@ -0,0 +1,75 @@ +# Agent Note:标准 ACP v1 自动化控制 + +状态:已实现 + +[English](2026-08-22-standard-acp-automation-controls.md) | 中文 + +> 本说明仅取代 [ACP 作为纯自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md) 中仅支持提示词的协议清单。该决策关于禁止 ACP 成为第二套产品 UI 的规定仍具权威性。 + +## 问题 + +纯自动化 ACP 桥接层可以创建新会话、一次提交一个提示词、取消提示词、接收已提交 assistant 消息,并回答一次性权限请求。通用外部自动化控制器仍需依赖私有进程知识,才能发现模型、挂载 MCP 服务器、在重启后找到持久会话、恢复会话、独立关闭一个会话,以及观察 reasoning、工具或上下文压力进度。如果在集成专用 runtime 中复制这些控制,ACP 只会名义上可互操作,而 DSH 自动化仍依赖私有旁路协议。 + +稳定 ACP v1 协议已经定义所需的控制词汇。增加私有 `_meta`、自定义方法、用例专用环境处理或展示投影会割裂该词汇,并重新引入纯自动化决策已经移除的 UI 耦合。 + +## 决策 + +`@deepseek-ai/dsh-acp` 实现通用控制器需要的完整标准 ACP v1 自动化子集:`session/new`、`session/list`、`session/resume`、`session/close`、`session/prompt`、`session/cancel`、`session/set_config_option`、JSON-RPC `$/cancel_request`、`session/update` 和 `session/request_permission`。仓库内每条连接的两端都使用 `@agentclientprotocol/sdk` 1.4 的 app/context 接口。 + +能力会省略未支持的方法和功能。DSH 不增加自定义方法、能力标记或 `_meta`,也不为客户端元数据赋予私有含义。`session/load`、`session/delete`、`session/fork`、附加目录、SSE 和 ACP 传输 MCP、模式、命令、计划、终端、客户端文件系统操作和 elicitation 仍不受支持。会话控制和语义更新是自动化协议数据;它们不会使 ACP 成为人工 UI。 + +## Per-session 所有权 + +每个已公布 Agent 由一个 `AcpSession` 模块拥有,该模块同时拥有所选模型状态、请求 MCP 挂载、单提示词槽位、有序更新链和记忆化关闭操作。全局事件监听器只识别确切 Agent 或 Session,再委托给该模块。模块会在内存中把准入快照与已识别消息关联到 inbox claim 时刻,再将其固定到已准入轮次。因此,图片能力检查、提示词变量、请求 header 和每个模型步骤都使用同一个提供方/模型/reasoning tuple,而普通持久用户 source 保持不变。并发配置变更从下一个 ACP 轮次开始生效。 + +显式 `session/close`、连接丢失和插件释放调用同一个关闭操作。它会先取消准入和 Agent 工作,再等待;随后 drain 已提交更新和可继续后代、flush 持久化,并释放 Agent scope 及其 MCP 客户端。关闭流程会保留事件路由直到 drain 完成。只有所有自有会话 teardown 都完成后才报告失败,其他前端的 Agent 和后代不受影响。 + +## 持久会话控制 + +完整 ACP 生命周期支持要求挂载会话持久化。`session/list` 读取已实体化的顶层 header,排除活动会话和后代会话,按规范物理 `cwd` 过滤,按创建时间和 id 排序,并通过不透明 keyset cursor 返回有界页面。摘要有意省略标题和展示元数据。 + +`session/new` 会显式要求持久化在不虚构会话事件的情况下实体化 live session header,因此即使空会话也可以关闭、列出和恢复。其他前端仍保留持久化 seam 的惰性默认行为,不会实体化被放弃的空会话。`session/resume` 拒绝活动 id,以及非顶层或未知的持久 id;在组合 Agent 前校验请求的规范 `cwd`;恢复持久日志但不向客户端重放;挂载该请求提供的 MCP 声明。`session/close` 让持久日志可供后续进程使用。 + +## 标准配置选项 + +建议性 LLM catalog 现在服务于另一个自动化 consumer,但不会成为请求校验。ACP 公开按提供方分组的 `model` select,其不透明值保留提供方/模型对;还会公开来自已解析确切模型的依赖 `reasoning_effort` select。新建、恢复和设置响应都返回完整状态。Adapter 拓扑事件发出 `config_option_update`;每个会话按接收顺序串行处理变更。配置的 ACP 提供方/模型仍是初始选择;未列出的配置路由会合成到返回选项中,而不会被拒绝。 + +## 标准 MCP 映射 + +`session/new` 和 `session/resume` 接受标准 stdio 和 Streamable HTTP MCP 声明。Stdio 使用会话 `cwd`;HTTP 使用已声明 URL 和 header;两者都保留 `dsh-mcp-client` 的超时和重连默认值。名称、命令、URL、环境项、header 和重复的规范化 namespace 都会在 Agent 公布前校验。初始连接或发现失败会回滚尚未公布的 Agent。 + +MCP namespace reservation 跟随最近的 DSH registration scope,而不是进程 root。独立 Agent scope 可以使用同一服务器名,同一 Agent 内的重复名称仍会失败。Scoped disposal 会释放工具、传输和 reservation。 + +ACP 客户端是受信任的控制器:stdio 声明授权执行进程,HTTP 声明授权携带其 header 发起请求。DSH 不增加每服务器私有 cwd 或超时字段。工具挂载后,普通 DSH 工具策略仍然约束调用。 + +## 语义更新投影 + +只有已提交的持久事实会进入 `session/update`。Assistant 文本/图片变成 `agent_message_chunk`;reasoning 变成 `agent_thought_chunk`;工具调用/结果变成通用 `tool_call` 和 `tool_call_update`;已知的测量上下文压力与容量变成 `usage_update`;adapter 拓扑变化变成 `config_option_update`。持久消息 id 和工具调用 id 保留关联。规范 DSH 工具名作为标准工具调用 title。 + +Per-session 链会串行处理所有更新,并在提示词完成前 drain。引用工具调用的权限请求只会在该工具调用通知 drain 后发送。原始模型 delta、重试尝试、卡片、终端状态、diff、位置、计划、标题、todo 和不受支持内容不会进入 wire。 + +`session/cancel` 和 `$/cancel_request` 进入同一个提示词自有取消路径。关联结尾只映射到标准 stop reason 和 JSON-RPC error;模型输出达到上限时报告 `max_tokens`。ACP 不返回额外 DSH 结果结构。 + +## 考虑过的替代方案 + +**增加私有控制器扩展。** 已拒绝,因为标准 ACP v1 已经承载所需生命周期、配置、MCP、取消、权限和语义更新概念。私有扩展会使通用 SDK 客户端不完整。 + +**恢复之前的编辑器投影。** 已拒绝,因为计划、终端、diff、卡片、导航和人工 elicitation 属于展示职责。语义工具和 reasoning 事实可以作为有用的自动化遥测,而无需导入展示模块。 + +**实现所有 ACP 会话方法。** 已拒绝。列出、恢复和关闭已经完成持久自动化生命周期。加载/重放、删除和 fork 会引入本用例不需要的独立 transcript、破坏性存储和 lineage 语义。 + +**使用不稳定 provider 方法发现模型。** 已拒绝,因为标准会话配置选项可以表达该选择,并保持会话 scope。 + +**把每个 DSH runtime 字段复制到 ACP 元数据。** 已拒绝,因为精确 token 明细、私有结果状态、程序化展示名称和每 MCP tunable 没有稳定 ACP v1 对应项。 + +## 验证 + +聚焦测试覆盖:无私有元数据的确切能力公布;模型/reasoning 选择、无效和并发变更、拓扑更新以及图片路由固定;stdio/HTTP MCP 设置、声明回滚、scope 隔离、恢复和释放;列表分页、规范 workspace 校验、活动冲突、关闭/恢复和重启恢复;消息/思考/工具/用量顺序与 id;工具先于权限;标准 stop reason;请求和会话取消;连接丢失 teardown。 + +通用 keyless conformance 测试会启动真实 ACP demo 两次,并且只使用公开 ACP SDK:选择模型和 reasoning effort、挂载 MCP 服务器、执行工具轮次、观察标准更新、关闭、重启、列出、恢复和取消。它不包含集成专用名称、依赖、元数据或环境行为。 + +## 后果 + +外部自动化项目可以通过稳定 ACP v1 使用 DSH,而无需维护 DSH 专用 runtime 协议。桥接层的控制接口变大,但仍小于 UI:它拥有生命周期和语义互操作,而人工展示和交互仍属于产品客户端。 + +持久生命周期和请求 MCP 挂载让会话创建更严格。配置错误和初始 MCP 失败会在公布前拒绝,关闭会等待真实完全停稳和持久化。这是避免部分 Agent、泄漏工具或孤儿进程所需的所有权证明。 diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml index 9bd8bace40..635851d4b8 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md -2026-07-23-acp-automation-only-protocol.md: deeba55ebb48468af80a6c74a704b18e07f33477 -2026-07-23-acp-automation-only-protocol.zh.md: 0b4d6b2886d34494d321b96abdc1cd30cb112312 +2026-07-23-acp-automation-only-protocol.md: 4bd02298d2a27809099efa90312762aaa1341075 +2026-07-23-acp-automation-only-protocol.zh.md: aa23c816802eae824e251f76b1d5cc6646e08dec diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md index deeba55ebb..4bd02298d2 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md @@ -4,6 +4,8 @@ Status: implemented English | [中文](2026-07-23-acp-automation-only-protocol.zh.md) +> The automation-only boundary remains current. [Standard ACP v1 automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) supersedes only this note's prompt-only method, configuration, MCP, update, and lifecycle inventory; it does not restore ACP as a UI. + ## Problem The ACP bridge had become a second interactive product UI. It translated durable events into editor cards, terminal metadata, diffs, plans, titles, reasoning, commands, modes, model and permission pickers, session navigation, and human elicitation. Those responsibilities duplicated the TUI and the Web client while coupling an automation transport to UI services, persistence queries, presentation policy, and editor-specific conventions. @@ -14,15 +16,15 @@ The snapshot suite complicates removal. Most ACP scenarios exercise the assemble ## Decision -`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol is intentionally small: version negotiation, fresh sessions with one in-flight prompt each, committed assistant text/image updates, per-session cancellation, concurrent sessions, and connection-owned teardown. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge rejects additional directories, MCP servers, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts. +`@deepseek-ai/dsh-acp` is an automation transport under [`packages/acp/acp`](../../../../packages/acp/acp/README.md), outside the `ui` package group. Its public protocol contains standard automation controls rather than presentation: persistent session creation/list/resume/close, one in-flight prompt per session, model configuration, stdio/HTTP MCP mounting, committed semantic updates, cancellation, concurrent sessions, and one-shot permission requests. Prompts preserve text and supported raster images in wire order, while resource links flatten to bracketed textual references; the bridge still rejects additional directories, audio, embedded resources, malformed or empty prompts, unknown sessions, and overlapping prompts. -Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each image prompt rechecks the session's latest exact route, strictly decodes every block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; before the prompt enters the Agent inbox it neither cancels nor waits for unrelated Agent work. A completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. Caller-correctable image-policy failures map to invalid parameters, while route lookup, storage corruption, and persistence failures remain internal faults. +Image capability is truthful rather than structural: `initialize` advertises it only when a durable attachment store exists and the configured exact provider/model resolves with explicit image input. Each prompt snapshots its exact route before asynchronous admission, strictly decodes every image block, and delegates the complete batch to `AttachmentStore.saveImages()` before publishing the user event. That same snapshot drives the admitted turn even if the next-turn configuration changes concurrently. Cancellation reserves and aborts the admission slot before any asynchronous work, waits for already-started writes to quiesce before the prompt settles, and never publishes a late message; before the prompt enters the Agent inbox it neither cancels nor waits for unrelated Agent work. A completed content-addressed write may remain unreachable because destructive rollback is not valid for a deduplicated store. Caller-correctable image-policy failures map to invalid parameters, while route lookup, storage corruption, and persistence failures remain internal faults. -The bridge emits only committed `assistant/message` text and images. A per-session promise chain preserves block and message order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Reasoning, raw chunks, tool activity, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and resource links remain in the durable session log or in UI-specific transports. It does not provide session load/list/delete, commands, modes, configuration selectors, model switching, plan review, or human elicitation. +The bridge emits only committed semantic facts. A per-session promise chain preserves reasoning, assistant block, tool lifecycle, configuration, and usage-update order while assistant image references are asynchronously re-read and integrity-verified for ACP base64 delivery; a missing or corrupt object fails prompt delivery instead of becoming a placeholder. Raw chunks, todos, plans, titles, retry markers, terminal metadata, diffs, locations, and presentation projections remain off the ACP wire. Standard model and reasoning options, list/resume/close, and stdio/HTTP MCP are automation controls; session load/delete/fork, commands, modes, plan review, terminals, client filesystem operations, and human elicitation remain unsupported. One-shot `session/request_permission` remains. It is a machine policy channel for bridge-owned agents, not a human approval UI: the answerer accepts only an exact agent object in the bridge's live session map, delegates foreign or call-less requests, and maps failed RPCs to the fail-closed unavailable outcome. The client chooses allow once, reject once, or cancel, and the bridge never turns that response into a durable grant. Asking policy stays in the approval seam and its producers; [`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.md) uses this channel programmatically. -The app composition contains the agent spine, persistence, checkpoint policy, and ACP transport. It does not mount command, session-query, session-reference, plan-mode, permission-picker, or user-questions services for ACP. +The app composition contains the agent spine, persistence, checkpoint policy, derived session query, and ACP transport. The ACP bridge reads persistence directly for standard resumable summaries; it does not expose command, session-reference, plan-mode, permission-picker, or user-question presentation surfaces. The transport programs interface-level agent, session, and approval services rather than the concrete agent loop. Tool execution stays inside the harness; ACP never delegates shell execution to an editor. stdout carries framed JSON-RPC only, so the app mounts no stdout logger and the bridge does not monkey-patch process output. @@ -30,7 +32,7 @@ Disconnect and plugin disposal share one memoized quiescence boundary. Both succ ## Snapshot boundary -The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite; semantic-checkpoint recovery runs through the headless `stream-json` example because ACP no longer loads sessions. +The ACP snapshot suite still boots the assembled ACP example and retains scenarios that pin backend behavior. Only scenarios driven through deleted UI methods leave the suite. Standard resume restores a persisted Agent without replaying transcript UI, while semantic-checkpoint recovery coverage may still use the headless SDK example when that protocol is the subject. Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthful image capability, fresh-session creation, ordered text/image admission, resource-link flattening, all-member validation before writes, absence of inline base64 in durable events, rejection of empty or unsupported prompts, exact-agent permission ownership, multi-session isolation, prompt settlement after ordered output, verified assistant-image delivery, cancellation during admission without a late followup or cancellation of unrelated Agent work, exclusion of unrelated pre-inbox failures, failed transport closure, ACP-only reload cleanup, and teardown quiescence. An assembled keyless snapshot sends a real inline PNG through the runnable ACP example and pins only its durable reference in the session log. Built and real-stdio smokes reject stray stdout. The `session/new` branch that loses a real stdio close race remains coverage-exempt because the in-memory transport cannot reproduce that ordering; it disposes the unpublished handle, while the surrounding disposal tests pin the no-orphan invariant. @@ -56,6 +58,6 @@ Protocol and lifecycle tests pin stop-reason codecs, version negotiation, truthf ACP has a narrow contract suitable for agents and automation, while TUI and Web own human interaction and presentation. The package has fewer injected services, dependencies, protocol branches, and lifecycle states, and it no longer claims compatibility as a general editor entry point. -Automation clients receive complete committed text/images rather than token deltas or structured tool UI. They inspect durable logs or another API when they need reasoning, tool traces, titles, or richer state. Fresh-session-only operation also means callers that need durable browsing or resume use a host API rather than ACP. +Automation clients receive committed message, reasoning, generic tool, configuration, and usage facts rather than token deltas or structured tool UI. Standard list/resume/close and session configuration cover automation lifecycle without adding navigation, transcript replay, titles, or other human presentation. Backend snapshot coverage therefore remains transport-coupled to ACP even though that transport is incidental to the behavior under test. diff --git a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md index 0b4d6b2886..aa23c81680 100644 --- a/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md @@ -4,6 +4,8 @@ Status: implemented [English](2026-07-23-acp-automation-only-protocol.md) | 中文 +> 仅面向自动化的边界仍然有效。[标准 ACP v1 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)仅取代本说明中仅支持提示词的方法、配置、MCP、更新和生命周期清单;它不会把 ACP 恢复为 UI。 + ## 问题 ACP(Agent Client Protocol)桥接层已经变成第二套交互式产品 UI。它将持久事件转换为编辑器卡片、终端元数据、diff、计划、标题、推理(reasoning)、命令、模式、模型和权限选择器、会话导航以及面向人类的询问。这些职责与 TUI 和 Web 客户端重复,同时将自动化传输层与 UI 服务、持久化查询、展示策略和编辑器特定约定耦合在一起。 @@ -14,15 +16,15 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 ## 决策 -`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.zh.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议特意保持精简:版本协商、全新会话(每个会话最多允许一个进行中的提示词)、已提交的助手文本/图片更新、按会话取消、并发会话,以及由连接负责的资源清理。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层会拒绝附加目录、MCP 服务器、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。 +`@deepseek-ai/dsh-acp` 是位于 [`packages/acp/acp`](../../../../packages/acp/acp/README.zh.md) 下、独立于 `ui` 包组的自动化传输层。其公开协议包含标准自动化控制,而非展示:持久会话创建/列出/恢复/关闭、每会话一个在途提示词、模型配置、stdio/HTTP MCP 挂载、已提交语义更新、取消、并发会话和一次性权限请求。提示词按协议顺序保留文本与受支持光栅图片,资源链接则展平为方括号文本引用;桥接层仍拒绝附加目录、音频、嵌入资源、格式错误或空提示词、未知会话和重叠提示词。 -图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个图片提示词都会重新检查会话的最新确切路由、严格解码全部块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;提示词进入 Agent inbox 前既不会取消,也不会等待无关的 Agent 工作。已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。可由调用方修正的图片策略失败会映射为无效参数,路由查询、存储损坏和持久化失败则仍属于内部故障。 +图片能力必须真实,而不能只看结构:只有持久附件存储存在,且配置的确切提供方/模型解析后明确支持图片输入时,`initialize` 才会公布该能力。每个提示词都会在异步准入前快照确切路由、严格解码全部图片块,并在发布用户事件前把完整批次委托给 `AttachmentStore.saveImages()`。即使下一轮配置并发变化,同一快照仍驱动已准入轮次。取消会在任何异步工作前预留并中止准入槽位,使提示词在已经启动的写入停稳后才结算,而且绝不发布迟到消息;提示词进入 Agent inbox 前既不会取消,也不会等待无关的 Agent 工作。已经完成的内容寻址写入可能保持不可达,因为对去重存储执行破坏性回滚并不正确。可由调用方修正的图片策略失败会映射为无效参数,路由查询、存储损坏和持久化失败则仍属于内部故障。 -桥接层只发出已提交的 `assistant/message` 文本与图片。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时保持块与消息顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。推理、原始分片、工具活动、待办事项、计划、标题、重试标记、终端元数据、diff、位置和资源链接仍保留在持久会话日志或 UI 专用传输层中。它不提供会话加载、列出与删除、命令、模式、配置选择器、模型切换、plan 评审或面向人类的询问。 +桥接层只发出已提交的语义事实。每个会话使用一条 Promise 链,在异步重新读取并校验助手图片引用、将其转换为 ACP base64 交付时,保持 reasoning、assistant 块、工具生命周期、配置和用量更新顺序;对象缺失或损坏会使提示词交付失败,而不是变成占位符。原始分片、待办事项、计划、标题、重试标记、终端元数据、diff、位置和展示投影不会进入 ACP wire。标准模型和 reasoning 选项、列出/恢复/关闭以及 stdio/HTTP MCP 属于自动化控制;会话加载/删除/fork、命令、模式、plan 评审、终端、客户端文件系统操作和面向人类的询问仍不受支持。 保留一次性 `session/request_permission`。它是为桥接层拥有的 agent 提供的机器策略通道,而不是面向人类的审批 UI:应答者只接受桥接层当前会话映射中登记的同一 agent 对象;不属于桥接层当前 agent 的请求或未关联具体调用的请求会继续委派;RPC 失败则映射为故障时默认拒绝的 `unavailable` 结果。客户端可选择允许一次、拒绝一次或取消,桥接层绝不会将该响应转换为持久授权。询问策略仍归审批 seam 及其生产者所有;[`dsh-subagent-acp`](../../../../packages/subagent/subagent-acp/README.zh.md) 会以程序化方式使用该通道。 -应用组装包含 agent 主干、持久化、检查点策略和 ACP 传输层。它不会为 ACP 挂载命令、会话查询、会话引用、plan mode、权限选择器或用户交互服务。 +应用组装包含 agent 主干、持久化、检查点策略、派生会话查询和 ACP 传输层。ACP 桥接层直接读取持久化以生成标准可恢复摘要;它不公开命令、会话引用、plan mode、权限选择器或用户问题展示接口。 传输层调用 agent、会话和审批的接口服务,而不依赖具体的 agent loop(智能体循环)。工具执行仍留在 harness 内;ACP 绝不会把 shell 执行委派给编辑器。stdout 只承载分帧 JSON-RPC,因此 app 不挂载 stdout logger,桥接层也不会 monkey-patch 进程输出。 @@ -32,7 +34,7 @@ ACP 仍有一个有用的职责:另一个 agent(智能体)或自动化控 ## 快照边界 -ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除的 UI 方法驱动的场景;由于 ACP 不再加载会话,语义检查点恢复通过 headless `stream-json` 示例执行。 +ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后端行为的场景。从该套件移出的只有通过已删除 UI 方法驱动的场景。标准恢复会还原持久 Agent,但不会重放 transcript UI;当 headless SDK 协议本身是测试对象时,语义检查点恢复覆盖仍可使用其示例。 协议与生命周期测试会锁定停止原因编解码器、版本协商、真实图片能力、新会话创建、有序文本/图片准入、资源链接展平、写入前校验全部成员、持久事件中不含内联 base64、拒绝空提示词或不受支持的提示词、基于同一 agent 对象的权限归属、多会话隔离、在有序输出后结算提示词、经过校验的助手图片交付、准入期间取消且不产生迟到 followup 或取消无关 Agent 工作、排除进入 inbox 前的无关失败、传输关闭失败、ACP 专属重载清理,以及拆卸完全停稳。组装后的无密钥快照通过可运行 ACP 示例发送一张真实内联 PNG,并在会话日志中只固定其持久引用。构建产物冒烟测试与真实 stdio 冒烟测试会拒绝混入 stdout 的额外输出。`session/new` 中在真实 stdio 关闭竞态中落败的分支仍豁免覆盖率要求,因为内存传输层无法复现这一顺序;该分支会 dispose 尚未发布的 handle,而周边 dispose 测试会锁定无遗留资源不变式。 @@ -58,6 +60,6 @@ ACP 快照套件仍会启动组装后的 ACP 示例,并保留用于锁定后 ACP 具有适合 agent 与自动化的精简约定,而 TUI 和 Web 拥有面向人类的交互与展示。该包注入的服务、依赖、协议分支和生命周期状态更少,也不再将自身定位为通用编辑器入口。 -自动化客户端收到完整的已提交文本/图片,而不是 token 增量或结构化工具 UI。当它们需要推理、工具跟踪信息、标题或更丰富的状态时,需要查看持久日志或其他 API。只支持全新会话也意味着,需要浏览持久会话或恢复会话的调用方必须使用 host API,而不是 ACP。 +自动化客户端收到已提交消息、reasoning、通用工具、配置和用量事实,而不是 token 增量或结构化工具 UI。标准列出/恢复/关闭和会话配置覆盖自动化生命周期,同时不增加导航、transcript 重放、标题或其他人工展示。 因此,后端快照测试仍与 ACP 传输层耦合,尽管对于受测行为而言,该传输层只是附带因素。 diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 5fa1696881..381249a21a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 9c679db07b9922d49e5ddb5f64c6726c7443e4ff -config-catalog.zh.md: b9b9b4ae8243f71fb8c60aec314709d1da325dbb +config-catalog.md: 33d4a75cbe6a127ff8ca22e3eb3ec8e68ea66866 +config-catalog.zh.md: 7c467efe62f46252f01d8ce9b4e1ce846af15818 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9c679db07b..33d4a75cbe 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -13,7 +13,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` +Requires: `agents` · `llm` · `sessionPersistence` · `sessions` ```ts config-catalog /** Plugin config: the provider/model selection used for each ACP-created agent. */ @@ -22,6 +22,8 @@ export interface AcpConfig { provider?: string /** Model name for created agents. */ model?: string + /** Maximum summaries returned by one session/list page. */ + sessionListPageSize?: number /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -29,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/acp/acp/src/index.ts:71`](../packages/acp/acp/src/index.ts) +Source: [`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) @@ -1770,7 +1772,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:36`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:37`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index b9b9b4ae82..7c467efe62 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -15,7 +15,7 @@ ## `@deepseek-ai/dsh-acp` -需要:`agents` +需要:`agents` · `llm` · `sessionPersistence` · `sessions` ```ts config-catalog /** Plugin config: the provider/model selection used for each ACP-created agent. */ @@ -24,6 +24,8 @@ export interface AcpConfig { provider?: string /** Model name for created agents. */ model?: string + /** Maximum summaries returned by one session/list page. */ + sessionListPageSize?: number /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -31,7 +33,7 @@ export interface AcpConfig { 依赖:`Stream`(`@agentclientprotocol/sdk`) -来源:[`packages/acp/acp/src/index.ts:71`](../packages/acp/acp/src/index.ts) +来源:[`packages/acp/acp/src/index.ts:74`](../packages/acp/acp/src/index.ts) @@ -1772,7 +1774,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:36`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:37`](../packages/session/session-persistence-sqlite/src/index.ts) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0255a185ba..885a4deb55 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: 43b6c2b13ce2c09b2e3c938e30651e5dcbca36b3 -event-producer-consumer.zh.md: 7813f488a494cfed0ede01443d5bad31bd4774f1 +event-producer-consumer.md: 1470d1002062b8283c08e9989e9bedf71a09f316 +event-producer-consumer.zh.md: c801ea16ec322c4a949a9e35c8e103dfb7e383f2 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 43b6c2b13c..1470d10020 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,7 +37,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 7813f488a4..c801ea16ec 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,7 +39,7 @@ | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy), [`skill-filesystem`](../packages/skill/skill-filesystem) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:65`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compaction`](../packages/compaction/compaction), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission-presets`](../packages/interaction/permission-presets), [`plan-mode`](../packages/plan/plan-mode), [`schedule`](../packages/schedule/schedule), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`time-context`](../packages/context/time-context), [`tool-workflow`](../packages/workflow/tool-workflow), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index cf23d7b690..22bd237e38 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 5db92716a36f51120dcec5f5bd4c5a5ae2b4db72 -persistence.zh.md: 751b8d49290e00e6f0e4e2389ebff241d5cf7d5c +persistence.md: a1bec03a1c5afefa81c713a07bcff2f80e586794 +persistence.zh.md: 2bece66c957d140eacfc364f60527eaa8f20e472 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 5db92716a3..a1bec03a1c 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -285,6 +285,14 @@ readRaw(_id: SessionId, signal?: AbortSignal): Promise +/** + * Ensure a live session has a durable header even when it has no events. + * Ordinary sessions remain lazily materialized; lifecycle frontends call + * this only when an empty session itself is a durable resumable resource. + * @param _session - exact live session whose registered header is materialized. + */ +ensureMaterialized(_session: Session): Promise + /** * Durably persist a batch of events. Honors the append-only and contiguous- * seq contracts: the first event's `seq` MUST equal the stored next-seq @@ -379,7 +387,7 @@ abstract list(signal?: AbortSignal): Promise abstract listSnapshots(signal?: AbortSignal): Promise ``` -Types: [SessionEvent](session.md) · [SessionId](core.md) +Types: [Session](session.md) · [SessionEvent](session.md) · [SessionId](core.md) Source: [`packages/session/session-persistence/src/index.ts`](../../packages/session/session-persistence/src/index.ts) diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index 751b8d4929..2bece66c95 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -285,6 +285,14 @@ readRaw(_id: SessionId, signal?: AbortSignal): Promise +/** + * Ensure a live session has a durable header even when it has no events. + * Ordinary sessions remain lazily materialized; lifecycle frontends call + * this only when an empty session itself is a durable resumable resource. + * @param _session - exact live session whose registered header is materialized. + */ +ensureMaterialized(_session: Session): Promise + /** * Durably persist a batch of events. Honors the append-only and contiguous- * seq contracts: the first event's `seq` MUST equal the stored next-seq @@ -379,7 +387,7 @@ abstract list(signal?: AbortSignal): Promise abstract listSnapshots(signal?: AbortSignal): Promise ``` -Types: [SessionEvent](session.zh.md) · [SessionId](core.zh.md) +Types: [Session](session.zh.md) · [SessionEvent](session.zh.md) · [SessionId](core.zh.md) Source: [`packages/session/session-persistence/src/index.ts`](../../packages/session/session-persistence/src/index.ts) diff --git a/examples/acp-agent/agent-instructions.cordis.snapshot.yml b/examples/acp-agent/agent-instructions.cordis.snapshot.yml index 92744506fb..659bfba286 100644 --- a/examples/acp-agent/agent-instructions.cordis.snapshot.yml +++ b/examples/acp-agent/agent-instructions.cordis.snapshot.yml @@ -28,5 +28,12 @@ - insert: - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro - id: workspace-context-compaction name: './tests/fixtures/workspace-context-compaction.ts' diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml index 96ba6f0b8c..2f05132b93 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml @@ -28,3 +28,10 @@ name: '@deepseek-ai/dsh-code-runtime-worker-thread' - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index ce9a32c2ef..be8b1e6777 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -13,7 +13,7 @@ import { cleanupAcpExampleTest } from './cleanup.ts' /** * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over - * its stdio, drive it with a real ClientSideConnection, send a real prompt, and + * its stdio, drive it with a real ACP SDK client app, send a real prompt, and * verify the WORLD (a file the agent wrote), not the agent's self-report. Owns * and disposes the subprocess in afterEach. Key-gated. * @@ -118,9 +118,10 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') expect(proof).toContain('ACP_OK') - // The transport exposes only committed assistant text; tool execution is - // proved by the world effect above and remains session-log data. - expect(updates.length).toBeGreaterThan(0) - expect(updates.every(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) + // The transport exposes committed semantic facts without UI projections; + // the world effect independently proves that the standard tool lifecycle ran. + expect(updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) + expect(updates.some(update => update.sessionUpdate === 'tool_call')).toBe(true) + expect(updates.some(update => update.sessionUpdate === 'tool_call_update')).toBe(true) }, 180_000) }) diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml index a9dbfb2d9a..6cded4a260 100644 --- a/examples/acp-agent/tests/lsp.cordis.snapshot.yml +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -27,3 +27,10 @@ maxLocations: 1 - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl index 9ba3346933..6d948c561f 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.expected.jsonl @@ -1,4 +1,14 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-define","title":"cordis_define","kind":"other","status":"in_progress","rawInput":{"plugin":{"kind":"new","idPrefix":"snap"},"name":"Snapshot Marker","purpose":"Exercise the dynamic Cordis Package lifecycle in the snapshot.","code":{"host":"return { apply() {} }"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-define","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"run_code","kind":"other","status":"in_progress","rawInput":{"code":"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\nreturn { run, inspected };","description":"Run and inspect the dynamic Cordis Package"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-code","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-direct-child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Check direct child","prompt":"Reply with exactly DIRECT_CHILD_OK and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-direct-child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DIRECT_CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-workflow","title":"workflow","kind":"other","status":"in_progress","rawInput":{"script":"phase('Delegate')\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\nreturn { reply }","meta":{"name":"advanced-acp-snapshot","description":"exercise one workflow child through ACP"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-workflow","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-undefine","title":"cordis_undefine","kind":"other","status":"in_progress","rawInput":{"pluginId":"snap-1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-undefine","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"ADVANCED_ACP_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/agent-instructions/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/agent-instructions/stdout.expected.jsonl index 82ae8907ca..804afd5011 100644 --- a/examples/acp-agent/tests/snapshots/agent-instructions/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/agent-instructions/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"nested/task.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/nested/task.txt\nfile\n\n1: snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_delimiter_read","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"scope/task.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_delimiter_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/scope/task.txt\nfile\n\n1: delimiter path snapshot task\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/background-job-admission/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/background-job-admission/stdout.expected.jsonl index 7f71f1b79b..c4a7805ddd 100644 --- a/examples/acp-agent/tests/snapshots/background-job-admission/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/background-job-admission/stdout.expected.jsonl @@ -1,4 +1,12 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"bounded-task-first","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"while :; do sleep 60; done","description":"Hold the only background job slot","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"bounded-task-first","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started background job bash-1"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"bounded-task-second","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf SHOULD_NOT_RUN > second-task-ran.txt; while :; do sleep 60; done","description":"Attempt a second background job","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"bounded-task-second","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started background job bash-2"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"bounded-task-kill","title":"job_kill","kind":"other","status":"in_progress","rawInput":{"job_id":"bash-1","reason":"free the bounded task slot"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"bounded-task-kill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"requested cancellation of job bash-1"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"bounded-task-side-effect-check","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"test ! -e second-task-ran.txt","description":"Verify the rejected producer did not run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"bounded-task-side-effect-check","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no output)\n[exit code: 1]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"BOUNDED_BACKGROUND_TASKS"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl index 82ae8907ca..e6909c49d3 100644 --- a/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-spill/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_spill","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"node -e \"process.stdout.write('SPILL_START-' + 'x'.repeat(2000) + '-SPILL_END')\"","description":"Print large deterministic output"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SPILL_START-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx-SPILL_END\n\n(Omitted 1417 bytes. Full formatted result stored at: {{spillLocator:bash.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/bash-tool-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/bash-tool-turn/stdout.expected.jsonl index 82ae8907ca..813e8f25f5 100644 --- a/examples/acp-agent/tests/snapshots/bash-tool-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/bash-tool-turn/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo TERMINAL_OK","description":"Echo TERMINAL_OK to verify terminal access"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","status":"completed","content":[{"type":"content","content":{"type":"text","text":"TERMINAL_OK\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl index 7b2bc6dff8..ef5922207e 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"BOTH_OK"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to call the run_code tool with a TypeScript program that runs `echo BOTH_OK` via `tools.bash` and returns its output."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","title":"run_code","kind":"other","status":"in_progress","rawInput":{"code":"const result = await tools.bash({ command: \"echo BOTH_OK\", description: \"Print BOTH_OK\" });\nreturn result.stdout.text;","description":"Run echo BOTH_OK via tools.bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Era4M5eh79bvNOIey5q90401","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BOTH_OK\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The output is \"BOTH_OK\" (with a trailing newline, but that's fine). The user asked me to reply with that output only."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"BOTH_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl index cb25d1c6bb..2befbba270 100644 --- a/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel-tool-calls/stdout.expected.jsonl @@ -1,3 +1,7 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_wait","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"node -e \"require('node:fs').writeFileSync('started.txt', 'started'); setInterval(() => {}, 1000)\"","description":"Wait until cancellation"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_wait","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skipped","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf skipped > skipped.txt","description":"Write skipped marker"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skipped","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: tool call aborted before dispatch"}}]}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl index 078b607e91..f3cd59b8d4 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl index 4f0fb2e442..ad1f83e0c0 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-read-image/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","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"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"code-image-call","title":"run_code","kind":"other","status":"in_progress","rawInput":{"code":"const bytes = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130];\nawait tools.bash({ command: \"node -e \\\"require('node:fs').writeFileSync('red.png',Buffer.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,2,0,0,0,144,119,83,222,0,0,0,12,73,68,65,84,120,156,99,248,207,192,0,0,3,1,1,0,201,254,146,239,0,0,0,0,73,69,78,68,174,66,96,130]));\\\"\", description: \"Create a one pixel PNG\" });\nconst image = await tools.read_image({ file_path: \"red.png\" });\nreturn image.path;","description":"Create and inspect one image"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"code-image-call","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/red.png"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl index 9ca552c9fd..4302979733 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to write a single run_code program that:\n1. Calls bash tool twice: `echo CODE_ONE` and `echo CODE_TWO`\n2. console.log exactly `captured output`\n3. Return the two outputs joined with a plus sign\n\nLet me write this."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","title":"run_code","kind":"other","status":"in_progress","rawInput":{"code":"\nconst out1 = await tools.bash({command: \"echo CODE_ONE\", description: \"Print CODE_ONE\"});\nconst out2 = await tools.bash({command: \"echo CODE_TWO\", description: \"Print CODE_TWO\"});\nconsole.log(\"captured output\");\nconst text1 = out1.stdout.text.trim();\nconst text2 = out2.stdout.text.trim();\nreturn text1 + \"+\" + text2;\n","description":"Run two echo commands and join outputs"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_UiQPVqoELyzBZCY5pm1z7875","status":"completed","content":[{"type":"content","content":{"type":"text","text":"captured output\nCODE_ONE+CODE_TWO"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The program ran successfully. The console.log output \"captured output\" appeared, and the return value is \"CODE_ONE+CODE_TWO\". The user asked me to reply with that joined string only."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl index 15b1d17236..f423eaeb56 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-workspace-context/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_workspace_read","title":"run_code","kind":"other","status":"in_progress","rawInput":{"code":"return await tools.read({ file_path: 'nested/task.txt' })","description":"Read nested/task.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_workspace_read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\n \"path\": \"{{cwd}}/nested/task.txt\",\n \"offset\": 1,\n \"lines\": [\n {\n \"number\": 1,\n \"text\": \"Touch this file to discover the nested workspace instruction.\"\n }\n ],\n \"totalLines\": 1\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"**Code Mode workspace handshake:** `CODE_MODE_CONTEXT_OK`"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl index eff1b66bf3..4e2224847c 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-api","title":"cordis_inspect_query","kind":"other","status":"in_progress","rawInput":{"platform":"host","provider":"Service","method":"listService","input":{"service":"tools"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-api","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Service\",\n \"method\": \"listService\",\n \"data\": {\n \"mode\": \"service\",\n \"service\": {\n \"key\": \"tools\",\n \"description\": \"Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch.\",\n \"access\": {\n \"optional\": {\n \"expression\": \"ctx.get(\\\"tools\\\")\",\n \"requiresUndefinedCheck\": true\n },\n \"hardDependency\": {\n \"inject\": [\n \"tools\"\n ],\n \"expression\": \"ctx.tools\"\n }\n },\n \"methods\": [\n {\n \"signature\": \"presentAs(mode: ToolPresentationMode): () => void\",\n \"description\": \"Present the calling scope's tools in `mode` instead of the deployment default. Nearest scope on the chain wins, so a preset's standing declaration covers every agent joined under it.\\n\\nScoped only, and one declaration per scope: this is how an agent preset composes Code Mode agents beside native ones in the same process, and a process-global override would be the `mode` config field instead.\",\n \"parameters\": [\n {\n \"name\": \"mode\",\n \"description\": \"the presentation the covered agents' models see.\"\n }\n ],\n \"returns\": \"the exact disposer that restores the deployment default.\"\n },\n {\n \"signature\": \"register(definition: ToolDefinition): () => void\",\n \"description\": \"Register globally or in the calling agent scope. Scoped tools shadow globals; duplicates within one layer and the reserved `run_code` name fail.\",\n \"parameters\": [\n {\n \"name\": \"definition\",\n \"description\": \"tool schema, execution, and optional finalization/presentation callbacks.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the tool.\"\n },\n {\n \"signature\": \"restrict(filter: ToolRestriction): () => void\",\n \"description\": \"Restrict global tools for the calling agent scope. Empty filters, unknown names, scope-local names, and reserved transport names fail. Restrictions intersect; scoped registrations remain visible.\",\n \"parameters\": [\n {\n \"name\": \"filter\",\n \"description\": \"global-tool mask: `allow` (keep only) and/or `deny` (remove).\"\n }\n ],\n \"returns\": \"the exact disposer that lifts this restriction.\"\n },\n {\n \"signature\": \"guard(guard: ToolGuard): () => void\",\n \"description\": \"Register a monotonic guard after the extensible `tools/pre-execute` waterfall. A plain-context guard applies globally; one registered through `agent.ctx` applies only to that agent. Any matching guard may deny by returning a reason, while no guard can force-allow a call another guard denied. The exact effect disposer is returned for ordered ownership and HMR cleanup.\",\n \"parameters\": [\n {\n \"name\": \"guard\",\n \"description\": \"synchronous check; a returned string denies the execution.\"\n }\n ],\n \"returns\": \"the exact disposer that unregisters the guard.\"\n },\n {\n \"signature\": \"get(name: string, scope?: ScopeKey): ToolDefinition | undefined\",\n \"description\": \"Look up a tool as one scope sees it (scoped shadows global; a restricted-away global reads as absent). Presenters pass the calling agent so the rendered card matches the definition that actually executed.\",\n \"parameters\": [\n {\n \"name\": \"name\",\n \"description\": \"the tool name as registered.\"\n },\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"the definition the scope resolves, or undefined when none is visible.\"\n },\n {\n \"signature\": \"schemas(scope?: ScopeKey): ToolSchema[]\",\n \"description\": \"Project visible definitions onto the allowlisted model-facing schema fields, excluding execution and presentation callbacks.\",\n \"parameters\": [\n {\n \"name\": \"scope\",\n \"description\": \"the viewing scope (the agent); omitted = the global view.\"\n }\n ],\n \"returns\": \"one deep-cloned schema per visible tool.\"\n },\n {\n \"signature\": \"executionMode(exec: ToolExecutionInput): ToolExecutionMode\",\n \"description\": \"Classify a pending call through the caller's visible tool definition. Only an exact `true` is parallel; unknown, hidden, undeclared, invalid, or throwing classifiers are exclusive.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"call name, parsed arguments, and optional agent scope.\"\n }\n ],\n \"returns\": \"the fail-closed scheduling mode.\"\n },\n {\n \"signature\": \"async execute(exec: ToolExecutionInput): Promise\",\n \"description\": \"Execute through pre-policy, guards, around-dispatch, post-policy, definition-owned content finalization, and final notification. Tool and listener failures resolve as materialized error results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen snapshot final observers receive. Cancellation arriving after entry and before final result materialization skips a not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a successful started outcome with `ABORTED`; already-started work is still drained and may retain a tool-owned structured error.\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the typed same-process call input. The registry assigns its correlation token before policy begins.\"\n }\n ],\n \"returns\": \"the materialized final result.\"\n }\n ]\n },\n \"referencedTypes\": []\n }\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"inspect-tools-event","title":"cordis_inspect_query","kind":"other","status":"in_progress","rawInput":{"platform":"host","provider":"Event","method":"listEvents","input":{"event":"tools/pre-execute"}}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"inspect-tools-event","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{\n \"platform\": \"host\",\n \"provider\": \"Event\",\n \"method\": \"listEvents\",\n \"data\": {\n \"mode\": \"event\",\n \"event\": {\n \"name\": \"tools/pre-execute\",\n \"description\": \"Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Async gates must observe `exec.signal`; the registry rechecks cancellation after they settle but never abandons their promise. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls.\",\n \"mode\": \"waterfall\",\n \"signature\": \"'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise\",\n \"parameters\": [\n {\n \"name\": \"exec\",\n \"description\": \"the pending call (name, parsed arguments, caller agent).\"\n }\n ]\n },\n \"referencedTypes\": []\n }\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CORDIS_INSPECT_JSDOC_OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl index 1ca475b573..0c31d0557c 100644 --- a/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/empty-response-retry/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Recovered."}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Recovered."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl index 4ad17f44e8..f32ff5be4f 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.expected.jsonl @@ -1,3 +1,3 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl index 0bf109087a..035713f0d4 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.expected.jsonl @@ -1,5 +1,9 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt","description":"Write file outside workspace and verify","sandbox_permissions":"danger-full-access","justification":"the user asked to write a file outside the workspace"}}}} {"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","status":"completed","content":[{"type":"content","content":{"type":"text","text":"escalated\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The command succeeded — it wrote the file, read it back (output \"escalated\"), and removed it. The user asked me to reply with the single word DONE after the result."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl index 23d260507b..afc6f47943 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.expected.jsonl @@ -1,5 +1,9 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf 'escalated\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt","description":"Write to /tmp and verify, then clean up","sandbox_permissions":"danger-full-access","justification":"the user asked to write a file outside the workspace"}}}} {"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_WB1vnPomi8yr6MlcFKTj7912","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user rejected the escalation. As instructed, I will not retry and will not work around it — just explain in one short sentence and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user rejected the permission escalation, so this command cannot be run."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl index 82ae8907ca..4b5829fcb6 100644 --- a/examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-delete-recreate/stdout.expected.jsonl @@ -1,4 +1,13 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to perform exact steps in order:\n1. read deleted.txt\n2. bash rm deleted.txt\n3. read deleted.txt again and observe not-found error\n4. write deleted.txt with content \"fresh\\n\"\n5. reply with exactly \"DONE\"\n\nLet me do step 1 first."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vDjxu2gdu87Gj8XZZvba8767","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"deleted.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vDjxu2gdu87Gj8XZZvba8767","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/deleted.txt\nfile\n\n1: original\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"rm deleted.txt","description":"Delete deleted.txt file"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ET_sBOnFnMNrptvzTOpSwBg6697","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no output)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"deleted.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ET_pKZS54ZqkXTdxAsdLQR91261","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: cannot read \"{{cwd}}/deleted.txt\": not found"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_ET_OqjRZggKy6eetff5jh3V9977","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"deleted.txt","content":"fresh\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ET_OqjRZggKy6eetff5jh3V9977","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/deleted.txt\nfile\n\nCreated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl index 82ae8907ca..45299795a6 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.expected.jsonl @@ -1,4 +1,11 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to:\n1. Read config.txt in the current directory\n2. Use the edit tool to replace DEBUG with RELEASE\n3. Reply with exactly \"DONE\"\n\nLet me start by reading the file."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_S6krdIDHoSCGWv7WnXX97617","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/config.txt\nfile\n\n1: mode=DEBUG\n2: level=info\n\n(End of file - total 2 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Now I need to replace \"DEBUG\" with \"RELEASE\" using the edit tool."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"config.txt","old_string":"DEBUG","new_string":"RELEASE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_vOytneZ0XpsLslEEJAxR6398","status":"completed","content":[{"type":"content","content":{"type":"text","text":"The file {{cwd}}/config.txt has been updated successfully."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Done. The user wants me to reply with exactly \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl index c8a50f539b..d20d39f653 100644 --- a/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-escalation-approved/stdout.expected.jsonl @@ -1,5 +1,9 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to create a file using the write tool with sandbox_permissions. Let me do that."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"escalated.md","content":"escalated","sandbox_permissions":"danger-full-access","justification":"the user asked to escalate this write"}}}} {"jsonrpc":"2.0","id":1,"method":"session/request_permission","params":{"sessionId":"{{sessionId}}","toolCall":{"toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227"},"options":[{"optionId":"allow-once","name":"Allow once","kind":"allow_once"},{"optionId":"reject-once","name":"Reject","kind":"reject_once"}]}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Fnymmavpr4klMDy4Fdej3227","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/escalated.md\nfile\n\nCreated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file was created successfully. The user asked me to reply with exactly the single word DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl index 691b11cef0..72f0780d3e 100644 --- a/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-glob-sampling/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"GLOB_SAMPLED"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to call glob exactly once with pattern * and path tree, then reply with exactly \"GLOB_SAMPLED\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","title":"glob","kind":"other","status":"in_progress","rawInput":{"pattern":"*","path":"tree"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_1cLZjkCW0vxVw0e3xVfh3430","status":"completed","content":[{"type":"content","content":{"type":"text","text":"tree/archive/a.ts\ntree/docs/guide.md\ntree/src/index.ts\ntree/test/spec.ts\n\n(Showing 4 of 8 paths, sampled across 4 of the 6 top-level entries this pattern matched instead of taken in modification-time order. Narrow path to inspect a specific subtree. The complete result could not be saved; narrow pattern or path to see more.)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The glob result shows it was sampled - 4 of 8 paths across 4 of 6 top-level entries. I need to reply with exactly \"GLOB_SAMPLED\" as instructed."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"GLOB_SAMPLED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl index 82ae8907ca..68fc443b98 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.expected.jsonl @@ -1,4 +1,14 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use the edit tool to replace \"blue\" with \"green\" in settings.txt without reading the file first, and then reply with just \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_x0zlnXl5JOxLrAYL9y7P0119","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: edit requires reading \"{{cwd}}/settings.txt\" first — read the file, then retry"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The edit tool requires reading the file first. The user said \"Do not read the file first\" but the tool's own constraints require reading. I need to follow the tool requirements. Let me read the file first."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tfr0gdIclW92oIEgi7NF0891","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/settings.txt\nfile\n\n1: color: blue\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file contains \"color: blue\". I need to replace \"blue\" with \"green\". The edit tool said it requires reading first — now I've read it, so the edit should work."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","title":"edit","kind":"other","status":"in_progress","rawInput":{"file_path":"settings.txt","old_string":"blue","new_string":"green"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GVknJu2tksKkP4lALCwh0926","status":"completed","content":[{"type":"content","content":{"type":"text","text":"The file {{cwd}}/settings.txt has been updated successfully."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The replacement was successful. I'll reply with just \"DONE\" as instructed."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl index 82ae8907ca..6f1e397c1d 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use the read tool with offset 5 and limit 4 to read lines 5 through 8 of big.txt in the current directory. Then reply with exactly the single word DONE.\n\nLet me first check the current directory, then read the file."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"big.txt","offset":5,"limit":4}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_GIZwZS9a7vhWTFCIc7Z35497","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/big.txt\nfile\n\n5: line five\n6: line six\n7: line seven\n8: line eight\n\n(Showing lines 5-8 of 10. Use offset=9 to continue.)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The read tool returned lines 5 through 8 as expected. Now I need to reply with exactly the single word \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl index 82ae8907ca..527a07f711 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to read the file greeting.txt using the read tool (not bash), then reply with exactly the single word \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_hHPZCcivsIkXAGS9jTGy8417","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user asked me to read the file and then reply with exactly the single word \"DONE\". I've read the file. Now I just need to reply with \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl index 82ae8907ca..287dbecd6d 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite-bounded/stdout.expected.jsonl @@ -1,4 +1,12 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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":"max","options":[{"value":"off","name":"off"},{"value":"low","name":"low"},{"value":"high","name":"high"},{"value":"max","name":"max"}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to read data.txt first, then write to replace its contents with the exact line, then reply DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":6311,"size":1000000}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Jxz49JNt6i4oaDnzes2I0794","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":6414,"size":1000000}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt","content":"The replacement line is deliberately longer than the configured sixty-four byte diff-basis bound."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_ET_7mLiYX652hJA9GW6d1bl4653","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"usage_update","used":6473,"size":1000000}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl index 82ae8907ca..e524e59aff 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.expected.jsonl @@ -1,4 +1,11 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to:\n1. Read data.txt using the read tool\n2. Replace its entire contents with exactly \"replaced\" using the write tool\n3. Reply with exactly \"DONE\""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_n4eRJuGoxNR07svgNtk82243","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\n1: original contents\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file contains \"original contents\". Now I'll replace it with \"replaced\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"data.txt","content":"replaced"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_N23EvXjDo4c8enyWpIUq4043","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/data.txt\nfile\n\nUpdated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file has been replaced successfully. Now I just reply with \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl index 82ae8907ca..7437dfa079 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to create a file named notes.txt with the content \"hello world\" using the write tool, then reply with \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"notes.txt","content":"hello world"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_APMUCJJm9lrTSlVbg6dB0185","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/notes.txt\nfile\n\nCreated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file has been created. Now I just need to reply with \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl index a20b86580e..45754888bd 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.expected.jsonl @@ -1,2 +1,2 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl index acfccdd778..b8fb6acfcd 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PONG"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl index e42141f739..5a4b35ed19 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.expected.jsonl @@ -1,4 +1,11 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run the bash tool with the command \"echo HELLO\". If it's rejected, retry once. Then quote the final result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Echo HELLO to stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_VAByyMjsct4c7P6k1ysX9256","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by policy: retry once"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The first call was rejected by policy. The user said to retry once. Let me retry."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Echo HELLO to stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tDV4j1p5eAeHTtQhXOfn6856","status":"completed","content":[{"type":"content","content":{"type":"text","text":"HELLO\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The second attempt succeeded. The final result is \"HELLO\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The final tool result verbatim:\n\n```\nHELLO\n```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl index da09fe35c3..0caa64e952 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The tool result was:\n\n```\nHELLO\n```\n\nIt completed successfully with exit code 0."}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_HbCMzTslWBZTSphWN0z97382","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Run echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_HbCMzTslWBZTSphWN0z97382","status":"completed","content":[{"type":"content","content":{"type":"text","text":"HELLO\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user asked me to report the tool result verbatim. The result was \"HELLO\" with an exit code of 0 (success)."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool result was:\n\n```\nHELLO\n```\n\nIt completed successfully with exit code 0."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl index 979ff3326b..1ffde44766 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_6k0oGSliVHxGSgqBmMEO4311","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: the user rejected tool \"bash\""}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The bash tool returned an error saying it requires manual approval in this session. I'll report this verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash requires manual approval in this session\n```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl index 2bb15b6f03..e2f7b99891 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Run echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: bash is disabled by policy in this session"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl index d25d2a6db0..a35e74401e 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.expected.jsonl @@ -1,3 +1,3 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl index 05c4f9235b..d3a385c2b8 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"teal"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user's favorite color is teal, as stated in the context provided by the plugin."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"teal"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl index 0f8f000343..dd0bdf8f19 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.expected.jsonl @@ -1,5 +1,7 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FIRST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SECOND"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with just the word \"FIRST\" and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"FIRST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"SECOND"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl index acfccdd778..b8fb6acfcd 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PONG"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl index d86218d3b7..55d235a26b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Echo HELLO to stdout"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl index 567b676605..be1f5d46af 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The tool result I received is:\n\n```\nHELLO\n```"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Q6wHtakaip2QNfIXaVJY5458","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Run echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Q6wHtakaip2QNfIXaVJY5458","status":"completed","content":[{"type":"content","content":{"type":"text","text":"HELLO\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user asked me to report the tool result verbatim. The result I got back is:\n\nHELLO\n\nThat's it."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool result I received is:\n\n```\nHELLO\n```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl index 6022fd5747..6badec7ee9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_tv0SMeLXaTuyuVrOxnV97085","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Run echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_tv0SMeLXaTuyuVrOxnV97085","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: bash is disabled by codex policy in this session"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The bash tool is disabled by policy in this session. I need to report this result verbatim to the user."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool result I got back verbatim is:\n\n```\nError: bash is disabled by codex policy in this session\n```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl index d25d2a6db0..a35e74401e 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.expected.jsonl @@ -1,3 +1,3 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl index 05c4f9235b..f42b7d8038 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"teal"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user asked about their favorite color, and the context tells me they previously stated it's teal. They asked me to reply with just the color and stop, without using any tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"teal"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl index 0f8f000343..078c7ac67b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.expected.jsonl @@ -1,5 +1,7 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"FIRST"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SECOND"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with the single word \"FIRST\" and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"FIRST"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with the single word \"SECOND\" and then stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"SECOND"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl index 4f0fb2e442..d8b1564674 100644 --- a/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/inline-image-prompt/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","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"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl index 82ae8907ca..e7dd8b70b9 100644 --- a/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/lsp-definition/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_lsp_definition","title":"lsp","kind":"other","status":"in_progress","rawInput":{"operation":"goToDefinition","file_path":"subject.ts","line":1,"character":7}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_lsp_definition","status":"completed","content":[{"type":"content","content":{"type":"text","text":"subject.ts:1:7\n… 1 more location omitted (limit 1)."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl index bf555a8d1c..af920844be 100644 --- a/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/max-tokens-continue/stdout.expected.jsonl @@ -1,6 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"Starting the write now."}}}} -{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The previous reply hit the output limit while a tool call was still streaming, so that call was discarded and no tool ran."}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Starting the write now."}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"max_tokens"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The previous reply hit the output limit while a tool call was still streaming, so that call was discarded and no tool ran."}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl index c7df2372dc..5d8f7ccfbf 100644 --- a/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/missing-sandbox-runner/stdout.expected.jsonl @@ -1,4 +1,10 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"missing-runner-foreground","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"true","description":"Exercise missing sandbox runner"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"missing-runner-foreground","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: sandbox mode \"read-only\" is requested but no sandbox backend is usable on this host; refusing to run the command unconfined. Install bubblewrap or run a Landlock-enforcing kernel (Linux), ensure sandbox-exec is usable (macOS), or ensure the ACL restricted-token runner can start (Windows) — otherwise switch the consumer to danger-full-access. Runner failure: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"missing-runner-background","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"true","description":"Exercise missing sandbox runner in background","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"missing-runner-background","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started background job bash-1"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"missing-runner-output","title":"job_output","kind":"other","status":"in_progress","rawInput":{"job_id":"bash-1","wait":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"missing-runner-output","status":"completed","content":[{"type":"content","content":{"type":"text","text":"[stderr]\nspawn failed: Error: spawn {{cwd}}/.dsh-missing-sandbox-runner ENOENT\n[sandbox: the sandbox runner itself failed under read-only mode — the command did not run; this is a sandbox problem, not a command failure]\n[status: killed, killed before exit]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"RUNNER_FAILURES_SURFACED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl index 52e86a6a94..6e1ef9c92d 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.expected.jsonl @@ -1,6 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TWO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"TWO"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl index 2bb15b6f03..e2f7b99891 100644 --- a/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/packed-chunks/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a simple bash command and report the result verbatim."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO","description":"Run echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JliP571Bh0QQ8QExbSPk0080","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: bash is disabled by policy in this session"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The bash tool is disabled by policy. I need to report this error verbatim back to the user."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The tool returned:\n\n> Error: bash is disabled by policy in this session\n\nI cannot run the command because the bash tool is disabled by policy."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl index 82ae8907ca..49aff17219 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"a.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"b.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_b","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl index 98a85f5207..5b44571a5e 100644 --- a/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/partial-landlock-child-failure/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"partial-landlock-call","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"false","description":"Exit with status one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"partial-landlock-call","status":"completed","content":[{"type":"content","content":{"type":"text","text":"[stderr]\nlandlock-run: partial enforcement (older Landlock ABI)\n[exit code: 1]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CHILD_EXIT_PRESERVED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl index acfccdd778..aab4c5ac0a 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PONG"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl index acfccdd778..aab4c5ac0a 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PONG"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl index 83e4ef4368..d36f91f048 100644 --- a/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/product-subagent-result-diagnostic/stdout.expected.jsonl @@ -1,4 +1,16 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_claude_foreground","title":"subagent_codex","kind":"other","status":"in_progress","rawInput":{"description":"Observe Claude foreground diagnostic","prompt":"Return the Claude diagnostic failure.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_claude_foreground","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)\nPartial output before the run ended:\npartial assistant text"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_claude_background","title":"subagent_codex","kind":"other","status":"in_progress","rawInput":{"description":"Observe Claude background diagnostic","prompt":"Return the Claude diagnostic failure.","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_claude_background","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started background subagent job subagent-1"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_claude_output","title":"job_output","kind":"other","status":"in_progress","rawInput":{"job_id":"subagent-1","wait":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_claude_output","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Claude Code; stage: query-run; category: error_max_budget_usd)]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_codex_foreground","title":"subagent_codex","kind":"other","status":"in_progress","rawInput":{"description":"Observe Codex foreground diagnostic","prompt":"Return the Codex diagnostic failure.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_codex_foreground","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: subagent run failed\nDiagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)\nPartial output before the run ended:\npartial assistant text"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_codex_background","title":"subagent_codex","kind":"other","status":"in_progress","rawInput":{"description":"Observe Codex background diagnostic","prompt":"Return the Codex diagnostic failure.","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_codex_background","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started background subagent job subagent-2"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_codex_output","title":"job_output","kind":"other","status":"in_progress","rawInput":{"job_id":"subagent-2","wait":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_codex_output","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no new output)\n[status: failed, error; diagnostic: Product subagent failure (product: Codex; stage: turn; category: httpConnectionFailed; HTTP status: 503)]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_OBSERVED_DIAGNOSTICS"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl index 82ae8907ca..c23d73be94 100644 --- a/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/pty-tools/stdout.expected.jsonl @@ -1,4 +1,16 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-spawn","title":"terminal_open","kind":"other","status":"in_progress","rawInput":{"type":"shell","name":"main"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-spawn","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-send","title":"terminal_send","kind":"other","status":"in_progress","rawInput":{"sessionId":"pty-1","text":"printf 'PTY_OK\\n'"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-send","status":"completed","content":[{"type":"content","content":{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-read","title":"terminal_read","kind":"other","status":"in_progress","rawInput":{"sessionId":"pty-1","offset":0,"count":20}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-read","status":"completed","content":[{"type":"content","content":{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-signal","title":"terminal_signal","kind":"other","status":"in_progress","rawInput":{"sessionId":"pty-missing","signal":"SIGINT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-signal","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: unknown PTY session pty-missing"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-kill","title":"terminal_close","kind":"other","status":"in_progress","rawInput":{"sessionId":"pty-1"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-kill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"closed terminal session pty-1"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"pty-list","title":"terminal_list","kind":"other","status":"in_progress","rawInput":{}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"pty-list","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no terminal sessions)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl index 80d27b8114..bc11f68f82 100644 --- a/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-dimension/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WIDE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","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"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"read-image-dimension","title":"read_image","kind":"other","status":"in_progress","rawInput":{"file_path":"wide.png"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-image-dimension","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/wide.png\nimage\n\nimage/png image, 2001x1 px, 133 bytes\n"}},{"type":"content","content":{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAAB9EAAAABCAIAAADmXckUAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAN0lEQVRYhe3YMQ0AAAzDsPAn3YHYaykIfKaVCBAgQIAAAQIECBAgQIAAAQIECBAgQIAAgb2H+QFsD8mZ8NyUgwAAAABJRU5ErkJggg==","mimeType":"image/png"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"WIDE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl index f93f99ce97..c931a1a280 100644 --- a/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"UNAVAILABLE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"read-image-refused","title":"read_image","kind":"other","status":"in_progress","rawInput":{"file_path":"red.png"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-image-refused","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"UNAVAILABLE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl index 4f0fb2e442..705695c6c5 100644 --- a/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"mcpCapabilities":{"http":true},"promptCapabilities":{"image":true,"audio":false,"embeddedContext":false},"sessionCapabilities":{"close":{},"list":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","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"},{"value":"[\"deepseek-official\",\"deepseek-v4-flash-vision-exp\"]","name":"deepseek-v4-flash-vision-exp"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"read-image-call","title":"read_image","kind":"other","status":"in_progress","rawInput":{"file_path":"red.png"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"read-image-call","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"}},{"type":"content","content":{"type":"image","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC","mimeType":"image/png"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl index 018593abb5..d65c5c5eca 100644 --- a/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/reject-extra-dirs/stdout.expected.jsonl @@ -1,2 +1,2 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} {"jsonrpc":"2.0","id":2,"error":{"code":-32602,"message":"Invalid params: additionalDirectories is not supported"}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-reminder/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-reminder/stdout.expected.jsonl index 2f80460389..05a26c431f 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-reminder/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-reminder/stdout.expected.jsonl @@ -1,4 +1,14 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE."}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"todo_write","kind":"other","status":"in_progress","rawInput":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_2","title":"todo_write","kind":"other","status":"in_progress","rawInput":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_3","title":"todo_write","kind":"other","status":"in_progress","rawInput":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_3","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_4","title":"todo_write","kind":"other","status":"in_progress","rawInput":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_4","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_5","title":"todo_write","kind":"other","status":"in_progress","rawInput":{"todos":[{"content":"watch the kettle boil","status":"in_progress"}]}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_5","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE."}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl index 82ae8907ca..18faa4a862 100644 --- a/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/session-query-spill/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_query_spill","title":"session_event_read","kind":"other","status":"in_progress","rawInput":{"seq":5}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Session {{sessionId}} — Read request event 5 with\nTarget event seq 5:\n```json\n{\n \"type\": \"user/message\",\n \"seq\": 5,\n \"time\": {{eventTime}},\n \"data\": {\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Current runtime context. This snapshot supersedes mpts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\"\n }\n ]\n },\n \"role\": \"user\",\n \"id\": \"{{sessionId}}\"\n },\n \"surfaceOp\": \"append\"\n}\n```\n\n(Omitted {{eventOmittedBytes}} bytes. Full formatted result stored at: {{spillLocator:session_event_read.txt}}. Use read with offset/limit, or grep this path to search within it.)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_verify_session_query_spill","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"file=$(find /tmp/dsh-acp-snap-035d1d054 -name '*-session_event_read.txt' -type f); grep -q request/header \"$file\" && grep -q session_event_search \"$file\" && echo SPILL_CANONICAL_OK","description":"Verify complete session query spill"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_verify_session_query_spill","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no output)\n[exit code: 1]"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl index 82ae8907ca..39a426d8fe 100644 --- a/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/session-sandbox-root/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_session_root","title":"write","kind":"other","status":"in_progress","rawInput":{"file_path":"session-root.txt","content":"session root"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_session_root","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/session-root.txt\nfile\n\nCreated file\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl index 651af9e5ce..d66b22bab1 100644 --- a/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/session-title-after-turn/stdout.expected.jsonl @@ -1,4 +1,4 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"TITLE_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","options":[{"group":"deepseek-official","name":"DeepSeek","options":[{"value":"[\"deepseek-official\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"}]},{"group":"title-replay","name":"Title replay","options":[{"value":"[\"title-replay\",\"title-model\"]","name":"title-model"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"TITLE_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl index 82ae8907ca..3ae1408cc0 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Load the requested skill."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"skill","kind":"other","status":"in_progress","rawInput":{"name":"editing-cordis-compositions"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/editing-cordis-compositions\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Editing Cordis compositions\n\nEvery capability in this harness is a plugin row in a `cordis.yml`. There is no separate configuration language: changing what an agent can do means changing which rows are composed for it.\n\n## Off-limits\n\n**Never edit, delete, or overwrite a preset that ships with the deployment** — the `agent-presets` directory beside the deployment's own config, which supplies `standard`, `code`, `minimal`, and `cordis`. Never escalate the sandbox to reach it, even when a change there looks quicker. An upgrade overwrites that install, and corrupting `cordis` disables preset authoring itself. Reading a shipped composition is the intended way to start; writing to one is not, and neither is editing the host composition to work around a preset limitation.\n\nTo change what a shipped preset does, copy it and edit the copy. Locally authored presets under the user root are yours to create, edit, and delete.\n\n## Decide the plane first\n\nTwo planes, and the choice is not about how \"agent-related\" something feels — it is about whether the thing must be shared.\n\n**Host composition.** The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), anything crossing sessions (persistence, session query, storage, settings, credentials, telemetry), the sandbox and approval stack, the model route, and the subagent registry with its spawn/fork backends. One instance for the process.\n\n**Agent preset.** What one session contributes to those registries: its tool plugins, its persona and prompt sections, its compaction policy. One instance per session, mounted under that session's scope and unwound with it.\n\n**A service with a consumer outside the agent plane cannot move into a preset.** `subagents` is the worked example: the registry answers cross-session queries for the host api-proxy, so a per-session copy both starves that host row — it waits forever for a service nothing provides — and collides on the second session, since a provider name registers once. The preset contributes the delegation *tools*; the registry and its backends stay host-side.\n\nA preset is a directory holding one `agent.cordis.yml`, optionally beside a `preset.yml` carrying display metadata — `name` and `description` (and, for shipped presets, a roster `order`). Write the metadata too: a preset without it shows up in every picker as its bare directory name.\n\nLocally authored presets live one directory per preset under `${DSH_HOME:-$HOME/.dsh}/.agent-presets/`, and the shipped set sits beside the deployment's own config. Use those when the user asks where to look. A deployment can configure other roots, so the path you read or edit comes from `list()` or `resolve()` — which is also where `copy()` reports what it just created.\n\n## The roster service\n\n`ctx.agentPresets` owns discovery, authoring, and mounting. You reach it by mounting a temporary plugin that injects it and registers a tool for yourself — `cordis_mount` returns only the mount acknowledgement, so a registered tool is how a service answer gets back to you, and it becomes callable on your next step.\n\nRead `cordis_inspect what:\"api\" name:\"agentPresets\"` for the current signatures before writing the code. What this skill relies on:\n\n- `list()` — every preset with its `id`, `trust` (`system` for the shipped set, `user` for authored ones), and the absolute `path` of its composition file. This is how you locate any composition without knowing the install layout; the directory is that path's parent.\n- `read(id)` — one preset's composition text, without a file tool or a path.\n- `copy(from, id, name?)` — the only authoring write (see below).\n- `standingKeyFor(id)` — mount-validate one preset (see below).\n\n```js\nreturn {\n name: 'preset-tools',\n inject: ['agentPresets', 'tools'],\n apply(ctx) {\n harness.registerTool(ctx, harness.defineTool({\n name: 'preset_check',\n description: 'Mount-validate one preset by id.',\n parameters: { id: { type: 'string', required: true } },\n output: { schema: { type: 'string' }, render(_a, v) { return [{ type: 'text', text: v }] } },\n async execute(args) {\n try {\n await ctx.agentPresets.standingKeyFor(args.id)\n return 'mounted OK'\n } catch (error) {\n return error.message\n }\n },\n }))\n },\n}\n```\n\nUnmount the plugin with `cordis_unmount` when you are done; it is a probe, not a capability to leave behind.\n\n## Authoring a preset\n\n1. **Start from a copy.** `copy(from, id, name)` copies a whole preset directory into the user root — composition, metadata, skill directories, assets. It validates the id against `[a-z0-9][a-z0-9-]*` (it becomes the directory name, so no leading hyphen), refuses an id any root already supplies, rolls a failed copy back, and rewrites the copy's `preset.yml` to keep the source's description while dropping its name and roster `order`. Prefer it over a shell copy: it needs no sandbox escalation, it lands the copy in whichever root this deployment made writable, and the copy is exactly as loadable as its source. `resolve(id)` then names the file it created — that path, not a guessed one, is what the following edits target. `standard` is the full coding agent and the usual source.\n2. **Expect the file sandbox on every edit after the copy.** The user preset root lies outside the session workspace, so under the default `workspace-write` policy the first write there is denied. Only writes are: reading any composition by absolute path needs no escalation. Retry that exact command once with `sandbox_permissions` escalation and a short justification — the user sees and approves it. Batch your writes (one heredoc per file) rather than escalating many small commands. `copy()` itself runs host-side and needs none of this; the edits do.\n3. **Write the copy's `description`** in `preset.yml`, and its `name` if you passed none to `copy()`.\n4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and the realm rule.\n5. **Mount-validate the result**, then hand off to the user for a real session — both under *Verifying a change*.\n\nA composition written from scratch usually forgets a group realm or a consumer row; a copy starts loadable.\n\n## The rule that catches people\n\n**A row that publishes a service may not sit loose in a preset.** Registering a service without an isolate realm puts it in the process-global realm, so the second session mounting that preset collides with the first. The mount rejects it rather than letting the collision surface later.\n\nWhether a row publishes a service is not visible from its name, and package READMEs are absent from an installed deployment. Read it off the live runtime instead: `cordis_inspect what:\"services\"` lists every service with the fiber that owns it, so a service attributed to a fiber other than the row you are adding is one that row consumes rather than provides. For a row not in your current composition, mount-validate and read the rejection — it names the offending service.\n\nWhen a preset genuinely owns a service, wrap the provider **and every consumer that reaches it** in one group carrying an `isolate` realm. The shipped `standard` composition does this for `workflows`, which nothing outside an agent reads — its `delegation` group, with the delegation tools omitted here:\n\n```yaml\n- id: delegation\n name: cordis:group\n group: true\n isolate:\n workflows: true\n config:\n - id: workflow-worker-thread\n name: '@deepseek-ai/dsh-workflow-worker-thread'\n config:\n provider: spawn\n - id: tool-workflow\n name: '@deepseek-ai/dsh-tool-workflow'\n```\n\n`true` means a realm private to each mounting session. A string label instead joins subtrees into one shared realm; `provide()` still throws on the second registration under that symbol, so a label does not pool instances and is not what a preset needs.\n\nA consumer left outside the group resolves the host's registry, which the preset did not populate, and then contributes nothing. Mount-validation catches that as a row that never activated.\n\nRealms are for services a preset owns, not for every group. A host capability the preset only consumes must stay outside a realm, or the row cannot resolve it: `tool-bash`, `tool-jobs`, and `tool-goal` publish nothing and sit loose in `standard`, which explains in comments which host instance each one resolves and why a realm would break it. Wrapping a consumer row in a realm of its own is the same error as leaving one outside its provider's realm.\n\n## Verifying a change\n\n**`standingKeyFor(id)` is the check.** It composes the preset's plugin subtree for real — the same mount a session start performs, minus the agent — and rejects the four ways a composition fails:\n\n- a row whose package does not resolve (`Cannot find package …`);\n- a row whose config is invalid (`invalid config: $. missing required value`);\n- a row that never activated (`N row(s) did not activate: : waiting for `);\n- a service published into the root realm, which arrives as one of two messages. A name the host does not supply lands in the root realm and the mount audit rejects it: `row(s) published process-global service(s) []; a preset service must sit behind an isolate realm or move to the host composition` — this is the shape a preset's own forgotten realm takes. A name the host already supplies collides before the audit: `service \"\" has been registered at `. Both name the offending service.\n\nIt returns normally when the composition mounts. Run it as the final check on a finished edit rather than after every line: a successful mount installs a standing generation that lives until the process exits, while a failed one disposes its subtree and leaves nothing behind.\n\n**Do not treat the roster's `broken` field as validation.** `list()` reports `broken` from a shape check — the file parses in the loader's YAML dialect and holds named rows — which every failure above passes. It catches a damaged file, not an unusable composition.\n\n`cordis_inspect` reports THIS session's composition, so it confirms what a row does in the runtime you are already in, never what your new preset will do.\n\nAfter a clean mount-validation, ask the user to start a session on the new preset and confirm the tool list; the preset decides tool schemas and prompt sections, and only a real session shows the agent that composition produces.\n\n`cordis_mount` evaluates JavaScript against the live runtime and disappears on restart. It is for probing, not for shipping a capability: a capability belongs in a composition file.\n\n## Native product subagents\n\nCodex and Claude Code providers are independent optional Profile Bundles. Install only the products a Profile needs, then restart the Profile so its Host registers those providers:\n\n```sh\ndsh plugin --profile add @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile add @deepseek-ai/dsh-subagent-claude-code\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-codex\ndsh plugin --profile remove @deepseek-ai/dsh-subagent-claude-code\n```\n\nEach Bundle owns its Host availability; the preset separately grants one Agent its ordinary delegation tool. Never move a product provider into the preset and never add a product-specific settings field. Removing one package withdraws only that provider on the next Profile start.\n\nCopy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested:\n\n```yaml\n- id: tool-subagent-codex\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: codex\n toolName: subagent_codex\n backgroundMode: one-shot\n maxDepth: provider-managed\n\n- id: tool-subagent-claude-code\n name: '@deepseek-ai/dsh-tool-subagent'\n disabled: true\n config:\n provider: claude-code\n toolName: subagent_claude_code\n backgroundMode: one-shot\n maxDepth: provider-managed\n```\n\nFor additional named Codex or Claude Code instances, mount a separate host-plane provider row for each instance with a unique `providerName`, then add a separate preset tool row whose `provider` exactly matches that name and whose `toolName` is also unique. Keep the shipped rows for the default `codex` and `claude-code` names; do not reuse one tool row for several providers or derive either name from permission or environment settings.\n\nThe two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. Production `dsh` does not install either optional provider: before enabling a row, install the matching `@deepseek-ai/dsh-subagent-codex` or `@deepseek-ai/dsh-subagent-claude-code` Bundle in the Profile and restart it. Each Bundle registers its dormant default provider and exclusively uses its pinned package-local platform CLI; additional named instances use extra host-plane rows from the same installed package. A preset cannot provide that host dependency. `backgroundMode: one-shot` keeps omitted or `false` calls in the foreground and lets explicit `run_in_background: true` return a generic Job id. Full presets already carry `tool-jobs`, while the base host carries the job registry; retain both so `job_output`, `job_list`, `job_kill`, cancellation, and completion notices stay available. Installing a Bundle or composing a preset row does not start a product, authenticate an account, select a model, probe credentials, or manage native product settings.\n\n## What not to move into a preset\n\n`agent-loop` registers the one agent factory and throws on a second. The registries own the per-session layering and cannot themselves be per-session. Session persistence must stay host-side or the session list fragments. The sandbox, approval, and permission rows are a deliberate boundary: a preset is exactly as privileged as the plugins it names, so letting one relax its own confinement would defeat the confinement.\n\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The skill is loaded."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl index ef130490e8..e43739adb5 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_COMPLETED"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_question_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Check deployment question","prompt":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_question_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_COMPLETED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl index d6a2728b5a..93a8af3156 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl @@ -1,5 +1,7 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_bg_start","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else.","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_bg_start","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started subagent {{sessionId}}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl index d6a2728b5a..67473f6611 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/stdout.expected.jsonl @@ -1,5 +1,13 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_bg_start","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else.","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_bg_start","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started subagent {{sessionId}}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_followup_1","title":"send_message","kind":"other","status":"in_progress","rawInput":{"subagent_id":"{{sessionId}}","message":"Now reply with exactly SECOND_OK."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_followup_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"message queued as the next turn for subagent {{sessionId}}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_followup_2","title":"send_message","kind":"other","status":"in_progress","rawInput":{"subagent_id":"{{sessionId}}","message":"Now reply with exactly THIRD_OK."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_followup_2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"message queued as the next turn for subagent {{sessionId}}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_followup_unknown","title":"send_message","kind":"other","status":"in_progress","rawInput":{"subagent_id":"{{sessionId}}","message":"Please continue."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_followup_unknown","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: subagent \"{{sessionId}}\" is unavailable"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl index c00054c284..0b2f2118a4 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ROOT_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_root_child","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Start depth one","prompt":"Call subagent once. Ask that child to attempt one further subagent call, then report the result.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_root_child","status":"completed","content":[{"type":"content","content":{"type":"text","text":"DEPTH_ONE_DONE"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"ROOT_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork-in-process/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork-in-process/stdout.expected.jsonl index 0350e89204..16f6191a88 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork-in-process/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork-in-process/stdout.expected.jsonl @@ -1,6 +1,11 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to remember the codeword \"MARMALADE\" and reply with just \"OK\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use subagent_fork to delegate a question to a child agent. The child agent inherits this conversation and should be able to answer: the project codeword is MARMALADE. After the subagent returns, I should reply with PARENT_DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_sAtKUseRzHRBvL4CF7XF1334","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The forked child agent correctly returned \"MARMALADE\". Now I need to reply with \"PARENT_DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_DONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl index 4813c19f90..062134d8bc 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/stdout.expected.jsonl @@ -1,7 +1,11 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"STARTED"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_bg_start","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else.","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_bg_start","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started subagent {{sessionId}}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"STARTED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_list","title":"list_agents","kind":"other","status":"in_progress","rawInput":{}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_list","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{sessionId}} [ready] — Reply with CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl index a460e019d4..bb5284f3aa 100644 --- a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_parent_1","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Truncated child","prompt":"Write the words 'partial one', call todo_write once, then keep going until you are cut off.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_parent_1","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: subagent run hit its token limit before finishing\nPartial output before the run ended:\npartial one"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl index 0350e89204..0c15c50c9c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.expected.jsonl @@ -1,6 +1,14 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"OK"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to remember a codeword and just reply with \"OK\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"OK"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Let me do these two delegations one at a time as requested.\n\nFirst, I'll use the subagent tool (fresh child) to reply with \"ALPHA\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_YvHr2bGomk5HhpgDTvE81896","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The first subagent returned \"ALPHA\". Now I need to use the subagent_fork tool (forked child that inherits this conversation) to ask about the project codeword."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_JSr5rhREq23wSmwSkCP77184","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Both subagents returned:\n1. First (fresh child): \"ALPHA\"\n2. Second (forked child): \"SAFFRON\" - correctly inherited the conversation context where I was asked to remember the codeword \"SAFFRON\".\n\nNow I reply with \"PARENT_DONE\" as instructed."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_DONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl index a460e019d4..c286e88d77 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.expected.jsonl @@ -1,4 +1,11 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use the subagent tool twice, sequentially (one at a time). First subagent should reply with \"ALPHA\", second with \"BETA\". After both return, I reply with \"PARENT_DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Return ALPHA only","prompt":"Reply with exactly the word ALPHA and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_7zDCCjdsQgrk5LR2bAEQ1010","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"First subagent returned \"ALPHA\". Now I'll call the second subagent to return \"BETA\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Return BETA only","prompt":"Reply with exactly the word BETA and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_FudNKuJ0fchSptGy3Scw1411","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Both subagents have returned: first with \"ALPHA\", second with \"BETA\". Now I should reply with \"PARENT_DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl index a460e019d4..caa22c2e2b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_parallel_alpha_1","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Say the word ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_parallel_alpha_2","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Say the word ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_parallel_alpha_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_parallel_alpha_2","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/stdout.expected.jsonl index 0b02252419..097fa6abb6 100644 --- a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/stdout.expected.jsonl @@ -1,4 +1,6 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_OBSERVED_ERROR"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_published_failure","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Fail published run","prompt":"This child prompt must never run.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_published_failure","status":"failed","content":[{"type":"content","content":{"type":"text","text":"Error: subagent run failed: Error: snapshot published run failed; dispose failed: Error: snapshot published handle disposal failed"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_OBSERVED_ERROR"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl index b6de818dea..51065cb871 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/stdout.expected.jsonl @@ -1,7 +1,9 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"STARTED"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_bg_start","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Report a finding","prompt":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop.","run_in_background":true}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_bg_start","status":"completed","content":[{"type":"content","content":{"type":"text","text":"started subagent {{sessionId}}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"STARTED"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"SUBAGENT_SETTLED_NOTED"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"CHILD_REPORT_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"CHILD_REPORT_OK"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/stdout.expected.jsonl index a460e019d4..c48c5b87ee 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn-in-process/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to:\n1. Use the subagent tool exactly once to delegate the task: \"Reply with exactly the word CHILD_OK and nothing else.\"\n2. After the subagent returns, reply with the single word PARENT_DONE and stop.\n3. Do not use the bash tool.\n\nLet me do this."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else.","run_in_background":false}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_gVbLWC12Qu8JheZpVRRz8749","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with the single word \"PARENT_DONE\" and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PARENT_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl index acfccdd778..b8fb6acfcd 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.expected.jsonl @@ -1,4 +1,5 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"PONG"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/todo-write/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/todo-write/stdout.expected.jsonl index 82ae8907ca..fecd469980 100644 --- a/examples/acp-agent/tests/snapshots/todo-write/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-write/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use todo_write to create exactly three todos, then reply with \"DONE\" and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_UHvM5RrwIkjNJ9xh3S735164","title":"todo_write","kind":"other","status":"in_progress","rawInput":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"watch the background build","status":"in_progress"},{"content":"write the fix","status":"pending"}]}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_UHvM5RrwIkjNJ9xh3S735164","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 1 pending, 2 in progress, 0 completed."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Done."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl index 82ae8907ca..15611de36d 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to run a specific bash command and then reply with DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo SNAPSHOT_OK","description":"Run echo SNAPSHOT_OK"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Rn2Mz1y8uZN62ukEXiNO2077","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SNAPSHOT_OK\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The command executed successfully and printed SNAPSHOT_OK. Now I need to reply with the single word DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl index 82ae8907ca..4e70efddf3 100644 --- a/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/web-fetch/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-pro\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use the web_fetch tool exactly once to fetch http://127.0.0.1:43117/menu.html, then reply with exactly \"DONE\". Let me do that."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","title":"web_fetch","kind":"other","status":"in_progress","rawInput":{"url":"http://127.0.0.1:43117/menu.html"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_sxjOyfDYN07koiE7jiIa5326","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Fetched http://127.0.0.1:43117/menu.html (HTTP 200)\n\nMenu\n\n# Café menu\n\nPrices include **service & _tax_** — updated daily.\n\n- Espresso\n- Flat white\n\n| Drink | Price |\n| --- | --- |\n| Espresso | €2 |\n| Flat white | €3 |\n\nSee [today’s specials](https://fixture.invalid/specials)."}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user asked me to fetch the URL, then reply with exactly \"DONE\". I've fetched it. Now I just reply with \"DONE\"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl index bdf91164ff..d456d6d1b6 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.expected.jsonl @@ -1,4 +1,8 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","title":"workflow","kind":"other","status":"in_progress","rawInput":{"meta":{"name":"snapshot-flow","description":"one child for the snapshot"},"script":"\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","status":"completed","content":[{"type":"content","content":{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"WORKFLOW_DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl index 82ae8907ca..5cf3bbd361 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.expected.jsonl @@ -1,4 +1,14 @@ -{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":1,"result":{"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":{},"resume":{}}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","category":"model","type":"select","currentValue":"[\"deepseek-official\",\"deepseek-v4-flash\"]","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"}]}]}]}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The user wants me to:\n1. Read the file greeting.txt\n2. Append the word WORLD as a second line\n3. Read the file back with cat to confirm\n4. Reply with DONE\n\nLet me start by reading the file to see its contents."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","title":"read","kind":"other","status":"in_progress","rawInput":{"file_path":"greeting.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_OjRFB4zvxu6UALDjytZD0978","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/greeting.txt\nfile\n\n1: hello\n\n(End of file - total 1 lines)\n"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file contains \"hello\" on one line. Now I need to append a second line with \"WORLD\" to it. Then cat it to confirm."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf '\\nWORLD' >> greeting.txt","description":"Append newline and WORLD to greeting.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_IUUvbNiPcnwhVL8ErEFS4806","status":"completed","content":[{"type":"content","content":{"type":"text","text":"(no output)"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"Good, now let me read the file back with cat."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"cat greeting.txt","description":"Read greeting.txt to confirm"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_Wo4H7tFNheZJWKVDrAHK5851","status":"completed","content":[{"type":"content","content":{"type":"text","text":"hello\n\nWORLD"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"The file now has two lines:\n1. hello\n2. WORLD\n\nI can reply with DONE."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","messageId":"{{messageId}}","content":{"type":"text","text":"DONE"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/knip.json b/knip.json index 28267ff092..7940454609 100644 --- a/knip.json +++ b/knip.json @@ -493,7 +493,8 @@ "packages/examples/acp-demo": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/control-surface-llm.ts" ], "project": [ "src/**/*.ts", diff --git a/package.json b/package.json index 391c93d938..72908e296e 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { - "@agentclientprotocol/sdk": "0.25.1", + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/dsh-tool-session-query": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/dom": "^10.4.1", diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 05760e4853..6c699f8e26 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/acp/acp/README.md -README.md: aaabb0c824e12c250851985e92c0473f147e8efa -README.zh.md: dfc7b321597cdc899cc2685d657c94bf39b7ae42 +README.md: 2694c7304b97a0a918f8e3754bdee6d382052dd2 +README.zh.md: 11a00ad44d311ae77b2ba16dc9426ccc5f87f2b3 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index aaabb0c824..2694c7304b 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -2,80 +2,106 @@ English | [中文](README.zh.md) -Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text/image prompts, collect committed assistant text/images, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). +Automation-only [Agent Client Protocol](https://agentclientprotocol.com) v1 server over JSON-RPC stdio. Trusted programmatic clients can discover standard configuration, create or resume persistent harness Agents, attach MCP servers, prompt and cancel work, receive semantic execution updates, and close one session without affecting others. -This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules. +This package is not a UI integration. It emits standard ACP semantic data, never DSH presentation cards, terminal views, diffs, locations, plans, titles, todos, custom methods, custom capability flags, or DSH-specific `_meta`. Client `_meta` is accepted as protocol metadata and has no private DSH meaning. ## Plugin -`apply(ctx, config)` opens an `AgentSideConnection` on stdin/stdout and drives `ctx.agents`. Stdout is reserved for protocol frames. +`apply(ctx, config)` opens an ACP SDK agent app on stdin/stdout and drives `ctx.agents`. Stdout is reserved for protocol frames. Complete lifecycle support requires `ctx.sessionPersistence`. | Config | Default | Meaning | |---|---|---| -| `provider` | — | Initial provider route for every created agent. | -| `model` | — | Initial model for every created agent. | +| `provider` | — | Initial provider route for each created or resumed Agent. | +| `model` | — | Initial exact model for each created or resumed Agent. | +| `sessionListPageSize` | `100` | Positive maximum number of summaries in one `session/list` page. | -Both fields are optional so another agent/request listener may supply the target. The runnable ACP composition requires both. +`provider` and `model` may be omitted when another Agent request listener supplies the initial route. The runnable ACP composition requires both. -## Protocol contract +## Standard ACP v1 surface -| Method | Behavior | +| Method or notification | Behavior | |---|---| -| `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. No session, editor, terminal, filesystem, or MCP capability is advertised. | +| `initialize` | Negotiates stable ACP v1. Advertises standard `session/list`, `session/resume`, `session/close`, and Streamable HTTP MCP support. Image prompts are advertised only when a durable attachment store and the configured exact route support them. | | `authenticate` | No-op because the server advertises no authentication methods. | -| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. | -| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission plus, once queued, whole-Agent idle and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | -| `session/cancel` | Marks and aborts any in-progress admission without cancelling or waiting for unrelated Agent work; once this prompt has entered the Agent inbox, it cancels the addressed Agent and waits for the owned interval to quiesce. No late user message is published and the prompt settles as `cancelled`. With no in-flight prompt it cancels autonomous work; unknown ids are no-ops. | -| `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. Raw deltas and non-message events are omitted. | -| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. | +| `session/new` | Creates one Agent with an absolute primary `cwd`, validates and mounts standard stdio or HTTP MCP servers before publishing the Agent, explicitly materializes its durable header, and returns the complete configuration-option state. | +| `session/list` | Returns deterministic newest-first pages of persisted, resumable top-level sessions. Summaries contain only `sessionId` and absolute `cwd`; cursors are opaque keyset tokens. An optional absolute `cwd` filter uses physical-directory identity when paths exist. Active sessions and subagent/fork descendants are omitted. | +| `session/resume` | Rejects an active id, verifies the persisted canonical workspace before Agent composition, restores the log without replaying it to the client, mounts the request's MCP servers, and returns the complete configuration-option state. | +| `session/close` | Cancels active work, drains ordered updates and continuable descendants, flushes persistence, and disposes only that Agent scope. Persisted state remains available to `session/list` and `session/resume`. | +| `session/set_config_option` | Sets an advertised `model` or `reasoning_effort` value and returns the complete resulting state. Invalid ids and values reject as invalid params. | +| `session/prompt` | Admits ordered text, resource links, and supported images; permits one in-flight prompt per session; and settles only after Agent idle plus ordered update delivery. | +| `session/cancel` | Cancels the addressed prompt admission or turn through its prompt-owned cancellation path. With no ACP prompt in flight it cancels autonomous work; unknown ids are no-ops. | +| `$/cancel_request` | Cancellation of a `session/prompt` JSON-RPC request uses the same prompt-owned path as `session/cancel`. | +| `session/update` | Emits committed message, thought, generic tool lifecycle, configuration, and context-usage updates described below. | +| `session/request_permission` | Requests one standard one-shot allow or reject decision after the referenced `tool_call` notification has been delivered. | -One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer. +Unsupported surfaces are omitted from capabilities or reject when addressed: `session/load`, `session/delete`, `session/fork`, additional directories, SSE and ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation. -Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text or images; reasoning and tool activity remain in the session log for observability through other interfaces. Per-session delivery is serialized because attachment reads are asynchronous, and a missing or corrupt committed image fails the prompt response instead of emitting a placeholder. +## Session configuration -## Lifecycle +Every new or resumed session returns standard select options: -Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. +- `model` groups choices by provider from the advisory LLM catalog. Values are opaque strings carrying the exact provider/model pair; clients must return them unchanged. +- `reasoning_effort` is derived from the selected exact model and is omitted when that model does not declare reasoning choices. -ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. The operation interval starts when the prompt enters the Agent inbox and ends after admission, whole-Agent idle, and ordered output delivery all quiesce; failures from unrelated Agent work before that inbox receipt are not attributed to the prompt. Committed assistant messages stream across the owned interval, and steering or injected work may contribute before idle. Settlement precedence is explicit cancellation, output-delivery failure, interval-wide Agent failure, then the correlated turn ending. Token-limit endings settle as `end_turn`; a correlated model error rejects only at the same quiescence boundary. +The ACP plugin's `provider` and `model` config establish the initial selection. Adapter topology changes emit `config_option_update` with the complete current state. Mutations are serialized per session. + +An accepted prompt snapshots the selected route before asynchronous image admission. Its per-session module associates that snapshot with the identified inbox message until claim, then pins the same provider, model, and reasoning effort across image validation, prompt variables, and every model step in that turn. A concurrent option change applies to the next ACP turn. + +## MCP trust and isolation + +ACP clients are trusted automation controllers. A stdio declaration authorizes DSH to execute its absolute command in the session `cwd` with the supplied arguments and environment entries. An HTTP declaration authorizes requests to its absolute HTTP(S) URL with the supplied headers. DSH does not reinterpret client metadata or add private cwd, timeout, or transport fields. + +Server names are validated and converted to stable DSH MCP namespaces; duplicate normalized names reject before Agent publication. Environment names/values and HTTP headers are validated, including case-insensitive duplicate headers. Standard stdio and Streamable HTTP clients use `dsh-mcp-client`'s existing tool-call timeout and reconnect defaults. Initial connection and tool discovery must succeed, so any failure rolls back the unpublished Agent. + +Each Agent scope owns its MCP registrations and connections. The same server namespace may therefore exist in independent ACP sessions, while a duplicate inside one session still fails. Session close, connection loss, and plugin disposal release the scoped tools and transports. + +## Semantic updates + +Per-session delivery is serialized and drained before prompt completion: + +| Durable DSH fact | Standard ACP update | +|---|---| +| Committed assistant text or image | `agent_message_chunk` with the durable message id | +| Committed reasoning | `agent_thought_chunk` with the durable message id | +| Durable tool call | `tool_call` with the DSH call id, canonical DSH tool name as `title`, generic `other` kind, and parsed input when valid JSON | +| Durable tool result | `tool_call_update` with the same call id, completed/failed status, and standard content blocks | +| Known context capacity plus measured context pressure | `usage_update` | +| LLM adapter topology change | `config_option_update` with all options | + +Raw model deltas, retry attempts, presentation data, and unsupported core content never enter the ACP wire. Committed images are re-read and integrity-verified before inline base64 delivery. A missing or corrupt committed image fails the correlated prompt instead of producing a placeholder. + +## Lifecycle and outcomes + +One connection may own several independent sessions. Exact Agent identity guards event and permission routing. Each per-session module owns its Agent handle, MCP mounts, future and turn-pinned model selections, prompt slot, update chain, and memoized close operation. + +Explicit close, connection loss, and plugin disposal use the same quiescent teardown. Teardown stops new work, cancels prompt admission and Agent activity, drains committed updates, disposes continuable descendants child-first, flushes the session, and releases every Agent scope. Failures are reported only after all owned teardown work settles; other frontends sharing the Context are untouched. + +Prompt settlement precedence is explicit cancellation, committed-output failure, interval-wide Agent failure, then the correlated turn ending. Standard outcomes include `end_turn`, `max_tokens`, and `cancelled`; correlated model failures become standard JSON-RPC errors. No additional DSH result object is returned. ## Running -`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above. +`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. The generic keyless conformance test drives this bin using only the ACP SDK, including model selection, MCP attachment, close, process restart, list/resume, and cancellation. ## Model Experience -### Prompt text and images +### Prompt content #### What the model sees -`session/prompt` preserves text/image order in one user message; adjacent text is concatenated, and a resource link appears as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Inline image base64 is discarded after batch admission, so the durable message contains only verified attachment references. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request. +`session/prompt` produces an ordinary logged user message. Text/image order is preserved; adjacent text is concatenated; a resource link becomes a bracketed `[resource_link name=… uri=…]` reference. Inline image base64 is discarded after durable admission. Protocol metadata, client capabilities, permission choices, session ids, and ACP configuration objects do not enter model requests. #### Token effect -Prompt tokens and image charges are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts. +Prompt content, tool calls/results, and durable image references remain in that session until compaction. Concurrent sessions retain independent contexts. #### KV Cache effect -Append-only; the new user message follows the reusable request prefix and does not invalidate prior cache entries. - -### Permission decisions - -#### What the model sees - -Nothing directly. The owning tool records its allowed, rejected, cancelled, or unavailable outcome through the normal tool-result path. - -#### Token effect - -Only the owning tool result contributes tokens. - -#### KV Cache effect - -Append-only through the owning tool result. +Append-only while the selected route and assembled prefix stay unchanged. A model change starts the next ACP turn on the new route. ## Known Limitations and Deferred Work -- **Fresh sessions only** — load, list, resume, delete, and fork are unsupported. -- **Raster images and one workspace only** — image prompts require a durable store plus an exact route that declares image input; only PNG, JPEG, WebP, and GIF are accepted. Audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content. -- **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire. -- **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented. +- Only one primary workspace is supported. Additional directories remain unsupported. +- Only PNG, JPEG, WebP, and GIF prompt images are supported, subject to the attachment store and exact model route. +- MCP resources and prompts have no DSH consumer; ACP mounts expose MCP tools only. +- Session deletion, fork, transcript replay through `session/load`, modes, commands, plans, terminals, client filesystem operations, and elicitation remain outside this automation surface. diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index dfc7b32159..11a00ad44d 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -2,82 +2,108 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本/图片提示词、收集已提交的 assistant 文本/图片、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.zh.md)。 +通过 JSON-RPC stdio 提供的仅面向自动化的 [Agent Client Protocol](https://agentclientprotocol.com) v1 服务器。受信任的程序化客户端可以发现标准配置、创建或恢复持久化的 harness Agent、挂载 MCP 服务器、提示和取消工作、接收语义执行更新,并在不影响其他会话的情况下关闭单个会话。 -此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。 +此包不是 UI 集成。它只发出标准 ACP 语义数据,绝不发出 DSH 展示卡片、终端视图、diff、位置、计划、标题、todo、自定义方法、自定义能力标记或 DSH 专用 `_meta`。客户端 `_meta` 仅作为协议元数据接收,不具有 DSH 私有含义。 ## 插件 -`apply(ctx, config)` 在 stdin/stdout 上打开 `AgentSideConnection` 并驱动 `ctx.agents`。Stdout 专用于协议帧。 +`apply(ctx, config)` 在 stdin/stdout 上打开 ACP SDK agent app,并驱动 `ctx.agents`。Stdout 专用于协议帧。完整生命周期支持要求挂载 `ctx.sessionPersistence`。 | 配置 | 默认值 | 含义 | |---|---|---| -| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 | -| `model` | 无 | 每个已创建 agent 的初始模型。 | +| `provider` | 无 | 每个新建或恢复 Agent 的初始提供方路由。 | +| `model` | 无 | 每个新建或恢复 Agent 的初始确切模型。 | +| `sessionListPageSize` | `100` | 单个 `session/list` 页面返回的摘要数量上限,必须为正数。 | -两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。 +当另一个 Agent 请求监听器提供初始路由时,可以省略 `provider` 和 `model`。可运行 ACP 组合同时要求两者。 - + -## 协议约定 +## 标准 ACP v1 接口 -| 方法 | 行为 | +| 方法或通知 | 行为 | |---|---| -| `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频与嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | +| `initialize` | 协商稳定 ACP v1。公布标准 `session/list`、`session/resume`、`session/close` 和 Streamable HTTP MCP 支持。只有持久附件存储和配置的确切路由都支持图片时,才公布图片提示词能力。 | | `authenticate` | 空操作,因为服务器不公布身份验证方法。 | -| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入,以及消息入队后的整个 Agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 | -| `session/cancel` | 标记并中止正在进行的准入,但不会取消或等待同一 Agent 上无关的既有工作;该提示词进入 Agent inbox 后,才会取消指定的 Agent 并等待自有区间停稳。不发布迟到的用户消息,提示词以 `cancelled` 结算。没有进行中的提示词时会取消自主工作;未知 id 为空操作。 | -| `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 | -| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | +| `session/new` | 使用绝对主 `cwd` 创建一个 Agent;在公布 Agent 前校验并挂载标准 stdio 或 HTTP MCP 服务器;显式实体化其持久 header;返回完整配置选项状态。 | +| `session/list` | 按创建时间从新到旧,确定性分页返回已持久化且可恢复的顶层会话。摘要只包含 `sessionId` 和绝对 `cwd`;cursor 是不透明的 keyset token。可选绝对 `cwd` 过滤器会在路径存在时比较物理目录身份。活动会话以及 subagent/fork 后代不会出现。 | +| `session/resume` | 拒绝活动 id;在组合 Agent 前校验持久化会话的规范工作区;恢复日志但不向客户端重放;挂载该请求的 MCP 服务器;返回完整配置选项状态。 | +| `session/close` | 取消活动工作、drain 有序更新和可继续后代、flush 持久化,并只释放该 Agent scope。持久化状态仍可供 `session/list` 和 `session/resume` 使用。 | +| `session/set_config_option` | 设置已公布的 `model` 或 `reasoning_effort` 值,并返回完整结果状态。无效 id 或值以 invalid params 拒绝。 | +| `session/prompt` | 准入有序文本、资源链接和受支持图片;每个会话只允许一个进行中的提示词;只在 Agent 空闲且有序更新交付完成后结算。 | +| `session/cancel` | 通过提示词自有取消路径取消指定的准入或轮次。没有 ACP 提示词进行时取消自主工作;未知 id 为空操作。 | +| `$/cancel_request` | 取消 `session/prompt` JSON-RPC 请求时,使用与 `session/cancel` 相同的提示词自有路径。 | +| `session/update` | 发出下文所述的已提交消息、思考、通用工具生命周期、配置和上下文用量更新。 | +| `session/request_permission` | 在引用的 `tool_call` 通知交付后,请求一次标准的一次性允许或拒绝决定。 | -一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。 +未支持的接口不会出现在能力中,或在被调用时拒绝:`session/load`、`session/delete`、`session/fork`、附加目录、SSE 和 ACP 传输 MCP、模式、命令、计划、终端、客户端文件系统操作以及 elicitation。 -已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本或图片;推理与工具活动仍保留在会话日志中,以便其他界面观测。由于附件读取是异步的,每个会话会串行交付内容;已提交图片缺失或损坏时,提示词响应会失败,而不会发出占位符。 +## 会话配置 -## 生命周期 +每个新建或恢复的会话都会返回标准 select 选项: -客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 +- `model` 根据建议性 LLM catalog 按提供方分组。值是不透明字符串,携带确切的提供方/模型对;客户端必须原样返回。 +- `reasoning_effort` 来自所选确切模型;该模型未声明推理选项时省略。 -ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。操作区间从提示词进入 Agent inbox 开始,在准入、整个 Agent 空闲和有序输出交付全部停稳后结束;inbox 接收前无关 Agent 工作的失败不会归因给该提示词。已提交的 assistant 消息会在自有区间内流式输出,Agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。结算优先级依次为显式取消、输出交付失败、区间内 Agent 失败、关联轮次结束。因 token 上限而结束时以 `end_turn` 结算;关联模型错误也只会在同一个完全停稳边界拒绝提示词。 +ACP 插件的 `provider` 和 `model` 配置建立初始选择。Adapter 拓扑变化会发送包含完整当前状态的 `config_option_update`。每个会话会串行处理配置变更。 + +已接受的提示词会在异步图片准入前快照所选路由。Per-session 模块会把该快照与已识别 inbox 消息关联到 claim 时刻,再把同一提供方、模型和 reasoning effort 固定到图片校验、提示词变量以及该轮次中的每个模型步骤。并发配置变更从下一个 ACP 轮次开始生效。 + +## MCP 信任与隔离 + +ACP 客户端是受信任的自动化控制器。stdio 声明授权 DSH 在会话 `cwd` 中执行其绝对命令,并使用所给参数和环境项。HTTP 声明授权向其绝对 HTTP(S) URL 发送带所给 header 的请求。DSH 不重新解释客户端元数据,也不增加私有 cwd、超时或传输字段。 + +服务器名称会经过校验并转换为稳定的 DSH MCP namespace;重复的规范化名称会在 Agent 公布前拒绝。环境变量名/值和 HTTP header 会被校验,其中 header 重复检查不区分大小写。标准 stdio 与 Streamable HTTP 客户端使用 `dsh-mcp-client` 现有的工具调用超时和重连默认值。初始连接和工具发现必须成功,因此任何失败都会回滚尚未公布的 Agent。 + +每个 Agent scope 拥有自己的 MCP 注册和连接。因此,独立 ACP 会话可以使用相同服务器 namespace,而同一会话内的重复仍会失败。会话关闭、连接丢失和插件释放都会移除 scoped 工具和传输。 + +## 语义更新 + +每个会话会串行交付更新,并在提示词完成前 drain: + +| 持久 DSH 事实 | 标准 ACP 更新 | +|---|---| +| 已提交 assistant 文本或图片 | 携带持久消息 id 的 `agent_message_chunk` | +| 已提交 reasoning | 携带持久消息 id 的 `agent_thought_chunk` | +| 持久工具调用 | `tool_call`:使用 DSH call id、规范 DSH 工具名作为 `title`、通用 `other` kind,并在参数为有效 JSON 时提供解析后的输入 | +| 持久工具结果 | `tool_call_update`:使用相同 call id、completed/failed 状态和标准内容块 | +| 已知上下文容量和已测上下文压力 | `usage_update` | +| LLM adapter 拓扑变化 | 包含全部选项的 `config_option_update` | + +原始模型 delta、重试尝试、展示数据和不受支持的核心内容绝不会进入 ACP wire。已提交图片在以内联 base64 交付前会重新读取并校验完整性。已提交图片缺失或损坏会使关联提示词失败,而不会产生占位符。 + +## 生命周期与结果 + +一个连接可以拥有多个独立会话。事件和权限路由会校验确切 Agent 身份。每个 per-session 模块拥有自己的 Agent handle、MCP 挂载、未来选择和轮次固定的模型选择、提示词槽位、更新链以及记忆化关闭操作。 + +显式关闭、连接丢失和插件释放使用同一个完全停稳的 teardown。Teardown 会停止新工作、取消提示词准入和 Agent 活动、drain 已提交更新、按 child-first 顺序释放可继续后代、flush 会话,并释放每个 Agent scope。只有在所有自有 teardown 工作结算后才报告失败;共享该 Context 的其他前端不受影响。 + +提示词结算优先级依次为显式取消、已提交输出失败、区间内 Agent 失败、关联轮次结束。标准结果包括 `end_turn`、`max_tokens` 和 `cancelled`;关联模型失败成为标准 JSON-RPC error。不会返回额外 DSH 结果对象。 ## 运行 -`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.zh.md) spawn 它;其他 ACP 客户端只需上述核心方法。 +`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。通用 keyless conformance 测试只使用 ACP SDK 驱动此 bin,覆盖模型选择、MCP 挂载、关闭、进程重启、列出/恢复和取消。 ## 模型体验 -### 提示词文本与图片 +### 提示词内容 #### 模型看到的内容 -`session/prompt` 会在一条用户消息中保留文本/图片顺序;相邻文本会拼接,资源链接则表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。内联图片 base64 在批量准入后即被丢弃,因此持久消息只包含经过校验的附件引用。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。 +`session/prompt` 产生普通的已记录用户消息。文本/图片顺序会保留;相邻文本会拼接;资源链接会变成带方括号的 `[resource_link name=… uri=…]` 引用。内联图片 base64 在持久准入后即被丢弃。协议元数据、客户端能力、权限选择、会话 id 和 ACP 配置对象不会进入模型请求。 #### Token 影响 -提示词 token 与图片费用取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。 +提示词内容、工具调用/结果和持久图片引用会保留在该会话中直到 compaction。并发会话保留独立上下文。 #### KV Cache 影响 -仅追加;新用户消息位于可复用请求前缀之后,不会使先前缓存条目失效。 - -### 权限决策 - -#### 模型看到的内容 - -不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用。 - -#### Token 影响 - -只有所属工具的结果会贡献 token。 - -#### KV Cache 影响 - -仅通过所属工具的结果追加。 +当所选路由和已组装前缀不变时仅追加。模型变更会让下一个 ACP 轮次使用新路由。 ## 已知限制与暂缓事项 -- **仅新会话**:不支持加载、列出、恢复、删除和 fork。 -- **仅光栅图片和一个 workspace**:图片提示词要求持久存储以及明确声明支持图片输入的确切路由;只接受 PNG、JPEG、WebP 和 GIF。音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。 -- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。 -- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。 +- 只支持一个主 workspace。附加目录仍不受支持。 +- 提示词图片只支持 PNG、JPEG、WebP 和 GIF,并受附件存储和确切模型路由约束。 +- MCP resource 和 prompt 没有 DSH consumer;ACP 挂载只公开 MCP 工具。 +- 会话删除、fork、通过 `session/load` 重放 transcript、模式、命令、计划、终端、客户端文件系统操作和 elicitation 仍不属于此自动化接口。 diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index fa9eaf8ded..1bd6af44c1 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -32,7 +32,7 @@ ], "license": "MIT", "dependencies": { - "@agentclientprotocol/sdk": "0.25.1", + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { @@ -40,10 +40,18 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-token-meter": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,7 +59,11 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/acp/acp/src/content.ts b/packages/acp/acp/src/content.ts index 66ac7ea3be..e31807b276 100644 --- a/packages/acp/acp/src/content.ts +++ b/packages/acp/acp/src/content.ts @@ -4,7 +4,7 @@ import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' import type { Context } from '@deepseek-ai/cordis' import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' /** Raster formats shared by ACP image blocks and the core attachment vocabulary. */ @@ -60,10 +60,9 @@ function decodeImage(block: Extract): SaveIm } /** Resolve the exact current route and require explicit image input support. */ -async function assertImageRoute(ctx: Context, agent: Agent, signal: AbortSignal): Promise { - const routed = agent.session.requestHeader()?.config - const provider = routed?.provider ?? agent.options.provider - const model = routed?.model ?? agent.options.model +async function assertImageRoute(ctx: Context, route: ModelSelection | undefined, signal: AbortSignal): Promise { + const provider = route?.provider + const model = route?.model const llm = ctx.get('llm') if (provider === undefined || model === undefined || llm === undefined) { throw new AcpContentError('the current model route could not be resolved for image input', 'invalid') @@ -115,7 +114,7 @@ function resourceLinkText(block: Extract 0) { const attachments = ctx.get('attachments') if (attachments === undefined) throw new AcpContentError('no attachment store is mounted', 'invalid') - await assertImageRoute(ctx, agent, signal) + await assertImageRoute(ctx, route, signal) signal.throwIfAborted() try { refs = await attachments.saveImages(images) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 7be2a2bda6..fc536d7e1b 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -1,61 +1,64 @@ /** * Automation-only Agent Client Protocol server over JSON-RPC stdio. * - * The bridge exposes fresh harness sessions to trusted programmatic clients. It - * carries prompt text/images, committed assistant text/images, cancellation, - * and one-shot permission decisions; presentation and human-interaction - * features stay with the harness's UI modules. + * The bridge exposes persistent harness sessions to trusted programmatic + * clients. It carries standard configuration, MCP mounts, prompt content, + * committed semantic updates, cancellation, and one-shot permission decisions; + * presentation and human-interaction features stay with the harness's UI modules. * * @module @deepseek-ai/dsh-acp */ import type { Context } from '@deepseek-ai/cordis' +import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' -import { isAbsolute } from 'node:path' +import { realpath } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from '@deepseek-ai/schemastery' -import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain } from '@deepseek-ai/dsh-llm' import { - AgentSideConnection, + agent as createAcpAgentApp, + methods, ndJsonStream, PROTOCOL_VERSION, RequestError, - type Agent as AcpAgent, + type AgentContext, type AuthenticateRequest, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type InitializeRequest, type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, type NewSessionRequest, type NewSessionResponse, type PromptRequest, type PromptResponse, + type RequestPermissionRequest, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, type SessionNotification, - type StopReason, type Stream, } from '@agentclientprotocol/sdk' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' // Side-effect type import: declaration-merges the approval waterfall answered below. import type {} from '@deepseek-ai/dsh-user-approval' -import { AcpContentError, admitAcpPrompt, assistantBlockToAcp, supportsAcpImagePrompts } from './content.ts' -import { turnEndToStopReason } from './codec.ts' +import { supportsAcpImagePrompts } from './content.ts' +import { AcpMcpConfigError } from './mcp.ts' +import { AcpModelConfigError } from './model-control.ts' +import { AcpSession } from './session.ts' + +const DEFAULT_SESSION_LIST_PAGE_SIZE = 100 export const name = 'acp' -/** The bridge creates and owns agents; every other concern is carried by the agent composition. */ -export const inject = ['agents'] - -/** - * The single continuable-subagent teardown the bridge needs. Declared - * structurally so this package does not depend on the subagent seam for one - * shutdown hook; an absent service means nothing continuable was materialized. - */ -interface ContinuableDrain { - /** - * Close admission below exact host-owned parents, then dispose only their - * continuable descendants child-first. - */ - drainContinuableDescendants(parents: readonly Agent[]): Promise -} +/** Core services required by the standard automation controls. */ +export const inject = ['agents', 'llm', 'sessionPersistence', 'sessions'] /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { @@ -73,6 +76,8 @@ export interface AcpConfig { provider?: string /** Model name for created agents. */ model?: string + /** Maximum summaries returned by one session/list page. */ + sessionListPageSize?: number /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -80,39 +85,9 @@ export interface AcpConfig { export const Config: Schema = Schema.object({ provider: Schema.string(), model: Schema.string(), + sessionListPageSize: Schema.natural().min(1).default(100), }) -/** Per-session protocol state. */ -interface SessionRecord { - agent: Agent - /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ - dispose: () => Promise - /** Ordered assistant-output delivery; every task contains its own failure. */ - outputTail: Promise - /** In-flight admission/turn/output lifecycle for exact settlement. */ - inflight: { - resolve: (reason: StopReason) => void - reject: (error: Error) => void - /** Set only after rich-content admission succeeds and the message is built. */ - messageId: string | undefined - /** Whether this prompt has entered the Agent's durable inbox interval. */ - messageQueued: boolean - turn: number | undefined - /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ - endReason: TurnEndReason | undefined - /** Admission quiescence gate, including any attachment write already in progress. */ - admissionDone: Promise - finishAdmission: () => void - admissionController: AbortController - cancelRequested: boolean - settlementStarted: boolean - /** Conversion failure for committed output owned by this prompt's turn. */ - outputError: Error | undefined - /** Interval-wide failure outside the correlated turn. */ - agentError: Error | undefined - } | undefined -} - /** * Mount the automation-only ACP server. * @param ctx - Cordis context carrying the agent factory and session events. @@ -121,24 +96,25 @@ interface SessionRecord { export function apply(ctx: Context, config: AcpConfig): void { // ACP handlers execute outside this plugin's injection scope, so capture the // injected service during apply rather than reading it lazily in a callback. - const agents = ctx.agents + const persistence = ctx.sessionPersistence const logger = ctx.logger - const sessions = new Map() + const sessionListPageSize = resolveSessionListPageSize(config.sessionListPageSize) + const sessions = new Map() + const activating = new Set() let closed = false - let conn: AgentSideConnection let imagePromptEnabled = false /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ - const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const ownedRecord = (agent: Parameters[0]): AcpSession | undefined => { const record = sessions.get(agent.session.id) - return record?.agent === agent ? record : undefined + return record?.owns(agent) === true ? record : undefined } const assertOpen = (): void => { if (closed) throw internalError('the ACP bridge has been disposed') } - const requireSession = (sessionId: SessionId): SessionRecord => { + const requireSession = (sessionId: SessionId): AcpSession => { const record = sessions.get(sessionId) if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`) return record @@ -147,7 +123,7 @@ export function apply(ctx: Context, config: AcpConfig): void { /** Send one ordered protocol update while containing transport-only failure. */ const notify = async (notification: SessionNotification): Promise => { try { - await conn.sessionUpdate(notification) + await conn.notify(methods.client.session.update, notification) /* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */ } catch (error: unknown) { logger.warn(`acp: session/update failed: ${String(error)}`) @@ -155,114 +131,21 @@ export function apply(ctx: Context, config: AcpConfig): void { /* v8 ignore stop */ } - const rejectFromError = ( - inflight: NonNullable, - reason: Extract, - ): void => { - inflight.reject(internalError(`turn failed: ${reason.error.message}`)) - } - - /** - * Settle one exact prompt only after admission, agent activity, and ordered - * assistant delivery have all reached quiescence. - */ - const settleAfterQuiescence = ( - record: SessionRecord, - inflight: NonNullable, - ): void => { - if (inflight.settlementStarted) return - inflight.settlementStarted = true - void (async () => { - await inflight.admissionDone - if (inflight.messageQueued) { - await record.agent.whenIdle() - // session/event enqueues synchronously before the agent becomes idle; - // reading the live tail here includes every committed output task. - await record.outputTail - } - /* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */ - if (record.inflight !== inflight) return - record.inflight = undefined - if (inflight.cancelRequested) { - inflight.resolve('cancelled') - return - } - if (inflight.outputError !== undefined) { - inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`)) - return - } - if (inflight.agentError !== undefined) { - inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`)) - return - } - const end = inflight.endReason - if (end === undefined) { - inflight.resolve('cancelled') - } else if (end.kind === 'error') { - rejectFromError(inflight, end) - } else { - // Token-limit and other non-terminal endings are not prompt-level stop - // reasons; ordinary quiescence reports end_turn. - inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) - } - })() - /* v8 ignore start -- admissionDone only resolves, and the queued path's idle/output gates contain their own failures. */ - .catch((error: unknown) => { - if (record.inflight !== inflight) return - record.inflight = undefined - inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`)) - }) - /* v8 ignore stop */ - } - - // Emit only committed assistant text/images. Raw chunks, reasoning, tools, - // plans, titles, and retry markers are presentation or trace data and stay - // off the automation wire. One per-session chain preserves block/message - // order across asynchronous attachment reads. - ctx.on('session/event', (session, event: SessionEvent) => { + ctx.on('session/event', (session, event) => { const record = sessions.get(session.header.id) - if (record === undefined || record.agent.session !== session) return - try { - if (event.type === 'assistant/message') { - const inflight = record.inflight?.turn === event.data.turn ? record.inflight : undefined - const previous = record.outputTail - const delivery = previous.then(async () => { - for (const block of event.data.message.content) { - const content = await assistantBlockToAcp(ctx, block) - if (content === undefined) continue - await notify({ - sessionId: record.agent.session.id, - update: { sessionUpdate: 'agent_message_chunk', content }, - }) - } - }) - record.outputTail = delivery.catch((error: unknown) => { - // assistantBlockToAcp owns conversion failures and always throws Error. - const failure = error as Error - if (inflight !== undefined) inflight.outputError ??= failure - logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`) - }) - } - } finally { - const inflight = record.inflight - if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - inflight.endReason = event.data.reason - } - } + if (record?.ownsSession(session) === true) record.onSessionEvent(session, event) }) ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => { - const record = ownedRecord(agent) - const inflight = record?.inflight - if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn + ownedRecord(agent)?.onInboxClaimed(message, turn) }) ctx.on('agent/error', ({ agent, turn, error }) => { - const record = ownedRecord(agent) - const inflight = record?.inflight - if (record === undefined || inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return - inflight.agentError = new Error(errorChain(error)) - settleAfterQuiescence(record, inflight) + ownedRecord(agent)?.onAgentError(turn, error) + }) + + ctx.on('llm/adapters-updated', () => { + for (const record of sessions.values()) record.topologyChanged() }) // Permission requests are a machine policy channel for ACP clients such as @@ -271,173 +154,215 @@ export function apply(ctx: Context, config: AcpConfig): void { ctx.on('approval/request', (request, next) => { const record = ownedRecord(request.agent) if (record === undefined || request.callId === undefined) return next() - return conn.requestPermission({ - sessionId: record.agent.session.id, - toolCall: { toolCallId: request.callId }, - options: [ - { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, - { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, - ], + const callId = request.callId + return record.drainUpdates().then(() => { + const params: RequestPermissionRequest = { + sessionId: record.agent.session.id, + toolCall: { toolCallId: callId }, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + } + return conn.request(methods.client.session.requestPermission, params) }).then(({ outcome }) => { if (outcome.outcome === 'cancelled') return 'cancelled' return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected' }) }) - const makeAgent = (connection: AgentSideConnection): AcpAgent => { - conn = connection - return { - async initialize(_params: InitializeRequest): Promise { - // Single-version agent: the spec's "same version if supported, else - // the latest supported" both resolve to this server's one version. - imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model) - return { - protocolVersion: PROTOCOL_VERSION, - agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, - agentCapabilities: { - promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false }, - }, - authMethods: [], - } - }, + const implementation = { + async initialize(_params: InitializeRequest): Promise { + // Single-version agent: the spec's "same version if supported, else + // the latest supported" both resolve to this server's one version. + imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model) + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, + agentCapabilities: { + mcpCapabilities: { http: true }, + promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, + }, + authMethods: [], + } + }, - authenticate(_params: AuthenticateRequest): Promise { - return Promise.resolve() - }, + authenticate(_params: AuthenticateRequest): Promise { + return Promise.resolve() + }, - async newSession(params: NewSessionRequest): Promise { - assertOpen() - validateSessionParams(params) - const sessionId = SessionId(randomUUID()) - // No preset composition: the ACP bundle keeps the model-facing rows in - // the host plane, so this agent reads them from the global layer. A - // deployment that configures a roster has to join one here first - // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). - const handle = await agents.create({ + async newSession(params: NewSessionRequest, signal: AbortSignal): Promise { + assertOpen() + validateWorkspaceParams(params) + const sessionId = SessionId(randomUUID()) + // No preset composition: the ACP bundle keeps the model-facing rows in + // the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). + let record: AcpSession + try { + record = await AcpSession.create(ctx, { sessionId, - meta: { cwd: params.cwd }, + cwd: params.cwd, + mcpServers: params.mcpServers, agentOptions: agentOptions(config), + fallbackSelection: initialSelection(config), + signal, + notify, }) - /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ - if (closed) { - await handle.dispose() - throw internalError('connection closed during session/new') - } - sessions.set(sessionId, { - agent: handle.agent, - dispose: () => handle.dispose(), - outputTail: Promise.resolve(), - inflight: undefined, - }) - return { sessionId } - }, - - async prompt(params: PromptRequest): Promise { + } catch (error: unknown) { + if (error instanceof AcpMcpConfigError) throw invalidParams(error.message) + throw error + } + /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ + if (closed) { + await record.close('connection closed during session/new') + throw internalError('connection closed during session/new') + } + sessions.set(sessionId, record) + try { + await persistence.ensureMaterialized(record.agent.session) assertOpen() - const record = requireSession(SessionId(params.sessionId)) - if (record.inflight !== undefined) { - throw invalidParams('a prompt is already in flight for this session') - } - const completion = Promise.withResolvers() - const admission = Promise.withResolvers() - const admissionController = new AbortController() - const inflight: NonNullable = { - resolve: completion.resolve, - reject: completion.reject, - messageId: undefined, - messageQueued: false, - turn: undefined, - endReason: undefined, - admissionDone: admission.promise, - finishAdmission: admission.resolve, - admissionController, - cancelRequested: false, - settlementStarted: false, - outputError: undefined, - agentError: undefined, - } - // Reserve the one-prompt slot before the first asynchronous route or - // attachment operation so concurrent prompts and cancellation observe - // admission as genuinely in flight. - record.inflight = inflight + return { sessionId, configOptions: await record.configOptions(signal) } + } catch (error: unknown) { + sessions.delete(sessionId) + await record.close('session/new activation failed') + throw error + } + }, - let admissionFailed = false - let admissionFailure: unknown + async resumeSession(params: ResumeSessionRequest, signal: AbortSignal): Promise { + assertOpen() + validateWorkspaceParams(params) + const sessionId = SessionId(params.sessionId) + if (sessions.has(sessionId) || activating.has(sessionId)) { + throw invalidParams(`session is already active: ${sessionId}`) + } + activating.add(sessionId) + return (async (): Promise => { + const persisted = (await persistence.list(signal)).find(header => header.id === sessionId) + if (persisted === undefined || persisted.origin === 'subagent' || persisted.parentSession !== undefined) { + throw invalidParams(`session is not resumable: ${sessionId}`) + } + if (!await sameDirectory(persisted.cwd, params.cwd)) { + throw invalidParams(`session cwd does not match: ${params.cwd}`) + } + let record: AcpSession try { - // Do not persist rich content for a retired destination. Re-check - // after admission too because an agent-loop reload may race storage. - if (ctx.agents.get(record.agent.id) !== record.agent) { - throw internalError('prompt was not queued: the agent was disposed outside the bridge') - } - const content = await admitAcpPrompt( - ctx, - record.agent, - params.prompt, - imagePromptEnabled, - admissionController.signal, - ) - // No await may separate this final abort check from followup: a - // cancellation that wins admission must never enqueue a late turn. - admissionController.signal.throwIfAborted() - if (ctx.agents.get(record.agent.id) !== record.agent) { - throw internalError('prompt was not queued: the agent was disposed outside the bridge') - } - const message = createUserMessage({ content, source: { kind: 'user' } }) - inflight.messageId = message.id - inflight.messageQueued = true - try { - record.agent.followup(message) - } catch (error: unknown) { - // The typed same-process seam may fail synchronously before durable - // inbox receipt; restore the pre-operation boundary for mapping. - inflight.messageQueued = false - throw error - } + record = await AcpSession.resume(ctx, { + sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + agentOptions: agentOptions(config), + fallbackSelection: initialSelection(config), + signal, + notify, + }) } catch (error: unknown) { - admissionFailed = true - admissionFailure = error - } finally { - inflight.finishAdmission() + if (error instanceof AcpMcpConfigError) throw invalidParams(error.message) + throw error } + /* v8 ignore start -- the persisted header was checked before resume; the factory restores that exact header. */ + if (!await sameDirectory(record.agent.session.header.cwd, params.cwd)) { + await record.close('session/resume cwd mismatch') + throw invalidParams(`session cwd does not match: ${params.cwd}`) + } + /* v8 ignore stop */ + /* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */ + if (closed) { + await record.close('connection closed during session/resume') + throw internalError('connection closed during session/resume') + } + sessions.set(sessionId, record) + try { + return { configOptions: await record.configOptions(signal) } + } catch (error: unknown) { + sessions.delete(sessionId) + await record.close('session/resume option discovery failed') + throw error + } + })().finally(() => { activating.delete(sessionId) }) + }, - if (inflight.cancelRequested) { - settleAfterQuiescence(record, inflight) - return { stopReason: await completion.promise } - } - if (admissionFailed) { - record.inflight = undefined - if (admissionFailure instanceof AcpContentError) { - throw admissionFailure.kind === 'invalid' - ? invalidParams(admissionFailure.message) - : internalError(admissionFailure.message) - } - if (admissionFailure instanceof RequestError) throw admissionFailure - // The admission codec and same-process agent seam throw Error values. - const detail = (admissionFailure as Error).message - throw internalError(`prompt was not queued: ${detail}`) + async listSessions(params: ListSessionsRequest, signal: AbortSignal): Promise { + assertOpen() + if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) { + throw invalidParams(`cwd must be an absolute path: ${params.cwd}`) + } + let cursor: SessionListCursor | undefined + try { + cursor = decodeSessionListCursor(params.cursor) + } catch (error: unknown) { + throw invalidParams((error as Error).message) + } + const listed = await persistence.list(signal) + const filtered = await Promise.all(listed.map(async (header) => { + if ( + sessions.has(header.id) + || activating.has(header.id) + || header.origin === 'subagent' + || header.parentSession !== undefined + || header.cwd === undefined + || !isAbsolute(header.cwd) + ) return undefined + if (params.cwd !== undefined && params.cwd !== null && !await sameDirectory(header.cwd, params.cwd)) { + return undefined } + return { sessionId: header.id, cwd: header.cwd, createdAt: header.createdAt } + })) + const entries = filtered + .filter((entry): entry is NonNullable => entry !== undefined) + .sort((left, right) => right.createdAt - left.createdAt || String(left.sessionId).localeCompare(String(right.sessionId))) + const remaining = cursor === undefined + ? entries + : entries.filter(entry => isAfterSessionListCursor(entry, cursor)) + const page = remaining.slice(0, sessionListPageSize) + const next = remaining.length > page.length ? page.at(-1) : undefined + return { + sessions: page.map(({ sessionId, cwd }) => ({ sessionId, cwd })), + ...next === undefined ? {} : { nextCursor: encodeSessionListCursor(next) }, + } + }, - settleAfterQuiescence(record, inflight) - const stopReason = await completion.promise - return { stopReason } - }, + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + signal: AbortSignal, + ): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + try { + return { configOptions: await record.setConfig(params.configId, params.value, signal) } + } catch (error: unknown) { + if (error instanceof AcpModelConfigError) throw invalidParams(error.message) + throw error + } + }, - cancel(params: CancelNotification): Promise { - const record = sessions.get(SessionId(params.sessionId)) - if (record === undefined) return Promise.resolve() - const inflight = record.inflight - if (inflight !== undefined) { - inflight.cancelRequested = true - inflight.admissionController.abort(new Error('ACP prompt cancelled')) - settleAfterQuiescence(record, inflight) - } - // Admission is not Agent work. Preserve unrelated producers until this - // prompt has entered the durable inbox; without a prompt, cancellation - // continues to target autonomous work on the addressed Agent. - if (inflight === undefined || inflight.messageQueued) record.agent.cancel({ kind: 'user' }) - return Promise.resolve() - }, - } + async closeSession(params: CloseSessionRequest): Promise { + assertOpen() + const sessionId = SessionId(params.sessionId) + const record = requireSession(sessionId) + try { + await record.close('ACP session closed') + } catch (error: unknown) { + throw internalError(`session close failed: ${errorChain(error)}`) + } finally { + if (sessions.get(sessionId) === record) sessions.delete(sessionId) + } + return {} + }, + + async prompt(params: PromptRequest, requestSignal: AbortSignal): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + return record.prompt(params, imagePromptEnabled, requestSignal) + }, + + cancel(params: CancelNotification): Promise { + sessions.get(SessionId(params.sessionId))?.cancel() + return Promise.resolve() + }, } /* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */ @@ -445,52 +370,35 @@ export function apply(ctx: Context, config: AcpConfig): void { Writable.toWeb(process.stdout) as WritableStream, Readable.toWeb(process.stdin) as ReadableStream, ) - conn = new AgentSideConnection(makeAgent, stream) + const app = createAcpAgentApp({ name: 'deepseek-harness-acp' }) + .onRequest(methods.agent.initialize, ({ params }) => implementation.initialize(params)) + .onRequest(methods.agent.authenticate, async ({ params }) => { + await implementation.authenticate(params) + return {} + }) + .onRequest(methods.agent.session.new, ({ params, signal }) => implementation.newSession(params, signal)) + .onRequest(methods.agent.session.list, ({ params, signal }) => implementation.listSessions(params, signal)) + .onRequest(methods.agent.session.resume, ({ params, signal }) => implementation.resumeSession(params, signal)) + .onRequest(methods.agent.session.close, ({ params }) => implementation.closeSession(params)) + .onRequest(methods.agent.session.setConfigOption, ({ params, signal }) => implementation.setSessionConfigOption(params, signal)) + .onRequest(methods.agent.session.prompt, ({ params, signal }) => implementation.prompt(params, signal)) + .onNotification(methods.agent.session.cancel, ({ params }) => implementation.cancel(params)) + const connection = app.connect(stream) + const conn: AgentContext = connection.client let quiescing: Promise | undefined const quiesce = (): Promise => { if (quiescing !== undefined) return quiescing closed = true const records = [...sessions.values()] - sessions.clear() - // Stop the bridge's own work before any await: a descendant drain can block - // on persistence or scoped cleanup, and the top-level agents must not keep - // running model and tool calls for its whole duration. - for (const record of records) { - const inflight = record.inflight - if (inflight !== undefined) { - inflight.cancelRequested = true - inflight.admissionController.abort(new Error('ACP bridge disposed')) - settleAfterQuiescence(record, inflight) - } - record.agent.cancel({ kind: 'user' }) - } + // AcpSession.close cancels synchronously before its first await, so every owned + // prompt stops before any descendant or persistence drain can block. quiescing = (async () => { - // Preserve the same prompt boundary during connection teardown: a rich - // admission already writing must stop before its slot settles, and every - // committed output conversion must drain while attachment services remain - // available. session/event enqueues output synchronously before idle. - await Promise.all(records.map(async (record) => { - await record.inflight?.admissionDone - await record.agent.whenIdle() - await record.outputTail - })) - // Continuable subagents outlive the turn that started them, and their - // Activations own descendant teardown. Drain only these sessions' forests - // child-first BEFORE disposing the top-level agents, so no descendant is - // left holding a runtime its owner already released and another frontend - // sharing this Context remains live. - // Read the one teardown method structurally: the bridge needs no other - // part of the subagent seam, so it does not depend on that package. - const subagents = ctx.get('subagents') as ContinuableDrain | undefined - if (subagents !== undefined) { - try { - await subagents.drainContinuableDescendants(records.map(record => record.agent)) - } catch (error: unknown) { - logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) - } + const disposals = await Promise.allSettled(records.map(record => record.close('ACP bridge disposed'))) + for (const record of records) { + /* v8 ignore next -- closed blocks concurrent handlers; each captured record remains mapped until this loop. */ + if (sessions.get(record.agent.session.id) === record) sessions.delete(record.agent.session.id) } - const disposals = await Promise.allSettled(records.map(record => record.dispose())) const failures: unknown[] = [] for (const result of disposals) { if (result.status === 'rejected') failures.push(result.reason as unknown) @@ -510,7 +418,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } /* v8 ignore start -- production transport rejection and teardown failure. */ - void conn.closed + void connection.closed .catch((error: unknown) => { logger.warn(`acp: connection closed with an error: ${String(error)}`) }) @@ -535,11 +443,84 @@ function agentOptions(config: AcpConfig): { provider?: string; model?: string } } } -/** Reject session features outside the automation contract. */ -function validateSessionParams(params: NewSessionRequest): void { +/** Initial session selection when both deployment fields are present. */ +function initialSelection(config: AcpConfig): ModelSelection | undefined { + return config.provider === undefined || config.model === undefined + ? undefined + : { provider: config.provider, model: config.model } +} + +interface SessionListCursor { + createdAt: number + sessionId: string +} + +/** Resolve and validate the deployment-owned session page limit. */ +function resolveSessionListPageSize(value: number | undefined): number { + const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE + /* v8 ignore start -- Cordis applies the positive-integer Config schema; this protects direct apply callers. */ + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error('acp: sessionListPageSize must be a positive safe integer') + } + /* v8 ignore stop */ + return resolved +} + +/** Decode an opaque keyset cursor without assigning meaning to client metadata. */ +function decodeSessionListCursor(value: string | null | undefined): SessionListCursor | undefined { + if (value === undefined || value === null) return undefined + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('session/list cursor is invalid') + try { + const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as unknown + const createdAt: unknown = Array.isArray(decoded) ? decoded[0] : undefined + const sessionId: unknown = Array.isArray(decoded) ? decoded[1] : undefined + if ( + !Array.isArray(decoded) + || decoded.length !== 2 + || typeof createdAt !== 'number' + || !Number.isSafeInteger(createdAt) + || createdAt < 0 + || typeof sessionId !== 'string' + || sessionId.length === 0 + ) throw new Error('invalid cursor fields') + const canonical = Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url') + if (canonical !== value) throw new Error('non-canonical cursor') + return { createdAt, sessionId } + } catch (_invalidCursor) { + throw new Error('session/list cursor is invalid') + } +} + +/** Encode the last returned ordering key as an opaque continuation token. */ +function encodeSessionListCursor(entry: SessionListCursor): string { + return Buffer.from(JSON.stringify([entry.createdAt, entry.sessionId]), 'utf8').toString('base64url') +} + +/** Test whether an entry follows the cursor in newest-first list order. */ +function isAfterSessionListCursor(entry: SessionListCursor, cursor: SessionListCursor): boolean { + return entry.createdAt < cursor.createdAt + || (entry.createdAt === cursor.createdAt && entry.sessionId.localeCompare(cursor.sessionId) > 0) +} + +/** Reject workspace features outside the automation contract. */ +function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] | null }): void { if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`) - if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) { + if ( + params.additionalDirectories !== undefined + && params.additionalDirectories !== null + && params.additionalDirectories.length > 0 + ) { throw invalidParams('additionalDirectories is not supported') } - if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported') +} + +/** Compare existing directories by physical identity and missing paths lexically. */ +async function sameDirectory(left: string | undefined, right: string): Promise { + if (left === undefined) return false + try { + const [realLeft, realRight] = await Promise.all([realpath(left), realpath(right)]) + return realLeft === realRight + } catch (_unresolvablePath) { + return resolve(left) === resolve(right) + } } diff --git a/packages/acp/acp/src/mcp.ts b/packages/acp/acp/src/mcp.ts new file mode 100644 index 0000000000..3aa3cd3316 --- /dev/null +++ b/packages/acp/acp/src/mcp.ts @@ -0,0 +1,137 @@ +/** Standard ACP MCP-server declarations translated into Agent-scoped DSH MCP clients. */ + +import type { Context } from '@deepseek-ai/cordis' +import { createHash } from 'node:crypto' +import { validateHeaderName, validateHeaderValue } from 'node:http' +import { isAbsolute } from 'node:path' +import type { McpServer } from '@agentclientprotocol/sdk' +import * as McpClient from '@deepseek-ai/dsh-mcp-client' + +const VALID_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/ + +/** Caller-correctable MCP declaration failure. */ +export class AcpMcpConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'AcpMcpConfigError' + } +} + +/** + * Validate and mount one session's complete standard MCP server list before Agent publication. + * @param agentCtx - unpublished Agent scope that owns the MCP clients and tools. + * @param servers - stable ACP stdio or HTTP server declarations. + * @param sessionCwd - canonical primary workspace used by stdio servers. + */ +export async function mountAcpMcpServers( + agentCtx: Context, + servers: readonly McpServer[], + sessionCwd: string, +): Promise { + const configs = resolveMcpConfigs(servers, sessionCwd) + for (const config of configs) await agentCtx.plugin(McpClient, config) +} + +/** Convert the stable stdio/HTTP ACP transports and reject every other transport. */ +function resolveMcpConfigs(servers: readonly McpServer[], sessionCwd: string): McpClient.Config[] { + const names = new Set() + return servers.map((server, index) => { + const serverName = normalizeServerName(server.name) + if (names.has(serverName)) { + throw new AcpMcpConfigError(`mcpServers contains duplicate normalized name: ${serverName}`) + } + names.add(serverName) + if (!('type' in server)) { + if (!isAbsolute(server.command)) { + throw new AcpMcpConfigError(`mcpServers[${index}].command must be an absolute path`) + } + return validateClientConfig(index, () => McpClient.Config({ + transport: 'stdio', + serverName, + command: server.command, + args: server.args, + env: entriesToRecord(server.env, `mcpServers[${index}].env`, 'environment'), + cwd: sessionCwd, + failOnStartupError: true, + })) + } + if (server.type === 'http') { + assertHttpUrl(server.url, `mcpServers[${index}].url`) + return validateClientConfig(index, () => McpClient.Config({ + transport: 'streamable-http', + serverName, + url: server.url, + headers: entriesToRecord(server.headers, `mcpServers[${index}].headers`, 'header'), + failOnStartupError: true, + })) + } + throw new AcpMcpConfigError(`mcpServers[${index}] transport ${server.type} is not supported`) + }) +} + +/** Convert ordered ACP name/value entries without silently accepting duplicate keys. */ +function entriesToRecord( + entries: readonly { name: string; value: string }[], + field: string, + kind: 'environment' | 'header', +): Record { + const result: Record = {} + const names = new Set() + for (const entry of entries) { + if (kind === 'header') { + try { + validateHeaderName(entry.name) + validateHeaderValue(entry.name, entry.value) + } catch (_invalidHeader) { + throw new AcpMcpConfigError(`${field} contains an invalid header entry`) + } + } else if ( + entry.name.length === 0 + || entry.name.includes('=') + || entry.name.includes('\0') + || entry.value.includes('\0') + ) { + throw new AcpMcpConfigError(`${field} contains an invalid environment entry`) + } + const identity = kind === 'header' ? entry.name.toLowerCase() : entry.name + if (names.has(identity)) throw new AcpMcpConfigError(`${field} contains duplicate name: ${entry.name}`) + names.add(identity) + result[entry.name] = entry.value + } + return result +} + +/** Produce a stable DSH tool namespace from ACP's human-readable server name. */ +function normalizeServerName(name: string): string { + if (name.trim().length === 0 || /[\u0000-\u001f\u007f]/.test(name)) { + throw new AcpMcpConfigError('mcpServers contains an invalid server name') + } + if (VALID_SERVER_NAME.test(name)) return name + const slug = name.normalize('NFKD') + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 20) || 'server' + const digest = createHash('sha256').update(name).digest('hex').slice(0, 8) + return `${slug}_${digest}`.slice(0, 32) +} + +/** Require the stable Streamable HTTP transport URL schemes. */ +function assertHttpUrl(value: string, field: string): void { + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('unsupported protocol') + } catch (_invalidUrl) { + throw new AcpMcpConfigError(`${field} must be an absolute HTTP(S) URL`) + } +} + +/** Map the existing MCP provider's schema error into ACP invalid params. */ +function validateClientConfig(index: number, parse: () => McpClient.Config): McpClient.Config { + try { + return parse() + } catch (error: unknown) { + /* v8 ignore next -- Schemastery validation rejects with Error instances. */ + const detail = error instanceof Error ? error.message : String(error) + throw new AcpMcpConfigError(`mcpServers[${index}] is invalid: ${detail}`) + } +} diff --git a/packages/acp/acp/src/model-control.ts b/packages/acp/acp/src/model-control.ts new file mode 100644 index 0000000000..f601ed4669 --- /dev/null +++ b/packages/acp/acp/src/model-control.ts @@ -0,0 +1,223 @@ +/** Standard ACP session configuration over one Agent's model selection. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionConfigOption, SessionConfigValueId } from '@agentclientprotocol/sdk' +import { installModelSelection, type ModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId, type LlmCallConfig, type LlmRuntime } from '@deepseek-ai/dsh-llm' + +const MODEL_CONFIG_ID = 'model' +const REASONING_CONFIG_ID = 'reasoning_effort' + +interface ModelChoice { + selection: ModelSelection + value: SessionConfigValueId +} + +interface ConfigState { + choices: Map + options: SessionConfigOption[] +} + +/** Caller-correctable session configuration failure. */ +export class AcpModelConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'AcpModelConfigError' + } +} + +/** Project and mutate one Agent's provider/model/reasoning selection through ACP config options. */ +export class AcpModelControl { + /** Scoped selection reference consumed by Agent request assembly. */ + readonly selection: ModelSelectionRef + private tail = Promise.resolve() + private selected: ModelSelection | undefined + private turnSelection: { turn: number; selection: ModelSelection } | undefined + private hasResolvedState = false + + constructor( + private readonly llm: LlmRuntime, + initial: ModelSelection | undefined, + ) { + this.selected = initial + const getCurrent = (): ModelSelection | undefined => this.turnSelection?.selection ?? this.selected + const setCurrent = (value: ModelSelection | undefined): void => { this.selected = value } + this.selection = { + get current() { return getCurrent() }, + set current(value) { setCurrent(value) }, + assembled: undefined, + } + } + + /** + * Install request/prompt consistency listeners in the unpublished Agent scope. + * @param agentCtx - Agent scope that consumes this selection. + */ + install(agentCtx: Context): void { + installModelSelection(agentCtx, this.selection) + } + + /** + * Snapshot the selection attached to the next accepted ACP prompt. + * @returns a detached future selection, or undefined when listeners supply the route. + */ + snapshot(): ModelSelection | undefined { + return this.selected === undefined ? undefined : { ...this.selected } + } + + /** + * Pin one admitted ACP message's selection for every step in its turn. + * @param turn - admitted Agent turn. + * @param selection - exact prompt-admission selection. + */ + pinTurn(turn: number, selection: ModelSelection): void { + this.turnSelection = { turn, selection: { ...selection } } + } + + /** + * Release only the exact completed turn's routing override. + * @param turn - completed Agent turn. + */ + releaseTurn(turn: number): void { + if (this.turnSelection?.turn === turn) this.turnSelection = undefined + } + + /** + * Return the complete standard config-option state after prior mutations settle. + * @param signal - optional catalog and exact-model cancellation. + * @returns all current standard configuration options. + */ + options(signal?: AbortSignal): Promise { + return this.serialize(async () => (await this.state(signal)).options) + } + + /** + * Set one advertised option and return the complete resulting option state. + * @param configId - standard option id. + * @param value - opaque selected value returned by a previous option state. + * @param signal - optional catalog and exact-model cancellation. + * @returns all standard options after the serialized mutation. + */ + set(configId: string, value: unknown, signal?: AbortSignal): Promise { + return this.serialize(async () => { + if (typeof value !== 'string') throw new AcpModelConfigError(`${configId} requires a select value`) + const current = this.selected + if (current === undefined) throw new AcpModelConfigError('this session has no model selection') + if (configId === MODEL_CONFIG_ID) { + const state = await this.state(signal) + const selected = state.choices.get(value) + if (selected === undefined) throw new AcpModelConfigError(`unknown model option: ${value}`) + await this.resolveSelection(selected, signal) + this.selected = selected + } else if (configId === REASONING_CONFIG_ID) { + const info = await this.llm.resolveModelInfo(current.provider, current.model, signal) + if (info.reasoning === undefined || !info.reasoning.efforts.some(effort => effort.id === value)) { + throw new AcpModelConfigError(`unknown reasoning effort for ${current.provider}/${current.model}: ${value}`) + } + this.selected = await this.resolveSelection({ + provider: current.provider, + model: current.model, + reasoningEffort: ReasoningEffortId(value), + }, signal) + } else { + throw new AcpModelConfigError(`unknown session config option: ${configId}`) + } + return (await this.state(signal)).options + }) + } + + /** Keep concurrent client mutations in receive order without wedging after rejection. */ + private serialize(operation: () => Promise): Promise { + const result = this.tail.then(operation) + this.tail = result.then(() => undefined, () => undefined) + return result + } + + /** Build detached model choices and the dependent reasoning option. */ + private async state(signal?: AbortSignal): Promise { + const selected = this.selected + if (selected === undefined) return { choices: new Map(), options: [] } + let resolved: ModelSelection + let routeAvailable = true + try { + resolved = await this.resolveSelection(selected, signal) + this.hasResolvedState = true + } catch (error: unknown) { + if (!this.hasResolvedState) throw error + resolved = selected + routeAvailable = false + } + const choices = new Map() + const groups = await Promise.all(this.llm.listProviders().map(async (provider) => { + try { + const models = await this.llm.listModels(provider.id) + const entries = models.map((model) => { + const choice: ModelChoice = { + value: modelValue(provider.id, model.id), + selection: { provider: provider.id, model: model.id }, + } + choices.set(choice.value, choice.selection) + return { + value: choice.value, + name: model.name, + ...model.description === undefined ? {} : { description: model.description }, + } + }) + return { group: provider.id, name: provider.name, options: entries } + } catch (_providerCatalogUnavailable) { + return { group: provider.id, name: provider.name, options: [] } + } + })) + const currentValue = modelValue(resolved.provider, resolved.model) + if (!choices.has(currentValue)) { + choices.set(currentValue, { provider: resolved.provider, model: resolved.model }) + let group = groups.find(item => item.group === resolved.provider) + if (group === undefined) { + group = { group: resolved.provider, name: resolved.provider, options: [] } + groups.push(group) + } + group.options.unshift({ value: currentValue, name: resolved.model }) + } + const options: SessionConfigOption[] = [{ + id: MODEL_CONFIG_ID, + name: 'Model', + category: 'model', + type: 'select', + currentValue, + options: groups.filter(group => group.options.length > 0), + }] + const info = routeAvailable + ? await this.llm.resolveModelInfo(resolved.provider, resolved.model, signal) + : undefined + if (info?.reasoning !== undefined && resolved.reasoningEffort !== undefined) { + options.push({ + id: REASONING_CONFIG_ID, + name: 'Reasoning effort', + category: 'thought_level', + type: 'select', + currentValue: String(resolved.reasoningEffort), + options: info.reasoning.efforts.map(effort => ({ + value: String(effort.id), + name: effort.name, + ...effort.description === undefined ? {} : { description: effort.description }, + })), + }) + } + return { choices, options } + } + + /** Validate an exact route and retain only Agent-owned selection fields. */ + private async resolveSelection(selection: ModelSelection, signal?: AbortSignal): Promise { + const resolved: LlmCallConfig = await this.llm.resolveCallConfig(selection, signal) + return { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }, + } + } +} + +/** Opaque ACP selector value carrying the full route identity. */ +function modelValue(provider: string, model: string): SessionConfigValueId { + return JSON.stringify([provider, model]) +} diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts new file mode 100644 index 0000000000..eca0e97068 --- /dev/null +++ b/packages/acp/acp/src/session.ts @@ -0,0 +1,518 @@ +/** One standard ACP session's Agent, configuration, prompt, update, and teardown lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RequestError, + type McpServer, + type PromptRequest, + type PromptResponse, + type SessionConfigOption, + type SessionNotification, + type StopReason, +} from '@agentclientprotocol/sdk' +import type { Agent, AgentHandle, AgentOptions, ModelSelection } from '@deepseek-ai/dsh-agent' +import { createUserMessage, errorChain, type UserMessage } from '@deepseek-ai/dsh-llm' +import { type Session, type SessionEvent, type SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { AcpContentError, admitAcpPrompt } from './content.ts' +import { turnEndToStopReason } from './codec.ts' +import { mountAcpMcpServers } from './mcp.ts' +import { AcpModelControl } from './model-control.ts' +import { assistantUpdates, toolCallUpdate, toolResultUpdate } from './updates.ts' + +/** The continuable-subagent teardown used without depending on the subagent package. */ +interface ContinuableDrain { + /** Dispose continuable descendants below exact host-owned parents child-first. */ + drainContinuableDescendants(parents: readonly Agent[]): Promise +} + +/** Inputs shared by fresh and resumed ACP session construction. */ +interface AcpSessionBuildOptions { + cwd: string + mcpServers: readonly McpServer[] + agentOptions: AgentOptions + fallbackSelection: ModelSelection | undefined + signal: AbortSignal + notify: (notification: SessionNotification) => Promise +} + +/** Fresh ACP session construction inputs. */ +export interface CreateAcpSessionOptions extends AcpSessionBuildOptions { + sessionId: SessionId +} + +/** Persisted ACP session construction inputs. */ +export interface ResumeAcpSessionOptions extends AcpSessionBuildOptions { + sessionId: SessionId +} + +interface InflightPrompt { + resolve: (reason: StopReason) => void + reject: (error: Error) => void + messageId: string | undefined + messageQueued: boolean + turn: number | undefined + endReason: TurnEndReason | undefined + admissionDone: Promise + finishAdmission: () => void + admissionController: AbortController + cancelRequested: boolean + settlementStarted: boolean + outputError: Error | undefined + agentError: Error | undefined +} + +/** Standard invalid-parameter failure with protocol-safe detail. */ +function invalidParams(detail: string): RequestError { + return RequestError.invalidParams(undefined, detail) +} + +/** Standard internal failure with protocol-safe detail. */ +function internalError(detail: string): RequestError { + return RequestError.internalError(undefined, detail) +} + +/** Restore the latest logged route before falling back to deployment config. */ +function selectionFor( + logged: { + config: { provider: string; model: string; reasoningEffort?: ModelSelection['reasoningEffort'] } + adapterDefaults?: { reasoningEffort?: boolean } + } | undefined, + fallback: ModelSelection | undefined, +): ModelSelection | undefined { + return logged === undefined + ? fallback + : { + provider: logged.config.provider, + model: logged.config.model, + ...logged.config.reasoningEffort === undefined || logged.adapterDefaults?.reasoningEffort === true + ? {} + : { reasoningEffort: logged.config.reasoningEffort }, + } +} + +/** + * Per-session ACP module. It owns the unpublished Agent composition, selected + * route, one-prompt admission slot, ordered standard updates, and memoized + * quiescent teardown. + */ +export class AcpSession { + /** The exact top-level Agent owned by this ACP session. */ + readonly agent: Agent + private readonly modelControl: AcpModelControl + private outputTail = Promise.resolve() + private inflight: InflightPrompt | undefined + private closing: Promise | undefined + private readonly pendingSelections = new Map() + + private constructor( + private readonly ctx: Context, + handle: AgentHandle, + modelControl: AcpModelControl, + private readonly notify: (notification: SessionNotification) => Promise, + ) { + this.agent = handle.agent + this.modelControl = modelControl + this.disposeAgent = () => handle.dispose() + } + + private readonly disposeAgent: () => Promise + + /** + * Compose a fresh Agent and all requested MCP clients before publication. + * @param ctx - ACP plugin context with Agent, LLM, and persistence services. + * @param options - fresh session identity, workspace, route, MCP, and notifier. + * @returns the fully composed per-session module. + */ + static async create(ctx: Context, options: CreateAcpSessionOptions): Promise { + const modelControl = new AcpModelControl(ctx.llm, options.fallbackSelection) + const handle = await ctx.agents.create({ + sessionId: options.sessionId, + meta: { cwd: options.cwd }, + agentOptions: options.agentOptions, + signal: options.signal, + setup: async (agentCtx) => { + modelControl.install(agentCtx) + await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) + }, + }) + return new AcpSession(ctx, handle, modelControl, options.notify) + } + + /** + * Restore a persisted Agent and compose the request's fresh MCP connections. + * @param ctx - ACP plugin context with Agent, LLM, and persistence services. + * @param options - persisted identity, workspace, fallback route, MCP, and notifier. + * @returns the restored per-session module. + */ + static async resume(ctx: Context, options: ResumeAcpSessionOptions): Promise { + let modelControl: AcpModelControl | undefined + const handle = await ctx.agents.resume({ + resumeSessionId: options.sessionId, + agentOptions: options.agentOptions, + signal: options.signal, + setup: async (agentCtx) => { + const agent = agentCtx.agent + /* v8 ignore next -- Agent factory setup always carries its unpublished Agent. */ + if (agent === undefined) throw new Error('acp: resumed Agent is absent during setup') + modelControl = new AcpModelControl( + ctx.llm, + selectionFor(agent.session.requestHeader(), options.fallbackSelection), + ) + modelControl.install(agentCtx) + await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) + }, + }) + /* v8 ignore start -- a fulfilled Agent resume necessarily ran setup to completion. */ + if (modelControl === undefined) { + await handle.dispose() + throw internalError('session/resume did not compose model selection') + } + /* v8 ignore stop */ + return new AcpSession(ctx, handle, modelControl, options.notify) + } + + /** + * Whether this module owns an exact Agent reference. + * @param agent - Agent observed on a scoped runtime event. + * @returns true only for this session's owned Agent. + */ + owns(agent: Agent): boolean { + return this.agent === agent + } + + /** + * Whether this module owns an exact Session reference. + * @param session - Session observed on a durable event. + * @returns true only for this session's owned Session. + */ + ownsSession(session: Session): boolean { + return this.agent.session === session + } + + /** + * Return the complete standard model configuration state. + * @param signal - optional request cancellation. + * @returns provider-grouped model and exact-model reasoning options. + */ + configOptions(signal?: AbortSignal): Promise { + this.assertActive() + return this.modelControl.options(signal) + } + + /** + * Apply one standard configuration option to later ACP turns. + * @param configId - advertised standard option id. + * @param value - selected standard option value. + * @param signal - optional request cancellation. + * @returns the complete resulting option state. + */ + setConfig(configId: string, value: unknown, signal?: AbortSignal): Promise { + this.assertActive() + return this.modelControl.set(configId, value, signal) + } + + /** Queue a complete option-state update after every earlier session update. */ + topologyChanged(): void { + if (this.closing !== undefined) return + const previous = this.outputTail + this.outputTail = previous + .then(async () => this.notify({ + sessionId: this.agent.session.id, + update: { + sessionUpdate: 'config_option_update', + configOptions: await this.modelControl.options(), + }, + })) + /* v8 ignore start -- option discovery contains per-provider failure and the bridge notifier contains transport failure. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: config-option update failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + } + + /** + * Admit, enqueue, and settle one prompt at whole-Agent quiescence. + * @param params - standard ACP prompt request for this session. + * @param imageEnabled - connection capability advertised at initialization. + * @param requestSignal - JSON-RPC request cancellation signal. + * @returns the correlated standard stop reason after ordered updates drain. + */ + async prompt( + params: PromptRequest, + imageEnabled: boolean, + requestSignal?: AbortSignal, + ): Promise { + this.assertActive() + if (this.inflight !== undefined) throw invalidParams('a prompt is already in flight for this session') + const completion = Promise.withResolvers() + const admission = Promise.withResolvers() + const admissionController = new AbortController() + const inflight: InflightPrompt = { + resolve: completion.resolve, + reject: completion.reject, + messageId: undefined, + messageQueued: false, + turn: undefined, + endReason: undefined, + admissionDone: admission.promise, + finishAdmission: admission.resolve, + admissionController, + cancelRequested: false, + settlementStarted: false, + outputError: undefined, + agentError: undefined, + } + this.inflight = inflight + const onRequestAbort = (): void => { this.cancelPrompt('ACP prompt request cancelled') } + requestSignal?.addEventListener('abort', onRequestAbort, { once: true }) + /* v8 ignore next -- the SDK dispatches a live signal, then notifies abort through its listener. */ + if (requestSignal?.aborted === true) onRequestAbort() + try { + let admissionFailure: unknown + const promptSelection = this.modelControl.snapshot() + try { + if (this.ctx.agents.get(this.agent.id) !== this.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const content = await admitAcpPrompt( + this.ctx, + promptSelection, + params.prompt, + imageEnabled, + admissionController.signal, + ) + admissionController.signal.throwIfAborted() + if (this.ctx.agents.get(this.agent.id) !== this.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const message = createUserMessage({ + content, + source: { kind: 'user' }, + }) + inflight.messageId = message.id + inflight.messageQueued = true + if (promptSelection !== undefined) this.pendingSelections.set(message.id, promptSelection) + try { + this.agent.followup(message) + } catch (error: unknown) { + inflight.messageQueued = false + this.pendingSelections.delete(message.id) + throw error + } + } catch (error: unknown) { + admissionFailure = error + } finally { + inflight.finishAdmission() + } + + if (inflight.cancelRequested) { + this.settleAfterQuiescence(inflight) + return { stopReason: await completion.promise } + } + if (admissionFailure !== undefined) { + this.inflight = undefined + if (admissionFailure instanceof AcpContentError) { + throw admissionFailure.kind === 'invalid' + ? invalidParams(admissionFailure.message) + : internalError(admissionFailure.message) + } + if (admissionFailure instanceof RequestError) throw admissionFailure + throw internalError(`prompt was not queued: ${(admissionFailure as Error).message}`) + } + + this.settleAfterQuiescence(inflight) + return { stopReason: await completion.promise } + } finally { + requestSignal?.removeEventListener('abort', onRequestAbort) + } + } + + /** Cancel the active prompt, or autonomous work when no ACP prompt exists. */ + cancel(): void { + const inflight = this.inflight + this.cancelPrompt('ACP prompt cancelled') + if (inflight === undefined) this.agent.cancel({ kind: 'user' }) + } + + /** + * Process one durable event and enqueue its standard ACP projections. + * @param session - exact event-owning Session. + * @param event - committed durable event. + */ + onSessionEvent(session: Session, event: SessionEvent): void { + try { + if (event.type === 'assistant/message') { + const inflight = this.inflight?.turn === event.data.turn ? this.inflight : undefined + const previous = this.outputTail + const delivery = previous.then(async () => { + for (const update of await assistantUpdates(this.ctx, session, event)) { + await this.notify({ sessionId: this.agent.session.id, update }) + } + }) + this.outputTail = delivery.catch((error: unknown) => { + const failure = error as Error + if (inflight !== undefined) inflight.outputError ??= failure + this.ctx.logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`) + }) + } else if (event.type === 'tool/call') { + const previous = this.outputTail + this.outputTail = previous + .then(() => this.notify({ sessionId: this.agent.session.id, update: toolCallUpdate(event) })) + /* v8 ignore start -- the bridge notifier contains transport rejection. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: tool-call update delivery failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + } else if (event.type === 'tool/result') { + const previous = this.outputTail + this.outputTail = previous + .then(async () => this.notify({ + sessionId: this.agent.session.id, + update: await toolResultUpdate(this.ctx, event), + })) + /* v8 ignore start -- supplemental-content conversion failure is contained and cannot fail Agent work. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: tool-result update delivery failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + } + } finally { + const inflight = this.inflight + if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { + inflight.endReason = event.data.reason + } + if (event.type === 'turn/end') this.modelControl.releaseTurn(event.data.turn) + } + } + + /** + * Correlate an accepted user message with its Agent turn and pinned route. + * @param message - claimed durable inbox message. + * @param turn - allocated Agent turn. + */ + onInboxClaimed(message: UserMessage, turn: number): void { + if (this.inflight !== undefined && this.inflight.messageId === message.id) this.inflight.turn = turn + const selection = this.pendingSelections.get(message.id) + this.pendingSelections.delete(message.id) + if (selection !== undefined) this.modelControl.pinTurn(turn, selection) + } + + /** + * Correlate an Agent interval failure with the active ACP prompt. + * @param turn - failed turn number. + * @param error - original same-process failure. + */ + onAgentError(turn: number, error: unknown): void { + const inflight = this.inflight + if (inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return + inflight.agentError = new Error(errorChain(error)) + this.settleAfterQuiescence(inflight) + } + + /** Await every update queued before this call. */ + drainUpdates(): Promise { + return this.outputTail + } + + /** + * Cancel, drain, flush, and dispose this session once. + * @param detail - cancellation detail for any prompt still in admission. + * @returns the shared quiescent teardown promise. + */ + close(detail: string): Promise { + if (this.closing !== undefined) return this.closing + this.closing = (async () => { + const failures: unknown[] = [] + const inflight = this.inflight + this.cancelPrompt(detail) + if (inflight === undefined || !inflight.messageQueued) this.agent.cancel({ kind: 'user' }) + try { + await inflight?.admissionDone + await this.agent.whenIdle() + await this.outputTail + } catch (error: unknown) { + failures.push(new Error('ACP session activity drain failed', { cause: error })) + } + const subagents = this.ctx.get('subagents') as ContinuableDrain | undefined + try { + await subagents?.drainContinuableDescendants([this.agent]) + } catch (error: unknown) { + this.ctx.logger.warn(`acp: continuable subagent teardown failed: ${errorChain(error)}`) + failures.push(new Error('continuable subagent teardown failed', { cause: error })) + } + try { + await this.ctx.sessions.flush(this.agent.session) + } catch (error: unknown) { + failures.push(new Error('ACP session persistence flush failed', { cause: error })) + } + try { + await this.disposeAgent() + } catch (error: unknown) { + failures.push(error) + } + this.pendingSelections.clear() + if (failures.length === 1) throw failures[0] + /* v8 ignore start -- independent teardown failures can aggregate only under multiple simultaneous provider faults. */ + if (failures.length > 1) { + throw new AggregateError(failures, `ACP session teardown failed: ${failures.map(errorChain).join('; ')}`) + } + /* v8 ignore stop */ + })() + return this.closing + } + + private assertActive(): void { + if (this.closing !== undefined) throw invalidParams(`session is closing: ${this.agent.session.id}`) + } + + private cancelPrompt(detail: string): void { + const inflight = this.inflight + if (inflight === undefined) return + inflight.cancelRequested = true + inflight.admissionController.abort(new Error(detail)) + this.settleAfterQuiescence(inflight) + if (inflight.messageQueued) this.agent.cancel({ kind: 'user' }) + } + + private settleAfterQuiescence(inflight: InflightPrompt): void { + if (inflight.settlementStarted) return + inflight.settlementStarted = true + void (async () => { + await inflight.admissionDone + if (inflight.messageQueued) { + await this.agent.whenIdle() + await this.outputTail + } + /* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */ + if (this.inflight !== inflight) return + this.inflight = undefined + if (inflight.cancelRequested) { + inflight.resolve('cancelled') + return + } + if (inflight.outputError !== undefined) { + inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`)) + return + } + if (inflight.agentError !== undefined) { + inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`)) + return + } + const end = inflight.endReason + if (end === undefined) { + inflight.resolve('cancelled') + } else if (end.kind === 'error') { + inflight.reject(internalError(`turn failed: ${end.error.message}`)) + } else { + inflight.resolve(turnEndToStopReason(end)) + } + })() + /* v8 ignore start -- admissionDone only resolves; idle/output gates contain their own failures. */ + .catch((error: unknown) => { + if (this.inflight !== inflight) return + this.inflight = undefined + inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`)) + }) + /* v8 ignore stop */ + } +} diff --git a/packages/acp/acp/src/updates.ts b/packages/acp/acp/src/updates.ts new file mode 100644 index 0000000000..09687078f1 --- /dev/null +++ b/packages/acp/acp/src/updates.ts @@ -0,0 +1,111 @@ +/** Standard ACP updates derived from committed DSH session events. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionUpdate, ToolCallContent } from '@agentclientprotocol/sdk' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-token-meter' +import { assistantBlockToAcp } from './content.ts' + +/** + * Convert one committed assistant message and its context usage in block order. + * @param ctx - bridge context carrying attachment and token-meter services. + * @param session - durable session used for context pressure. + * @param event - committed assistant message event. + * @returns ordered standard thought, message, and optional usage updates. + */ +export async function assistantUpdates( + ctx: Context, + session: Session, + event: SessionEvent<'assistant/message'>, +): Promise { + const updates: SessionUpdate[] = [] + for (const block of event.data.message.content) { + if (block.type === 'reasoning') { + if (block.text.length > 0) { + updates.push({ + sessionUpdate: 'agent_thought_chunk', + messageId: event.data.message.id, + content: { type: 'text', text: block.text }, + }) + } + continue + } + const content = await assistantBlockToAcp(ctx, block) + if (content !== undefined) { + updates.push({ + sessionUpdate: 'agent_message_chunk', + messageId: event.data.message.id, + content, + }) + } + } + const usage = usageUpdate(ctx, session, event) + if (usage !== undefined) updates.push(usage) + return updates +} + +/** + * Start one generic ACP tool lifecycle from the durable call fact. + * @param event - committed DSH tool-call event. + * @returns the standard generic tool-call update. + */ +export function toolCallUpdate(event: SessionEvent<'tool/call'>): SessionUpdate { + return { + sessionUpdate: 'tool_call', + toolCallId: event.data.callId, + title: event.data.name, + kind: 'other', + status: 'in_progress', + rawInput: parseToolArguments(event.data.arguments), + } +} + +/** + * Finish one generic ACP tool lifecycle from its committed model-facing result. + * @param ctx - bridge context carrying the attachment store. + * @param event - committed DSH tool-result event. + * @returns the standard completed or failed tool-call update. + */ +export async function toolResultUpdate( + ctx: Context, + event: SessionEvent<'tool/result'>, +): Promise { + const result = event.data.message.content[0] + const content: ToolCallContent[] = [] + for (const block of result.content) { + const converted = await assistantBlockToAcp(ctx, block) + if (converted !== undefined) content.push({ type: 'content' as const, content: converted }) + } + return { + sessionUpdate: 'tool_call_update', + toolCallId: result.toolCallId, + status: result.isError === true ? 'failed' : 'completed', + content, + } +} + +/** Report current context occupancy only when DSH has both usage and capacity facts. */ +function usageUpdate( + ctx: Context, + session: Session, + event: SessionEvent<'assistant/message'>, +): SessionUpdate | undefined { + if (event.data.usage === undefined) return undefined + const size = session.requestContext()?.contextWindow + const meter = ctx.get('tokenMeter') + if (size === undefined || meter === undefined) return undefined + return { + sessionUpdate: 'usage_update', + used: meter.measure(session).totalTokens, + size, + } +} + +/** Preserve malformed model output as opaque input instead of dropping the call update. */ +function parseToolArguments(value: string): unknown { + try { + return JSON.parse(value) as unknown + } catch (_invalidModelJson) { + return value + } +} diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index ea1ec994a4..9cd522bb92 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -21,12 +21,20 @@ describe('ACP machine permission policy', () => { const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const agent = harness.ctx.agents.get(SessionId(sessionId))! agent.session.append('turn/start', { turn: 1 }) + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('tool/call', { turn: 1, step: 1, callId: CallId('call-9'), name: 'bash', arguments: '{}' }) return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides } } it('maps the two advertised one-shot choices', async () => { harness = await makeBridgeHarness() - harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + harness.onPermission = () => { + expect(harness?.sessionUpdates.at(-1)?.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'call-9', + }) + return { outcome: { outcome: 'selected', optionId: 'allow-once' } } + } const request = await ownedRequest() await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once') expect(harness.permissionRequests[0]).toMatchObject({ diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 2823f717db..e9efddddbf 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -1,8 +1,28 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' +import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture.ts' + +function oneToolCall(): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('call-switch'), name: 'switch_model', argumentsDelta: '{}' }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: CallId('call-switch'), name: 'switch_model', arguments: '{}' }, + }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} describe('automation-only ACP bridge', () => { let harness: BridgeHarness | undefined @@ -12,7 +32,7 @@ describe('automation-only ACP bridge', () => { harness = undefined }) - it('advertises only fresh text sessions', async () => { + it('advertises the standard automation controls without private metadata', async () => { harness = await makeBridgeHarness() const response = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -23,7 +43,9 @@ describe('automation-only ACP bridge', () => { protocolVersion: PROTOCOL_VERSION, agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, agentCapabilities: { + mcpCapabilities: { http: true }, promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, }, authMethods: [], }) @@ -57,15 +79,686 @@ describe('automation-only ACP bridge', () => { }) expect(result.stopReason).toBe('end_turn') - await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) }) - expect(harness.updates).toEqual([{ + await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') }) + expect(harness.updates[0]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello there' }, - }]) + }) + expect('messageId' in harness.updates[0]!).toBe(true) + if ('messageId' in harness.updates[0]!) expect(typeof harness.updates[0].messageId).toBe('string') expect(harness.ctx.agents.get(SessionId(sessionId))?.session.header.cwd).toBe(process.cwd()) expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'say hello' }]) }) + it('closes one active session without affecting its neighbor', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.client.closeSession({ sessionId: first.sessionId }) + + expect(harness.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(second.sessionId))).toBeDefined() + await expect(harness.client.prompt({ + sessionId: first.sessionId, + prompt: [{ type: 'text', text: 'closed' }], + })).rejects.toThrow(/unknown session/) + }) + + it('cancels a running prompt and makes its session resumable before close returns', async () => { + harness = await makeBridgeHarness({ script: ['hang', textResponse('resumed')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const prompt = harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'hang' }] }) + await vi.waitFor(() => { + expect(harness!.ctx.agents.get(SessionId(created.sessionId))?.status).toBe('running') + }) + + await harness.client.closeSession({ sessionId: created.sessionId }) + + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.listSessions({})).resolves.toMatchObject({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [] }) + }) + + it('shares one close operation and rejects new work while close is draining', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const flushing: PromiseWithResolvers = Promise.withResolvers() + const flush = vi.spyOn(harness.ctx.sessions, 'flush').mockImplementationOnce(() => flushing.promise.then(() => true)) + + const first = harness.client.closeSession({ sessionId: created.sessionId }) + await vi.waitFor(() => { expect(flush).toHaveBeenCalled() }) + const second = harness.client.closeSession({ sessionId: created.sessionId }) + harness.registerCatalogProvider('closing-topology') + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'too late' }], + })).rejects.toThrow(/session is closing/) + flushing.resolve() + + await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]) + }) + + it('disposes the Agent and reports an explicit close drain failure', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(created.sessionId))! + vi.spyOn(agent, 'whenIdle').mockRejectedValueOnce(new Error('idle probe failed')) + + await expect(harness.client.closeSession({ sessionId: created.sessionId })).rejects.toThrow(/session close failed/) + + expect(harness.ctx.agents.get(SessionId(created.sessionId))).toBeUndefined() + }) + + it('resumes a closed persisted session without replaying its history', async () => { + harness = await makeBridgeHarness({ script: [textResponse('first answer'), textResponse('second answer')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first prompt' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const updatesBeforeResume = harness.updates.length + + const resumed = await harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [], + }) + expect(Array.isArray(resumed.configOptions)).toBe(true) + expect(harness.updates).toHaveLength(updatesBeforeResume) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second prompt' }] }) + + expect(harness.adapter.requests[1]?.messages.map(message => message.content)).toContainEqual([ + { type: 'text', text: 'first prompt' }, + ]) + }) + + it('materializes an empty closed session for list and resume', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.client.closeSession({ sessionId: created.sessionId }) + + await expect(harness.client.listSessions({})).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .resolves.toHaveProperty('configOptions') + }) + + it('rejects active or wrong-workspace resume before composing another Agent', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [], + })).rejects.toThrow(/already active/) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const resume = vi.spyOn(harness.ctx.agents, 'resume') + + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: tmpdir(), + mcpServers: [], + })).rejects.toThrow(/cwd does not match/) + expect(resume).not.toHaveBeenCalled() + + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: `${process.cwd()}/packages/..`, + mcpServers: [], + })).resolves.toHaveProperty('configOptions') + }) + + it('reserves a persisted id across concurrent resume admission', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const resume = harness.ctx.agents.resume.bind(harness.ctx.agents) + const entered: PromiseWithResolvers = Promise.withResolvers() + const release: PromiseWithResolvers = Promise.withResolvers() + vi.spyOn(harness.ctx.agents, 'resume').mockImplementationOnce(async (options) => { + entered.resolve() + await release.promise + return resume(options) + }) + + const first = harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + await entered.promise + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .rejects.toThrow(/already active/) + await expect(harness.client.listSessions({})).resolves.toEqual({ sessions: [] }) + release.resolve() + + await expect(first).resolves.toHaveProperty('configOptions') + }) + + it('rejects unknown resume ids and rolls back invalid resume MCP', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(harness.client.resumeSession({ + sessionId: 'missing', + cwd: process.cwd(), + })).rejects.toThrow(/not resumable/) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const duplicate = { name: 'same', command: process.execPath, args: [], env: [] } + + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [duplicate, duplicate], + })).rejects.toThrow(/duplicate normalized name/) + expect(harness.ctx.agents.list()).toHaveLength(0) + }) + + it('restores the deployment selection when persisted events have no request header', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(created.sessionId))! + agent.session.append('session/title', { title: 'materialized', messageSeqs: [], source: { kind: 'fallback' } }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + + expect(resumed.configOptions?.find(option => option.id === 'model')).toMatchObject({ + currentValue: '["mock","mock"]', + }) + }) + + it('restores an explicitly selected reasoning effort', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: 'low', + }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + + expect(resumed.configOptions?.find(option => option.id === 'reasoning_effort')).toMatchObject({ + currentValue: 'low', + }) + }) + + it('lists closed persisted sessions without presentation metadata', async () => { + harness = await makeBridgeHarness({ script: [textResponse('answer')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist me' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + await expect(harness.client.listSessions({})).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + }) + + it('paginates resumable sessions with an opaque deterministic cursor', async () => { + harness = await makeBridgeHarness({ + config: { sessionListPageSize: 1 }, + script: [textResponse('first'), textResponse('second')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: first.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.closeSession({ sessionId: first.sessionId }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: second.sessionId, prompt: [{ type: 'text', text: 'second' }] }) + await harness.client.closeSession({ sessionId: second.sessionId }) + + const firstPage = await harness.client.listSessions({}) + expect(firstPage.sessions).toHaveLength(1) + expect(firstPage.nextCursor).toEqual(expect.any(String)) + if (typeof firstPage.nextCursor !== 'string') throw new Error('expected a pagination cursor') + const secondPage = await harness.client.listSessions({ cursor: firstPage.nextCursor }) + expect(secondPage.sessions).toHaveLength(1) + expect(secondPage.nextCursor).toBeUndefined() + expect(new Set([...firstPage.sessions, ...secondPage.sessions].map(item => item.sessionId))) + .toEqual(new Set([first.sessionId, second.sessionId])) + await expect(harness.client.listSessions({ cursor: 'not-a-cursor' })).rejects.toThrow(/cursor is invalid/) + }) + + it('filters non-resumable headers and canonical missing workspaces', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const active = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const persistence = harness.ctx.get('sessionPersistence')! + vi.spyOn(persistence, 'list').mockResolvedValue([ + { version: 0, id: SessionId(active.sessionId), createdAt: 9, cwd: process.cwd() }, + { version: 0, id: SessionId('subagent'), createdAt: 8, cwd: '/missing/filter', origin: 'subagent' }, + { version: 0, id: SessionId('fork'), createdAt: 7, cwd: '/missing/filter', parentSession: SessionId('parent') }, + { version: 0, id: SessionId('no-cwd'), createdAt: 6 }, + { version: 0, id: SessionId('relative'), createdAt: 5, cwd: 'relative' }, + { version: 0, id: SessionId('other'), createdAt: 4, cwd: '/missing/other' }, + { version: 0, id: SessionId('valid-b'), createdAt: 3, cwd: '/missing/filter' }, + { version: 0, id: SessionId('valid-a'), createdAt: 3, cwd: '/missing/filter' }, + ]) + + await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow(/absolute path/) + await expect(harness.client.listSessions({ cwd: '/missing/filter' })).resolves.toEqual({ + sessions: [ + { sessionId: 'valid-a', cwd: '/missing/filter' }, + { sessionId: 'valid-b', cwd: '/missing/filter' }, + ], + }) + await expect(harness.client.resumeSession({ + sessionId: 'no-cwd', + cwd: '/missing/filter', + })).rejects.toThrow(/cwd does not match/) + }) + + it.each([ + [null], + [[]], + [['not-a-number', 'id']], + [[-1, 'id']], + [[1, '']], + ] as const)('rejects malformed decoded list cursors %#', async (decoded) => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const cursor = Buffer.from(JSON.stringify(decoded)).toString('base64url') + await expect(harness.client.listSessions({ cursor })).rejects.toThrow(/cursor is invalid/) + }) + + it('rejects invalid and non-canonical cursor encodings', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(harness.client.listSessions({ cursor: '*' })).rejects.toThrow(/cursor is invalid/) + const bytes = Buffer.from(JSON.stringify([1, 'id'])) + const canonical = bytes.toString('base64url') + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + const nonCanonical = alphabet.split('') + .map(char => canonical.slice(0, -1) + char) + .find(candidate => candidate !== canonical && Buffer.from(candidate, 'base64url').equals(bytes)) + if (nonCanonical === undefined) throw new Error('expected an alternate base64url spelling') + + await expect(harness.client.listSessions({ cursor: nonCanonical })).rejects.toThrow(/cursor is invalid/) + }) + + it('rolls back new and resume when configuration discovery fails', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const resolve = vi.spyOn(harness.ctx.llm, 'resolveCallConfig') + resolve.mockRejectedValueOnce(new Error('catalog resolution failed')) + await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })) + .rejects.toThrow(/Internal error/) + expect(harness.ctx.agents.list()).toHaveLength(0) + + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + resolve.mockRejectedValueOnce(new Error('resume catalog failed')) + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .rejects.toThrow(/Internal error/) + expect(harness.ctx.agents.list()).toHaveLength(0) + }) + + it('propagates non-MCP Agent factory failures and non-config selection failures', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const create = vi.spyOn(harness.ctx.agents, 'create') + create.mockRejectedValueOnce(new Error('factory create failed')) + await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })) + .rejects.toThrow(/Internal error/) + + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected model options') + const plain = model.options.flatMap(option => 'group' in option ? option.options : [option]) + .find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected plain model') + const resolution = vi.spyOn(harness.ctx.llm, 'resolveCallConfig').mockRejectedValue(new Error('selection failed')) + await expect(harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + })).rejects.toThrow(/Internal error/) + resolution.mockRestore() + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + vi.spyOn(harness.ctx.agents, 'resume').mockRejectedValueOnce(new Error('factory resume failed')) + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .rejects.toThrow(/Internal error/) + }) + + it('lists and resumes persisted sessions after an equivalent process restart', async () => { + const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-acp-restart-')) + try { + harness = await makeBridgeHarness({ persistenceRoot, script: [textResponse('before restart')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + await harness.dispose() + + harness = await makeBridgeHarness({ persistenceRoot, script: [textResponse('after restart')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(harness.client.listSessions({})).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [] }) + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'second' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + } finally { + await harness?.dispose() + harness = undefined + await rm(persistenceRoot, { recursive: true, force: true }) + } + }) + + it('discovers and selects a session model through standard config options', async () => { + harness = await makeBridgeHarness({ script: [textResponse('plain answer')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model select option') + const choices = model.options.flatMap(option => 'group' in option ? option.options : [option]) + const plain = choices.find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected Mock Plain in the model catalog') + + const selected = await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }) + expect(selected.configOptions.find(option => option.id === 'reasoning_effort')).toBeUndefined() + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use plain' }] }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' }) + }) + + it('publishes complete config options when adapter topology changes', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + harness.registerCatalogProvider('other') + + await vi.waitFor(() => { + const update = harness!.updates.find(item => item.sessionUpdate === 'config_option_update') + expect(update).toBeDefined() + if (update?.sessionUpdate !== 'config_option_update') return + const model = update.configOptions.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model select option') + expect(model.options.some(option => 'group' in option && option.group === 'other')).toBe(true) + }) + expect(harness.sessionUpdates.at(-1)?.sessionId).toBe(created.sessionId) + }) + + it('publishes recoverable options when the selected adapter disappears', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + harness.registerCatalogProvider('other') + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'config_option_update')).toBe(true) + }) + + harness.replacePrimaryProviders([]) + expect(harness.ctx.llm.listProviders().map(provider => provider.id)).toEqual(['other']) + + await vi.waitFor(() => { + const configUpdates = harness!.updates.filter(item => item.sessionUpdate === 'config_option_update') + expect(configUpdates).toHaveLength(2) + const update = configUpdates.at(-1) + if (update?.sessionUpdate !== 'config_option_update') throw new Error('expected config update') + const model = update.configOptions.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected model options') + const groups = model.options.filter(option => 'group' in option) + expect(groups.map(group => group.group)).toEqual(['other', 'mock']) + expect(model.currentValue).toBe('["mock","mock"]') + }) + expect(harness.sessionUpdates.at(-1)?.sessionId).toBe(created.sessionId) + }) + + it('selects an advertised reasoning effort for the next turn', async () => { + harness = await makeBridgeHarness({ script: [textResponse('reasoned')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const reasoning = created.configOptions?.find(option => option.id === 'reasoning_effort') + if (reasoning?.type !== 'select') throw new Error('expected a reasoning select option') + const low = reasoning.options.find(option => !('group' in option) && option.name === 'Low') + if (low === undefined || 'group' in low) throw new Error('expected Low reasoning effort') + + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: low.value, + }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'reason' }] }) + + expect(harness.adapter.requests[0]?.reasoningEffort).toBe('low') + }) + + it('rejects unknown config choices without changing the selected route', async () => { + harness = await makeBridgeHarness({ script: [textResponse('unchanged')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: 'not-advertised', + })).rejects.toThrow(/unknown model option/) + await expect(harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'private_option', + value: 'anything', + })).rejects.toThrow(/unknown session config option/) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'go' }] }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + }) + + it('serializes concurrent standard config changes in receive order', async () => { + harness = await makeBridgeHarness({ script: [textResponse('plain')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + const reasoning = created.configOptions?.find(option => option.id === 'reasoning_effort') + if (model?.type !== 'select' || reasoning?.type !== 'select') throw new Error('expected model and reasoning options') + const plain = model.options.flatMap(option => 'group' in option ? option.options : [option]) + .find(option => option.name === 'Mock Plain') + const low = reasoning.options.find(option => !('group' in option) && option.name === 'Low') + if (plain === undefined || low === undefined || 'group' in low) throw new Error('expected selectable values') + + await Promise.all([ + harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: low.value, + }), + harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }), + ]) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'go' }] }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' }) + expect(harness.adapter.requests[0]?.reasoningEffort).toBeUndefined() + }) + + it('pins image admission and request routing to one prompt selection', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('image accepted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model option') + const plain = model.options.flatMap(option => 'group' in option ? option.options : [option]) + .find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected Mock Plain') + const validationStarted = Promise.withResolvers() + const releaseValidation = Promise.withResolvers() + harness.attachments!.beforeValidate = () => { + validationStarted.resolve(undefined) + return releaseValidation.promise + } + + const prompt = harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + }) + await validationStarted.promise + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }) + releaseValidation.resolve(undefined) + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + harness.attachments!.beforeValidate = undefined + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'image', data: 'Ag==', mimeType: 'image/png' }], + })).rejects.toThrow(/does not declare image input/) + }) + + it('applies a mid-turn model change to the following turn', async () => { + harness = await makeBridgeHarness({ script: [oneToolCall(), textResponse('first turn'), textResponse('second turn')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model select option') + const choices = model.options.flatMap(option => 'group' in option ? option.options : [option]) + const plain = choices.find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected Mock Plain in the model catalog') + harness.ctx.tools.register(defineContentToolFixture({ + name: 'switch_model', + description: 'Switch the following turn to the plain model.', + parameters: {}, + execute: async () => { + await harness!.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }) + return [{ type: 'text', text: 'selected' }] + }, + })) + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] }) + + expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'mock', 'plain']) + }) + + it('mounts a standard stdio MCP server inside the created session', async () => { + harness = await makeBridgeHarness({ script: [textResponse('used MCP')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + const created = await harness.client.newSession({ + cwd: process.cwd(), + mcpServers: [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }], + }) + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use MCP' }] }) + + expect(harness.adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('mcp__fixture__add') + await harness.client.closeSession({ sessionId: created.sessionId }) + }, 30_000) + + it('mounts a standard Streamable HTTP MCP server with request headers', async () => { + const fixture = await startHttpMcpFixture() + try { + harness = await makeBridgeHarness({ script: [textResponse('used HTTP MCP')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ + cwd: process.cwd(), + mcpServers: [{ + type: 'http', + name: 'web', + url: fixture.url, + headers: [{ name: 'Authorization', value: 'Bearer acp-test' }], + }], + }) + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use HTTP MCP' }] }) + + expect(harness.adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('mcp__web__ping') + expect(fixture.authorization).toContain('Bearer acp-test') + await harness.client.closeSession({ sessionId: created.sessionId }) + } finally { + await fixture.close() + } + }, 30_000) + + it('allows the same MCP server namespace in independent sessions', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + const mcpServers = [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }] + + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers }) + + await Promise.all([ + harness.client.closeSession({ sessionId: first.sessionId }), + harness.client.closeSession({ sessionId: second.sessionId }), + ]) + }, 30_000) + + it('validates standard MCP declarations before publishing an Agent', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const stdio = { name: 'fixture', command: process.execPath, args: [], env: [] } + const invalidLists = [ + [stdio, stdio], + [{ ...stdio, name: ' ' }], + [{ ...stdio, command: 'node' }], + [{ ...stdio, env: [{ name: 'BAD=NAME', value: 'x' }] }], + [{ type: 'http' as const, name: 'web', url: 'file:///tmp/mcp', headers: [] }], + [{ type: 'http' as const, name: 'web', url: 'https://example.test/mcp', headers: [{ name: 'bad header', value: 'x' }] }], + [{ type: 'sse' as const, name: 'legacy', url: 'https://example.test/sse', headers: [] }], + [{ type: 'acp' as const, name: 'nested', serverId: 'server-1' }], + ] + for (const mcpServers of invalidLists) { + await expect(harness.client.newSession({ + cwd: process.cwd(), + mcpServers, + })).rejects.toThrow(/mcpServers/) + expect(harness.ctx.agents.list()).toHaveLength(0) + } + }) + + it('reconnects requested MCP servers when resuming a closed session', async () => { + harness = await makeBridgeHarness({ script: [textResponse('first'), textResponse('second')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + const mcpServers = [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }] + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] }) + + expect(harness.adapter.requests[1]?.tools?.map(tool => tool.name)).toContain('mcp__fixture__add') + }, 30_000) + it('leaves absent agent targets for request listeners to supply', async () => { harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -74,6 +767,27 @@ describe('automation-only ACP bridge', () => { expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({}) }) + it('allows request listeners to supply a route when ACP has no initial selection', async () => { + harness = await makeBridgeHarness({ + config: { provider: undefined, model: undefined }, + script: [textResponse('listener-routed')], + }) + harness.ctx.on('agent/request', async (_payload, next) => ({ + ...await next(), + provider: 'mock', + model: 'mock', + })) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(created.configOptions).toEqual([]) + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'route me' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + }) + it('concatenates text blocks without exposing protocol framing to the model', async () => { harness = await makeBridgeHarness({ script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -164,7 +878,7 @@ describe('automation-only ACP bridge', () => { expect(harness.adapter.requests[0]?.system).toContain(`Automation persona for mock in ${process.cwd()}.`) }) - it('requires one absolute workspace and no MCP servers', async () => { + it('requires one absolute primary workspace', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -174,11 +888,6 @@ describe('automation-only ACP bridge', () => { mcpServers: [], additionalDirectories: ['/tmp/other'], })).rejects.toThrow(/additionalDirectories/) - await expect(harness.client.newSession({ - cwd: process.cwd(), - mcpServers: [{ name: 'fs', command: 'node', args: [], env: [] }], - })).rejects.toThrow(/mcpServers/) - await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [], diff --git a/packages/acp/acp/tests/content.spec.ts b/packages/acp/acp/tests/content.spec.ts index a22dbe9069..144559b6e0 100644 --- a/packages/acp/acp/tests/content.spec.ts +++ b/packages/acp/acp/tests/content.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' import { AcpContentError, admitAcpPrompt, @@ -20,7 +20,7 @@ const REF: ImageAttachmentRef = { interface AdmissionFixture { ctx: Context - agent: Agent + route: ModelSelection | undefined saveImages: ReturnType Promise>> resolveModelInfo: ReturnType } @@ -30,7 +30,6 @@ function admissionFixture(options: { llm?: boolean provider?: string | undefined model?: string | undefined - header?: { provider?: string; model?: string } } = {}): AdmissionFixture { const saveImages = vi.fn(async (inputs: readonly SaveImageAttachment[]) => inputs.map((input, index) => ({ ...REF, @@ -55,11 +54,8 @@ function admissionFixture(options: { } as unknown as Context const provider = 'provider' in options ? options.provider : 'mock' const model = 'model' in options ? options.model : 'vision' - const agent = { - options: { provider, model }, - session: { requestHeader: () => options.header === undefined ? undefined : { config: options.header } }, - } as unknown as Agent - return { ctx, agent, saveImages, resolveModelInfo } + const route = provider === undefined || model === undefined ? undefined : { provider, model } + return { ctx, route, saveImages, resolveModelInfo } } describe('ACP rich content codec', () => { @@ -93,19 +89,19 @@ describe('ACP rich content codec', () => { const fixture = admissionFixture() const signal = new AbortController().signal - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'AQ==', mimeType: 'image/tiff' }, ] as never, true, signal)).rejects.toThrow(/mimeType/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'not base64', mimeType: 'image/png' }, ], true, signal)).rejects.toThrow(/canonical base64/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'AB==', mimeType: 'image/png' }, ], true, signal)).rejects.toThrow(/canonical base64/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'audio', data: 'AQ==', mimeType: 'audio/wav' }, ], true, signal)).rejects.toThrow(/audio prompt/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'resource', resource: { uri: 'file:///tmp/a', text: 'a' } }, ], true, signal)).rejects.toThrow(/embedded resource/) expect(fixture.saveImages).not.toHaveBeenCalled() @@ -114,41 +110,41 @@ describe('ACP rich content codec', () => { it('requires the advertised capability, store, and exact image-capable route', async () => { const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const const capable = admissionFixture() - await expect(admitAcpPrompt(capable.ctx, capable.agent, prompt, false, new AbortController().signal)) + await expect(admitAcpPrompt(capable.ctx, capable.route, prompt, false, new AbortController().signal)) .rejects.toThrow(/not advertised/) const noStore = admissionFixture({ attachments: false }) - await expect(admitAcpPrompt(noStore.ctx, noStore.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noStore.ctx, noStore.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/no attachment store/) const noProvider = admissionFixture({ provider: undefined }) - await expect(admitAcpPrompt(noProvider.ctx, noProvider.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noProvider.ctx, noProvider.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/route could not be resolved/) const noModel = admissionFixture({ model: undefined }) - await expect(admitAcpPrompt(noModel.ctx, noModel.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noModel.ctx, noModel.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/route could not be resolved/) const noLlm = admissionFixture({ llm: false }) - await expect(admitAcpPrompt(noLlm.ctx, noLlm.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noLlm.ctx, noLlm.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/route could not be resolved/) const broken = admissionFixture() broken.resolveModelInfo.mockRejectedValueOnce(new Error('catalog down')) - const routeFailure = admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal) + const routeFailure = admitAcpPrompt(broken.ctx, broken.route, prompt, true, new AbortController().signal) await expect(routeFailure).rejects.toMatchObject({ kind: 'internal' }) await expect(routeFailure).rejects.toThrow(/route could not be verified/) const unknown = admissionFixture() unknown.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision' }) - await expect(admitAcpPrompt(unknown.ctx, unknown.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(unknown.ctx, unknown.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/does not declare image input/) const textOnly = admissionFixture() textOnly.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision', inputModalities: ['text'], }) - await expect(admitAcpPrompt(textOnly.ctx, textOnly.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(textOnly.ctx, textOnly.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/does not declare image input/) - const routed = admissionFixture({ provider: 'fallback', model: 'fallback', header: { provider: 'live', model: 'vision-2' } }) - await expect(admitAcpPrompt(routed.ctx, routed.agent, prompt, true, new AbortController().signal)).resolves.toHaveLength(1) + const routed = admissionFixture({ provider: 'live', model: 'vision-2' }) + await expect(admitAcpPrompt(routed.ctx, routed.route, prompt, true, new AbortController().signal)).resolves.toHaveLength(1) expect(routed.resolveModelInfo).toHaveBeenCalledWith('live', 'vision-2', expect.any(AbortSignal)) }) @@ -156,16 +152,16 @@ describe('ACP rich content codec', () => { const fixture = admissionFixture() const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const fixture.saveImages.mockRejectedValueOnce(new AttachmentError('too many', 'TOO_MANY_IMAGES')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'invalid', message: 'too many' }) fixture.saveImages.mockRejectedValueOnce(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) fixture.saveImages.mockRejectedValueOnce(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) fixture.saveImages.mockRejectedValueOnce(new Error('unknown store failure')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toBeInstanceOf(AcpContentError) }) @@ -174,7 +170,7 @@ describe('ACP rich content codec', () => { const before = admissionFixture() const beforeController = new AbortController() beforeController.abort(new Error('cancel before write')) - await expect(admitAcpPrompt(before.ctx, before.agent, prompt, true, beforeController.signal)) + await expect(admitAcpPrompt(before.ctx, before.route, prompt, true, beforeController.signal)) .rejects.toThrow('cancel before write') expect(before.saveImages).not.toHaveBeenCalled() @@ -184,19 +180,19 @@ describe('ACP rich content codec', () => { afterController.abort(new Error('cancel after write')) return [REF] }) - await expect(admitAcpPrompt(after.ctx, after.agent, prompt, true, afterController.signal)) + await expect(admitAcpPrompt(after.ctx, after.route, prompt, true, afterController.signal)) .rejects.toThrow('cancel after write') expect(after.saveImages).toHaveBeenCalledOnce() }) it('reconstructs image-only and baseline prompts without empty text blocks', async () => { const fixture = admissionFixture() - const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.agent, [ + const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'AQ==', mimeType: 'image/png' }, ], true, new AbortController().signal) expect(imageOnly).toHaveLength(1) expect(imageOnly[0]?.type).toBe('image') - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'text', text: 'before' }, { type: 'resource_link', name: 'Guide', uri: 'https://example.test/guide' }, { type: 'text', text: 'after' }, @@ -204,7 +200,7 @@ describe('ACP rich content codec', () => { type: 'text', text: 'before\n[resource_link name="Guide" uri="https://example.test/guide"]\nafter', }]) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'text', text: ' \n ' }, ], true, new AbortController().signal)).rejects.toThrow(/empty prompt/) }) diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 84bbff3b3d..8cd4599e94 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -7,9 +7,13 @@ import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.t function toolCallResponse(): StreamChunk[] { return [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } }, + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'inspect first' }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'inspect first' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 8, outputTokens: 2, reasoningTokens: 1 } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] } @@ -22,7 +26,7 @@ describe('ACP automation output boundary', () => { harness = undefined }) - it('does not emit tool, terminal, plan, title, or reasoning presentation updates', async () => { + it('emits committed reasoning, generic tool lifecycle, usage, and final text in order', async () => { harness = await makeBridgeHarness({ script: [toolCallResponse(), textResponse('done')] }) harness.ctx.tools.register(defineContentToolFixture({ name: 'echo', @@ -34,11 +38,45 @@ describe('ACP automation output boundary', () => { const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) }) - expect(harness.updates).toEqual([{ + await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') }) + expect(harness.updates.map(update => update.sessionUpdate)).toEqual([ + 'agent_thought_chunk', + 'usage_update', + 'tool_call', + 'tool_call_update', + 'agent_message_chunk', + 'usage_update', + ]) + expect(harness.updates[0]).toMatchObject({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'inspect first' }, + }) + expect('messageId' in harness.updates[0]!).toBe(true) + expect(harness.updates[2]).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'echo', + kind: 'other', + status: 'in_progress', + rawInput: {}, + }) + expect(harness.updates[3]).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'tool result' } }], + }) + expect(harness.updates[4]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done' }, - }]) + }) + expect('messageId' in harness.updates[4]!).toBe(true) + expect(harness.updates[5]).toMatchObject({ + sessionUpdate: 'usage_update', + size: 1_024, + }) + if (harness.updates[5]?.sessionUpdate !== 'usage_update') throw new Error('expected usage update') + expect(typeof harness.updates[5].used).toBe('number') }) it('ignores events from agents the bridge does not own', async () => { @@ -62,11 +100,13 @@ describe('ACP automation output boundary', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() + await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') }) - expect(harness.updates).toEqual([{ + expect(harness.updates[0]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'external' }, - }]) + }) + expect('messageId' in harness.updates[0]!).toBe(true) }) it('contains output conversion failure outside an ACP prompt', async () => { diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ce6e93794f..70e31cbbe4 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -2,21 +2,29 @@ import { Context } from '@deepseek-ai/cordis' import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { - ClientSideConnection, + client as createAcpClientApp, + methods, ndJsonStream, type Agent as AcpAgent, - type Client, + type PromptRequest, + type PromptResponse, type RequestPermissionRequest, type RequestPermissionResponse, + type SendRequestOptions, type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' -import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as AcpPlugin from '../src/index.ts' import type { AcpConfig } from '../src/index.ts' @@ -27,22 +35,32 @@ class MockAdapter extends LlmAdapter { constructor( private readonly script: (StreamChunk[] | 'hang')[], private readonly imageCapable: boolean, + private readonly provider = 'mock', ) { super() } override providerInfo(provider: string) { - if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`) - return { id: 'mock', name: 'Mock' } + if (provider !== this.provider) throw new Error(`MockAdapter: unknown provider ${provider}`) + return { id: this.provider, name: this.provider === 'mock' ? 'Mock' : `Mock ${this.provider}` } } override listModels(provider: string) { - return Promise.resolve(provider === 'mock' ? [{ - provider: 'mock', - id: 'mock', - name: 'Mock', - inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const, - }] : []) + return Promise.resolve(provider === this.provider ? [ + { + provider: this.provider, + id: 'mock', + name: 'Mock Reasoner', + description: 'Mock model with selectable reasoning.', + inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const, + }, + { + provider: this.provider, + id: 'plain', + name: 'Mock Plain', + inputModalities: ['text'] as const, + }, + ] : []) } override resolveModel(provider: string, model: string): Promise { @@ -50,7 +68,17 @@ class MockAdapter extends LlmAdapter { provider, id: model, name: model, - inputModalities: this.imageCapable ? ['text', 'image'] : ['text'], + inputModalities: this.imageCapable && model === 'mock' ? ['text', 'image'] : ['text'], + context: { contextWindow: 1_024 }, + ...model === 'mock' ? { + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + } : {}, }) } @@ -153,16 +181,32 @@ export function errorResponse(message: string): StreamChunk[] { export type CapturedUpdate = SessionNotification['update'] +/** Stable-v1 client methods exercised by the bridge tests. */ +interface BridgeClient { + initialize: NonNullable + authenticate: NonNullable + newSession: NonNullable + listSessions: NonNullable + resumeSession: NonNullable + closeSession: NonNullable + setSessionConfigOption: NonNullable + prompt: (params: PromptRequest, options?: SendRequestOptions) => Promise + cancel: NonNullable +} + export interface BridgeHarness { ctx: Context - client: ClientSideConnection + client: BridgeClient adapter: MockAdapter attachments: MemoryAttachmentStore | undefined updates: CapturedUpdate[] sessionUpdates: { sessionId: string; update: CapturedUpdate }[] permissionRequests: RequestPermissionRequest[] + persistenceRoot: string onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse onSessionUpdateError: (() => void) | undefined + registerCatalogProvider: (provider: string) => () => void + replacePrimaryProviders: (providers: string[]) => void closeClientTransport: () => Promise abortClientTransport: () => Promise acpFiber: Awaited> @@ -180,13 +224,18 @@ export async function makeBridgeHarness(options: { persona?: string imageCapable?: boolean attachments?: boolean + persistenceRoot?: string } = {}): Promise { const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true) const ctx = new Context() + const ownsPersistenceRoot = options.persistenceRoot === undefined + const persistenceRoot = options.persistenceRoot ?? await mkdtemp(join(tmpdir(), 'dsh-acp-test-')) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) + await ctx.plugin(TokenMeter) if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + const primaryAdapter = ctx.llm.registerAdapter(['mock'], adapter) const agentToClient = new TransformStream() const clientToAgent = new TransformStream() @@ -207,28 +256,33 @@ export async function makeBridgeHarness(options: { updates, sessionUpdates, permissionRequests, + persistenceRoot, onPermission: () => ({ outcome: { outcome: 'cancelled' } }), onSessionUpdateError: undefined, - client: undefined as unknown as ClientSideConnection, + registerCatalogProvider: provider => ctx.llm.registerAdapter([provider], new MockAdapter([], false, provider)), + replacePrimaryProviders: (providers) => { primaryAdapter.replace(providers) }, + client: undefined as unknown as BridgeClient, acpFiber: undefined as unknown as BridgeHarness['acpFiber'], loopFiber, closeClientTransport: async () => { await clientToAgentWriter.close() }, abortClientTransport: async () => { await clientToAgentWriter.abort(new Error('client transport failed')) }, - dispose: async () => { await ctx.fiber.dispose() }, + dispose: async () => { + await ctx.fiber.dispose() + if (ownsPersistenceRoot) await rm(persistenceRoot, { recursive: true, force: true }) + }, } - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { + const clientApp = createAcpClientApp({ name: 'dsh-acp-test-client' }) + .onNotification(methods.client.session.update, ({ params }) => { updates.push(params.update) sessionUpdates.push({ sessionId: params.sessionId, update: params.update }) if (harness.onSessionUpdateError !== undefined) return Promise.reject(new Error('client update rejected')) return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { permissionRequests.push(params) return Promise.resolve(harness.onPermission(params)) - }, - }) + }) const config = { stream: agentStream, ...options.config } as AcpConfig if (!(options.config && 'provider' in options.config)) config.provider = 'mock' @@ -238,6 +292,18 @@ export async function makeBridgeHarness(options: { inject: [...AcpPlugin.inject], apply: (inner: Context) => { AcpPlugin.apply(inner, config) }, }) - harness.client = new ClientSideConnection(makeClient, clientStream) + const clientConnection = clientApp.connect(clientStream) + const client = clientConnection.agent + harness.client = { + initialize: params => client.request(methods.agent.initialize, params), + authenticate: params => client.request(methods.agent.authenticate, params), + newSession: params => client.request(methods.agent.session.new, params), + listSessions: params => client.request(methods.agent.session.list, params), + resumeSession: params => client.request(methods.agent.session.resume, params), + closeSession: params => client.request(methods.agent.session.close, params), + setSessionConfigOption: params => client.request(methods.agent.session.setConfigOption, params), + prompt: (params, options) => client.request(methods.agent.session.prompt, params, options), + cancel: params => client.notify(methods.agent.session.cancel, params), + } return harness } diff --git a/packages/acp/acp/tests/mcp.spec.ts b/packages/acp/acp/tests/mcp.spec.ts new file mode 100644 index 0000000000..e32906cf5a --- /dev/null +++ b/packages/acp/acp/tests/mcp.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { McpServer } from '@agentclientprotocol/sdk' +import type { Config as McpClientConfig } from '@deepseek-ai/dsh-mcp-client' +import { mountAcpMcpServers } from '../src/mcp.ts' + +/** Context stand-in that captures validated MCP configs without opening transports. */ +function captureContext(): { ctx: Context; configs: McpClientConfig[] } { + const configs: McpClientConfig[] = [] + const plugin = vi.fn((_plugin: unknown, config: McpClientConfig) => { + configs.push(config) + return Promise.resolve(undefined) + }) + return { ctx: { plugin } as unknown as Context, configs } +} + +describe('ACP MCP declaration mapping', () => { + it('normalizes human server names and preserves standard stdio/HTTP fields', async () => { + const { ctx, configs } = captureContext() + + await mountAcpMcpServers(ctx, [ + { + name: 'Fancy server!', + command: process.execPath, + args: ['server.js'], + env: [{ name: 'TOKEN', value: 'secret' }], + }, + { + type: 'http', + name: '!!!', + url: 'https://example.test/mcp', + headers: [{ name: 'Authorization', value: 'Bearer token' }], + }, + ], process.cwd()) + + expect(configs).toHaveLength(2) + expect(configs[0]).toMatchObject({ + transport: 'stdio', + command: process.execPath, + args: ['server.js'], + env: { TOKEN: 'secret' }, + cwd: process.cwd(), + failOnStartupError: true, + }) + expect(configs[0]?.serverName).toMatch(/^Fancy_server_[0-9a-f]{8}$/) + expect(configs[1]).toMatchObject({ + transport: 'streamable-http', + url: 'https://example.test/mcp', + headers: { Authorization: 'Bearer token' }, + failOnStartupError: true, + }) + expect(configs[1]?.serverName).toMatch(/^server_[0-9a-f]{8}$/) + }) + + it.each([ + [[{ name: 'A', value: '1' }, { name: 'A', value: '2' }], /duplicate name/], + [[{ name: '', value: '1' }], /invalid environment entry/], + [[{ name: 'A\0', value: '1' }], /invalid environment entry/], + [[{ name: 'A', value: '1\0' }], /invalid environment entry/], + ] as const)('rejects invalid environment entries %#', async (env, message) => { + const { ctx } = captureContext() + await expect(mountAcpMcpServers(ctx, [{ + name: 'fixture', command: process.execPath, args: [], env: [...env], + }], process.cwd())).rejects.toThrow(message) + }) + + it('rejects case-insensitive duplicate headers and malformed URLs', async () => { + const { ctx } = captureContext() + await expect(mountAcpMcpServers(ctx, [{ + type: 'http', + name: 'web', + url: 'https://example.test/mcp', + headers: [{ name: 'X-Key', value: 'one' }, { name: 'x-key', value: 'two' }], + }], process.cwd())).rejects.toThrow(/duplicate name/) + await expect(mountAcpMcpServers(ctx, [{ + type: 'http', name: 'web', url: 'not a URL', headers: [], + }], process.cwd())).rejects.toThrow(/absolute HTTP/) + }) + + it('maps provider schema failures into the indexed declaration error', async () => { + const { ctx } = captureContext() + const malformed = { + name: 'fixture', + command: process.execPath, + args: 'not-an-array', + env: [], + } as unknown as McpServer + + await expect(mountAcpMcpServers(ctx, [malformed], process.cwd())) + .rejects.toThrow(/mcpServers\[0\] is invalid/) + }) +}) diff --git a/packages/acp/acp/tests/model-control.spec.ts b/packages/acp/acp/tests/model-control.spec.ts new file mode 100644 index 0000000000..c278bda5a3 --- /dev/null +++ b/packages/acp/acp/tests/model-control.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { ReasoningEffortId, type LlmRuntime } from '@deepseek-ai/dsh-llm' +import { AcpModelControl } from '../src/model-control.ts' + +/** Minimal LLM catalog/runtime double for pure standard-option tests. */ +function llmRuntime(overrides: Partial = {}): LlmRuntime { + return { + listProviders: () => [{ id: 'mock', name: 'Mock' }], + listModels: () => Promise.resolve([{ provider: 'mock', id: 'mock', name: 'Mock' }]), + resolveCallConfig: (selection: { provider?: string; model?: string; reasoningEffort?: string }) => Promise.resolve({ + provider: selection.provider ?? 'mock', + model: selection.model ?? 'mock', + ...selection.reasoningEffort === undefined + ? { reasoningEffort: ReasoningEffortId('high') } + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }, + }), + resolveModelInfo: (provider: string, model: string) => Promise.resolve({ + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low', description: 'Less thought.' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }), + ...overrides, + } as unknown as LlmRuntime +} + +describe('ACP model configuration control', () => { + it('represents an absent route and validates value types before mutation', async () => { + const control = new AcpModelControl(llmRuntime(), undefined) + + expect(control.snapshot()).toBeUndefined() + await expect(control.options()).resolves.toEqual([]) + await expect(control.set('model', false)).rejects.toThrow(/requires a select value/) + await expect(control.set('model', 'missing')).rejects.toThrow(/no model selection/) + + control.selection.current = { provider: 'mock', model: 'mock' } + expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' }) + }) + + it('synthesizes an unlisted current route and exposes reasoning descriptions', async () => { + const control = new AcpModelControl(llmRuntime({ listProviders: () => [] }), { + provider: 'private', + model: 'unlisted', + }) + + const options = await control.options() + + const model = options.find(option => option.id === 'model') + const reasoning = options.find(option => option.id === 'reasoning_effort') + expect(model).toMatchObject({ + type: 'select', + currentValue: '["private","unlisted"]', + options: [{ group: 'private', name: 'private', options: [{ name: 'unlisted' }] }], + }) + expect(reasoning).toMatchObject({ + type: 'select', + currentValue: 'high', + options: [{ name: 'Low', description: 'Less thought.' }, { name: 'High' }], + }) + + control.pinTurn(3, { provider: 'turn', model: 'pinned' }) + expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' }) + control.releaseTurn(2) + expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' }) + control.releaseTurn(3) + expect(control.selection.current).toEqual({ provider: 'private', model: 'unlisted' }) + }) + + it('keeps the selected route when its provider catalog is temporarily unavailable', async () => { + const listModels = vi.fn(() => Promise.reject(new Error('catalog unavailable'))) + const control = new AcpModelControl(llmRuntime({ listModels }), { provider: 'mock', model: 'mock' }) + + const options = await control.options() + + expect(listModels).toHaveBeenCalledWith('mock') + expect(options[0]).toMatchObject({ + type: 'select', + options: [{ group: 'mock', options: [{ name: 'mock' }] }], + }) + }) + + it('rejects an unadvertised reasoning effort and accepts a later valid change', async () => { + const control = new AcpModelControl(llmRuntime(), { provider: 'mock', model: 'mock' }) + + await expect(control.set('reasoning_effort', 'extreme')).rejects.toThrow(/unknown reasoning effort/) + const options = await control.set('reasoning_effort', 'low') + + expect(options.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: 'low' }) + }) +}) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index 71e2a21e43..8a44856590 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -31,13 +31,11 @@ describe('ACP prompt lifecycle', () => { harness = undefined }) - it('maps a max-token turn to end_turn without losing its committed text', async () => { + it('reports a max-token turn without losing its committed text', async () => { harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] }) const sessionId = await newSession(harness) const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - // A token-limit turn ending is not a prompt-level stop reason (README): - // the prompt settles at whole-agent idle with end_turn. - expect(result.stopReason).toBe('end_turn') + expect(result.stopReason).toBe('max_tokens') await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) @@ -59,10 +57,12 @@ describe('ACP prompt lifecycle', () => { ]) const sessionId = await newSession(harness) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) - expect(harness.updates).toContainEqual({ + const image = harness.updates.find(update => update.sessionUpdate === 'agent_message_chunk') + expect(image).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'AQ==', mimeType: 'image/png' }, }) + expect(image !== undefined && 'messageId' in image && typeof image.messageId === 'string').toBe(true) }) it('preserves committed text/image/text order on the ACP wire', async () => { @@ -82,11 +82,15 @@ describe('ACP prompt lifecycle', () => { await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) - expect(harness.updates).toEqual([ - { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'before' } }, - { sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' } }, - { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'after' } }, + expect(harness.updates.map(update => update.sessionUpdate)).toEqual([ + 'agent_message_chunk', 'agent_message_chunk', 'agent_message_chunk', ]) + expect(harness.updates.map(update => 'content' in update ? update.content : undefined)).toEqual([ + { type: 'text', text: 'before' }, + { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' }, + { type: 'text', text: 'after' }, + ]) + expect(new Set(harness.updates.map(update => 'messageId' in update ? update.messageId : undefined)).size).toBe(1) }) it('does not settle a prompt before ordered output delivery drains', async () => { @@ -259,6 +263,35 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) }) + it('routes JSON-RPC request cancellation through the prompt cancellation path', async () => { + harness = await makeBridgeHarness({ script: ['hang'] }) + const sessionId = await newSession(harness) + const controller = new AbortController() + const prompt = harness.client.prompt( + { sessionId, prompt: [{ type: 'text', text: 'one' }] }, + { cancellationSignal: controller.signal }, + ) + await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') }) + + controller.abort() + + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.adapter.requests[0]?.signal?.aborted).toBe(true) + }) + + it('cancels a prompt request whose JSON-RPC signal is already aborted', async () => { + harness = await makeBridgeHarness({ script: [] }) + const sessionId = await newSession(harness) + const controller = new AbortController() + controller.abort() + + await expect(harness.client.prompt( + { sessionId, prompt: [{ type: 'text', text: 'never admitted' }] }, + { cancellationSignal: controller.signal }, + )).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.adapter.requests).toEqual([]) + }) + it('reserves the prompt slot during image admission and cancels without a late followup', async () => { harness = await makeBridgeHarness({ imageCapable: true, script: [] }) const validationStarted = Promise.withResolvers() diff --git a/packages/acp/acp/tests/updates.spec.ts b/packages/acp/acp/tests/updates.spec.ts new file mode 100644 index 0000000000..9af5e31d52 --- /dev/null +++ b/packages/acp/acp/tests/updates.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import { CallId, MessageId } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { assistantUpdates, toolCallUpdate, toolResultUpdate } from '../src/updates.ts' + +/** Minimal committed assistant event for pure update projection tests. */ +function assistantEvent( + content: SessionEvent<'assistant/message'>['data']['message']['content'], + usage?: SessionEvent<'assistant/message'>['data']['usage'], +): SessionEvent<'assistant/message'> { + return { + type: 'assistant/message', + seq: 0, + time: 0, + data: { + turn: 1, + step: 1, + message: { + id: MessageId('message-1'), + role: 'assistant', + source: { kind: 'model', provider: 'mock', model: 'mock' }, + content, + }, + ...usage === undefined ? {} : { usage }, + }, + } +} + +describe('standard ACP update projection', () => { + it('omits empty reasoning, unsupported assistant blocks, and absent usage', async () => { + const ctx = { get: () => undefined } as unknown as Context + const session = { requestContext: () => undefined } as unknown as Session + const event = assistantEvent([ + { type: 'reasoning', text: '' }, + { type: 'tool-call', id: CallId('call-hidden'), name: 'hidden', arguments: '{}' }, + ]) + + await expect(assistantUpdates(ctx, session, event)).resolves.toEqual([]) + }) + + it('requires both measured usage and context capacity', async () => { + const meter = { measure: vi.fn(() => ({ totalTokens: 7 })) } + const withMeter = { get: (name: string) => name === 'tokenMeter' ? meter : undefined } as unknown as Context + const withoutMeter = { get: () => undefined } as unknown as Context + const withCapacity = { requestContext: () => ({ contextWindow: 100 }) } as unknown as Session + const withoutCapacity = { requestContext: () => undefined } as unknown as Session + const event = assistantEvent([{ type: 'text', text: 'done' }], { inputTokens: 1, outputTokens: 1 }) + + expect((await assistantUpdates(withMeter, withoutCapacity, event)).map(update => update.sessionUpdate)) + .toEqual(['agent_message_chunk']) + expect((await assistantUpdates(withoutMeter, withCapacity, event)).map(update => update.sessionUpdate)) + .toEqual(['agent_message_chunk']) + expect(meter.measure).not.toHaveBeenCalled() + }) + + it('preserves malformed tool input and projects a failed result without hidden content', async () => { + const call = toolCallUpdate({ + type: 'tool/call', + seq: 0, + time: 0, + data: { turn: 1, step: 1, callId: CallId('call-bad'), name: 'broken', arguments: '{' }, + }) + const result = await toolResultUpdate({ get: () => undefined } as unknown as Context, { + type: 'tool/result', + seq: 0, + time: 0, + data: { + turn: 1, + step: 1, + message: { + id: MessageId('tool-message'), + role: 'user', + source: { kind: 'tool', callId: CallId('call-bad') }, + content: [{ + type: 'tool-result', + toolCallId: CallId('call-bad'), + isError: true, + content: [{ type: 'reasoning', text: 'hidden' }], + }], + }, + }, + }) + + expect(call).toMatchObject({ rawInput: '{' }) + expect(result).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-bad', + status: 'failed', + content: [], + }) + }) +}) diff --git a/packages/acp/acp/tsconfig.json b/packages/acp/acp/tsconfig.json index 93aa066a8b..71276d8ee3 100644 --- a/packages/acp/acp/tsconfig.json +++ b/packages/acp/acp/tsconfig.json @@ -23,6 +23,21 @@ { "path": "../../core/agent" }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../mcp/mcp-client" + }, + { + "path": "../../session/session-persistence" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/examples/acp-demo/README.i18n.yaml b/packages/examples/acp-demo/README.i18n.yaml index 6d428338e0..d96950f388 100644 --- a/packages/examples/acp-demo/README.i18n.yaml +++ b/packages/examples/acp-demo/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/acp-demo/README.md -README.md: 10ab91716a4e3c1395a41e2cb44cb46719d8e0f3 -README.zh.md: 78199121282f20f25bb341e0f71d542a25f71d81 +README.md: dea2831d372e0075cee7e948ee7abd185e27cf6c +README.zh.md: a1493ec1ded67cd8f936e76c4db0bfde9a0a7d42 diff --git a/packages/examples/acp-demo/README.md b/packages/examples/acp-demo/README.md index 10ab91716a..dea2831d37 100644 --- a/packages/examples/acp-demo/README.md +++ b/packages/examples/acp-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -ACP automation server app: the default agent spine, client-created agents through [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md), JSONL persistence, and semantic checkpointing behind one JSON-RPC stdio bin. Programmatic clients create fresh sessions; this package mounts no human UI. +ACP automation server app: the default agent spine, client-created agents through [`@deepseek-ai/dsh-acp`](../../acp/acp/README.md), JSONL persistence, and semantic checkpointing behind one JSON-RPC stdio bin. Programmatic clients create, list, close, and resume sessions; this package mounts no human UI. ## Composition @@ -44,6 +44,12 @@ The shipped [`examples/acp-agent/cordis.yml`](../../../examples/acp-agent/cordis `dsh-acp-demo [--config path-to-cordis.yml]` (short form `-c`; default `./cordis.yml`) loads the gitignored `.env`, except in replay mode; `DSH_SNAPSHOT=replay` selects the sibling `cordis.snapshot.yml`; stdin EOF disposes the context and flushes sessions before exit. Loader's installed optional `node-addon-require-builtin` peer resolves bare plugin specifiers for the built bin under plain Node. Diagnostics use stderr because stdout is the ACP wire. +## Standard automation workflow + +An ACP v1 SDK client initializes the bin, creates a session with an absolute `cwd` and optional standard stdio/HTTP MCP declarations, chooses an advertised `model` or `reasoning_effort`, prompts and observes semantic standard updates, then calls `session/close`. A later process can use `session/list` and `session/resume` against the same `persistenceRoot`; resume reconnects the MCP declarations supplied by that request and does not replay history. + +The complete supported/unsupported method matrix, MCP trust model, update mapping, and stop reasons live in the [`dsh-acp` protocol contract](../../acp/acp/README.md#standard-acp-v1-surface). The demo adds no private method, capability, `_meta`, environment variable, or transport field. The keyless control-surface conformance test drives this exact bin through the public ACP SDK. + ## Model Experience Indirectly, through `dsh-agent-spine-demo` and the leaf's model-facing plugins. ACP prompt text becomes the ordinary logged user message; protocol metadata and permission choices do not enter the model request. @@ -56,4 +62,4 @@ Append-only per session; the app adds no request-prefix content itself. - **JSONL persistence is fixed** — a different backend requires another composition. - **Sibling plugins can corrupt stdout** — the app cannot prevent another entry from writing non-protocol bytes. -- **Fresh automation sessions only** — resume and human interaction belong to other entry points. +- **Automation only** — session controls and semantic execution updates are protocol data, not a human UI; human interaction remains in other entry points. diff --git a/packages/examples/acp-demo/README.zh.md b/packages/examples/acp-demo/README.zh.md index 7819912128..a1493ec1de 100644 --- a/packages/examples/acp-demo/README.zh.md +++ b/packages/examples/acp-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能体)主干、客户端通过 [`@deepseek-ai/dsh-acp`](../../acp/acp/README.zh.md) 创建的 agent、JSONL 持久化,以及语义检查点机制,并通过一个 JSON-RPC stdio bin 对外提供服务。程序化客户端创建新会话;此包不挂载人工交互 UI。 +ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能体)主干、客户端通过 [`@deepseek-ai/dsh-acp`](../../acp/acp/README.zh.md) 创建的 agent、JSONL 持久化,以及语义检查点机制,并通过一个 JSON-RPC stdio bin 对外提供服务。程序化客户端可以创建、列出、关闭和恢复会话;此包不挂载人工交互 UI。 ## 组合 @@ -44,6 +44,12 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 `dsh-acp-demo [--config path-to-cordis.yml]`(短形式 `-c`;默认为 `./cordis.yml`)会加载 gitignore 排除的 `.env`,回放模式除外;`DSH_SNAPSHOT=replay` 选择同级 `cordis.snapshot.yml`;stdin EOF 会在退出前 dispose(资源释放)上下文并刷新会话。Loader 已安装的可选对等依赖(peer dependency)`node-addon-require-builtin` 使纯 Node 下构建后的 bin 可以解析裸插件说明符。诊断使用 stderr,因为 stdout 是 ACP wire。 +## 标准自动化工作流 + +ACP v1 SDK 客户端先初始化 bin,再使用绝对 `cwd` 和可选标准 stdio/HTTP MCP 声明创建会话,选择已公布的 `model` 或 `reasoning_effort`,发送提示词并观察标准语义更新,最后调用 `session/close`。后续进程可以针对同一个 `persistenceRoot` 使用 `session/list` 和 `session/resume`;恢复会重新连接该请求提供的 MCP 声明,并且不会重放历史。 + +完整的支持/不支持方法矩阵、MCP 信任模型、更新映射和 stop reason 位于 [`dsh-acp` 协议约定](../../acp/acp/README.zh.md#standard-acp-v1-surface)。Demo 不增加私有方法、能力、`_meta`、环境变量或传输字段。Keyless control-surface conformance 测试只通过公开 ACP SDK 驱动这个确切 bin。 + ## 模型体验 模型体验由 `dsh-agent-spine-demo` 和叶节点的面向模型插件间接提供。ACP 提示词文本会成为普通的已记录用户消息;协议元数据与权限选择不会进入模型请求。 @@ -56,4 +62,4 @@ ACP(Agent Client Protocol)自动化服务器应用:默认 agent(智能 - **JSONL 持久化固定不变**:使用其他后端需要另一种组合。 - **同级插件可能破坏 stdout**:应用无法阻止另一个 Cordis 配置项写入非协议字节。 -- **只支持新建自动化会话**:恢复和人工交互属于其他运行入口。 +- **仅面向自动化**:会话控制和语义执行更新是协议数据,不是人工 UI;人工交互仍属于其他运行入口。 diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 6ef3e8a95d..cbfc63e7d9 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -56,9 +56,11 @@ "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-acp-snapshot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", @@ -68,6 +70,7 @@ "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/examples/acp-demo/tests/built-bin.e2e.ts b/packages/examples/acp-demo/tests/built-bin.e2e.ts index 1fdf363c75..215cdb7caa 100644 --- a/packages/examples/acp-demo/tests/built-bin.e2e.ts +++ b/packages/examples/acp-demo/tests/built-bin.e2e.ts @@ -5,13 +5,10 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { - ClientSideConnection, + client as createAcpClientApp, + methods, ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' import { Readable, Writable } from 'node:stream' @@ -31,12 +28,12 @@ const acpBin = join(repoRoot, 'packages/examples/acp-demo/lib/bin.js') const decompress = promisify(zstdDecompress) const dshPackages = [ - 'examples/agent-spine-demo', 'core/agent', 'core/session', 'core/system-prompt', + 'examples/agent-spine-demo', 'core/agent', 'core/scope', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'shell/shell', 'shell/bash-local', 'shell/tool-bash', 'subprocess/subprocess', 'subprocess/subprocess-local', 'context/agent-instructions', 'runtime-diagnostics/invariants', 'boot/app-boot', 'session/session-persistence', 'session/session-checkpoint-policy', 'session/session-persistence-jsonl', - 'acp/acp', 'examples/acp-demo', 'util/home-paths', + 'acp/acp', 'mcp/mcp-client', 'examples/acp-demo', 'util/home-paths', 'util/timeout', ] const vendorPackages = [ 'cordis', 'loader', 'include', 'timer', 'hmr', 'logger-console', @@ -73,9 +70,11 @@ async function makeConsumer(): Promise { for (const dep of npmDeps) { // Resolve from ACP's package.json URL (the package that declares the // dep), not this test file's location — `acp-agent` does not depend on these. + // ACP SDK 1.4 intentionally does not export package.json; its stable entry + // is `/dist/acp.js`, so the package root is two directories up. const fromAcp = pathToFileURL(join(acpPkgDir, 'package.json')).href - const resolved = fileURLToPath(import.meta.resolve(`${dep}/package.json`, fromAcp)) - await link(dirname(resolved), dep, nm) + const resolved = fileURLToPath(import.meta.resolve(dep, fromAcp)) + await link(dirname(dirname(resolved)), dep, nm) } await writeFile(join(dir, 'mock-llm.mjs'), [ "import { LlmAdapter } from '@deepseek-ai/dsh-llm'", @@ -156,29 +155,41 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-demo BUILT bin (node lib/bin.js, n Readable.toWeb(passthrough) as ReadableStream, ) const updates: SessionNotification['update'][] = [] - const makeClient = (_a: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { + const clientApp = createAcpClientApp({ name: 'dsh-acp-built-smoke' }) + .onNotification(methods.client.session.update, ({ params }) => { updates.push(params.update) return Promise.resolve() - }, - requestPermission(_p: RequestPermissionRequest): Promise { + }) + .onRequest(methods.client.session.requestPermission, () => { return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) + }) + const client = clientApp.connect(stream).agent - const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const init = await client.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: {}, + }) + .catch((error: unknown): never => { + throw new Error(`built ACP initialize failed\n${stderr.join('')}`, { cause: error }) + }) expect(init.agentCapabilities).toEqual({ + mcpCapabilities: { http: true }, promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, }) const sessionCwd = consumer - const { sessionId } = await client.newSession({ cwd: sessionCwd, mcpServers: [] }) - const result = await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'reply' }] }) + const { sessionId } = await client.request(methods.agent.session.new, { cwd: sessionCwd, mcpServers: [] }) + const result = await client.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'reply' }], + }) expect(result.stopReason).toBe('end_turn') - await expect.poll(() => updates).toEqual([{ + await expect.poll(() => updates).toHaveLength(1) + expect(updates[0]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'ACP BUILT OK' }, - }]) + }) + expect(updates[0] !== undefined && 'messageId' in updates[0] && typeof updates[0].messageId === 'string').toBe(true) const sessionsRoot = join(sessionCwd, '.sessions') let log: string | undefined await expect.poll(async () => { diff --git a/packages/examples/acp-demo/tests/control-surface-llm.ts b/packages/examples/acp-demo/tests/control-surface-llm.ts new file mode 100644 index 0000000000..b0858998ee --- /dev/null +++ b/packages/examples/acp-demo/tests/control-surface-llm.ts @@ -0,0 +1,106 @@ +/** Keyless two-model adapter for the generic ACP control-surface conformance test. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + CallId, + LlmAdapter, + ReasoningEffortId, + type GenerateOptions, + type LlmResolvedModelInfo, + type StreamChunk, +} from '@deepseek-ai/dsh-llm' + +/** Adapter whose deterministic tool turn proves model selection and MCP attachment. */ +class ControlSurfaceAdapter extends LlmAdapter { + override providerInfo(provider: string) { + if (provider !== 'control-fixture') throw new Error(`unknown fixture provider: ${provider}`) + return { id: provider, name: 'Control fixture' } + } + + override listModels(provider: string) { + if (provider !== 'control-fixture') return Promise.resolve([]) + return Promise.resolve([ + { provider, id: 'alpha', name: 'Alpha', inputModalities: ['text'] as const }, + { provider, id: 'beta', name: 'Beta', inputModalities: ['text'] as const }, + ]) + } + + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, + id: model, + name: model, + inputModalities: ['text'], + context: { contextWindow: 2_048 }, + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }) + } + + override async * stream(options: GenerateOptions): AsyncIterable { + const lastUserIndex = options.messages.findLastIndex(message => message.source.kind === 'user') + const current = options.messages.slice(lastUserIndex) + const userText = current.flatMap(message => message.content) + .flatMap(block => block.type === 'text' ? [block.text] : []) + .join('') + const hasToolResult = current.some(message => message.content.some(block => block.type === 'tool-result')) + if (!hasToolResult) { + const callId = CallId(userText.includes('cancel') ? 'control-cancel-add' : 'control-add') + yield { type: 'block-start', index: 0, blockType: 'reasoning' } + yield { type: 'reasoning-delta', index: 0, text: 'checking the attached tool' } + yield { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the attached tool' } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { + type: 'tool-call-delta', + index: 1, + id: callId, + name: 'mcp__fixture__add', + argumentsDelta: '{"a":2,"b":3}', + } + yield { + type: 'block-end', + index: 1, + block: { + type: 'tool-call', + id: callId, + name: 'mcp__fixture__add', + arguments: '{"a":2,"b":3}', + }, + } + yield { type: 'usage', usage: { inputTokens: 8, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + return + } + if (userText.includes('cancel')) { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'waiting' } + await new Promise((_resolve, reject) => { + if (options.signal?.aborted === true) { + reject(new Error('cancelled')) + return + } + options.signal?.addEventListener('abort', () => { reject(new Error('cancelled')) }, { once: true }) + }) + return + } + const text = `model=${options.model}; tool=5` + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 13, outputTokens: 5 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'control-surface-llm' +export const inject = ['llm'] + +/** Register the deterministic control-surface provider. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['control-fixture'], new ControlSurfaceAdapter()) +} diff --git a/packages/examples/acp-demo/tests/control-surface.cordis.yml b/packages/examples/acp-demo/tests/control-surface.cordis.yml new file mode 100644 index 0000000000..a6ccfdd876 --- /dev/null +++ b/packages/examples/acp-demo/tests/control-surface.cordis.yml @@ -0,0 +1,15 @@ +# Keyless generic ACP v1 control-surface conformance composition. +- id: control-surface-llm + name: './control-surface-llm.ts' + +- id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: control-fixture + model: alpha + persistenceRoot: !!js process.env.DSH_CONFORMANCE_PERSISTENCE_ROOT + persistenceCompression: none + workspaceContext: false + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' diff --git a/packages/examples/acp-demo/tests/control-surface.e2e.ts b/packages/examples/acp-demo/tests/control-surface.e2e.ts new file mode 100644 index 0000000000..96c44f2a1f --- /dev/null +++ b/packages/examples/acp-demo/tests/control-surface.e2e.ts @@ -0,0 +1,117 @@ +/** Generic keyless ACP v1 automation-control conformance over the real demo process. */ + +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const agent: AgentUnderTest = { + binScript: join(repoRoot, 'packages/examples/acp-demo/src/bin.ts'), + configPath: fileURLToPath(new URL('./control-surface.cordis.yml', import.meta.url)), + tsconfigPath: join(repoRoot, 'tsconfig.json'), +} +const mcpServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + +/** Find one named select value in grouped or ungrouped standard options. */ +function selectValue( + options: Awaited>['configOptions'], + configId: string, + name: string, +): string { + const option = options?.find(candidate => candidate.id === configId) + if (option?.type !== 'select') throw new Error(`missing select option: ${configId}`) + const values = option.options.flatMap(candidate => 'group' in candidate ? candidate.options : [candidate]) + const selected = values.find(candidate => candidate.name === name) + if (selected === undefined) throw new Error(`missing ${configId} value: ${name}`) + return selected.value +} + +describe('standard ACP v1 control surface', () => { + it('selects, mounts MCP, closes, restarts, resumes, and cancels through the SDK only', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-acp-control-')) + const persistenceRoot = join(cwd, '.sessions') + const env = { DSH_CONFORMANCE_PERSISTENCE_ROOT: persistenceRoot } + const mcpServers = [{ name: 'fixture', command: process.execPath, args: [mcpServer], env: [] }] + let first: LaunchedAcpTestAgent | undefined + let second: LaunchedAcpTestAgent | undefined + try { + first = launchAcpTestAgent({ agent, cwd, env }) + await first.spawned + const initialized = await first.client.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { _meta: { ignored: true } }, + }) + expect(initialized.agentCapabilities).toEqual({ + mcpCapabilities: { http: true }, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, + }) + expect('_meta' in initialized).toBe(false) + const created = await first.client.newSession({ cwd, mcpServers }) + const beta = selectValue(created.configOptions, 'model', 'Beta') + const selectedModel = await first.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: beta, + }) + const low = selectValue(selectedModel.configOptions, 'reasoning_effort', 'Low') + await first.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: low, + }) + + await expect(first.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'exercise the attached server' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(first.updates.map(update => update.sessionUpdate)).toEqual([ + 'agent_thought_chunk', + 'usage_update', + 'tool_call', + 'tool_call_update', + 'agent_message_chunk', + 'usage_update', + ]) + expect(first.updates).toContainEqual(expect.objectContaining({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'model=beta; tool=5' }, + })) + const message = first.updates.find(update => update.sessionUpdate === 'agent_message_chunk') + expect(message !== undefined && 'messageId' in message && typeof message.messageId === 'string').toBe(true) + await first.client.closeSession({ sessionId: created.sessionId }) + await first.close() + first = undefined + + second = launchAcpTestAgent({ agent, cwd, env }) + await second.spawned + await second.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(second.client.listSessions({ cwd })).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd }], + }) + await second.client.resumeSession({ sessionId: created.sessionId, cwd, mcpServers }) + const toolFinished = second.waitForUpdate(update => ( + update.sessionUpdate === 'tool_call_update' && update.toolCallId === 'control-cancel-add' + )) + const prompt = second.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'cancel after the tool finishes' }], + }) + await toolFinished + await second.client.cancel({ sessionId: created.sessionId }) + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + await second.client.closeSession({ sessionId: created.sessionId }) + } finally { + await Promise.allSettled([first?.close(), second?.close()].filter((value): value is Promise => value !== undefined)) + await rm(cwd, { recursive: true, force: true }) + } + }, 30_000) +}) diff --git a/packages/examples/acp-demo/tests/load-path.e2e.ts b/packages/examples/acp-demo/tests/load-path.e2e.ts index 9b731ad8b6..865ba24c28 100644 --- a/packages/examples/acp-demo/tests/load-path.e2e.ts +++ b/packages/examples/acp-demo/tests/load-path.e2e.ts @@ -6,14 +6,11 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { - ClientSideConnection, + client as createAcpClientApp, + methods, ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, + type ClientContext, } from '@agentclientprotocol/sdk' /** @@ -58,7 +55,7 @@ const CORDIS_YML = ` interface Spawned { child: ChildProcessWithoutNullStreams - client: ClientSideConnection + client: ClientContext stderr: string[] } @@ -102,34 +99,33 @@ async function boot(): Promise { Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(child.stdout) as ReadableStream, ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(_params: SessionNotification): Promise { - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) + const client = createAcpClientApp({ name: 'dsh-acp-load-smoke' }) + .onNotification(methods.client.session.update, () => Promise.resolve()) + .onRequest(methods.client.session.requestPermission, () => ( + Promise.resolve({ outcome: { outcome: 'cancelled' } }) + )) + .connect(stream).agent spawned = { child, client, stderr } return { ...spawned, cwd } } describe('dsh-acp-demo real-load-path smoke (bin + Loader, keyless)', () => { - it('boots via its bin and exposes only fresh text sessions', async () => { + it('boots via its bin and exposes the standard automation controls', async () => { const { client, cwd, stderr } = await boot() // initialize: a broken export shape (collapsed bridge plugin, dropped inject) // crashes the tree on the first service read here — see postmortem 0001. - const init = await client.initialize({ + const init = await client.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {}, }) expect(init.agentCapabilities).toEqual({ + mcpCapabilities: { http: true }, promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, }) // session/new reaches the agent FACTORY (create) without the model. - const { sessionId } = await client.newSession({ cwd, mcpServers: [] }) + const { sessionId } = await client.request(methods.agent.session.new, { cwd, mcpServers: [] }) expect(sessionId).toBeTruthy() expect(stderr.join('')).not.toContain('without inject') diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index 32e9ccbda6..a283f5c789 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -1204,6 +1204,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ description: 'Register a new session\'s metadata. A backend MAY defer the physical write until the first append (lazy materialization), in which case a created-but-never-appended session is absent from list — abandoned sessions leave nothing behind.', parameters: [{ name: 'meta', description: 'the immutable header (id, version, cwd, lineage) to record.' }], }, + { + signature: 'ensureMaterialized(_session: Session): Promise', + description: 'Ensure a live session has a durable header even when it has no events. Ordinary sessions remain lazily materialized; lifecycle frontends call this only when an empty session itself is a durable resumable resource.', + parameters: [{ name: '_session', description: 'exact live session whose registered header is materialized.' }], + }, { signature: 'abstract append(id: SessionId, events: readonly SessionEvent[]): Promise', description: 'Durably persist a batch of events. Honors the append-only and contiguous- seq contracts: the first event\'s `seq` MUST equal the stored next-seq (after `load` has durably closed any interrupted turn). Rejects non-JSON- serializable `event.data` with an error naming the offending event type.', diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index cf715b23da..b97f1b92b0 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md -README.md: f3bf65d90d72f9eb3271cbbbb8ae8c586a7fd082 -README.zh.md: 1596ec72c28c4811eabb9f5cafe41cd7bdf1cf5e +README.md: 46ea451e2e28513981a96470fe93506dd3298d96 +README.zh.md: ab72bfa1649efac17c0a56c2d18941cbb6c6f925 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index f3bf65d90d..46ea451e2e 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -36,7 +36,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same | Field | Transport | Required | Description | |---|---|---|---| | `transport` | both | yes | `"stdio"` or `"streamable-http"` | -| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique across live instances | +| `serverName` | both | yes | Namespace for this server's model-facing tool names; `[A-Za-z0-9_-]{1,32}`, unique inside one registration scope | | `command` | stdio | yes | Executable to spawn | | `args` | stdio | no | Arguments passed to the command | | `env` | stdio | no | Extra env vars merged on top of scrubbed ambient env | @@ -55,7 +55,7 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`) and the public name `mcp____` registered on `ctx.tools`. Public names are normalized to the DeepSeek function-name contract (64 chars, `[A-Za-z0-9_-]`); when replacement or truncation changes the name, a deterministic 12-hex-char hash of `(serverName, rawName)` is appended so distinct tools never collapse into one name. Names are pure functions of `(serverName, rawName)` — connection order, re-syncs, and other servers never rename a tool. - Two servers publishing the same raw name (e.g. `search`) coexist under their namespaces. -- A duplicate `serverName` across live instances fails the later plugin instance at load. +- A duplicate `serverName` inside one live registration scope fails the later plugin instance at load. Independent Agent scopes may reuse the same namespace because their tools and transports are isolated. - A server listing the same tool name twice is rejected as an invalid tool list. - A foreign registration squatting on this server's namespace rolls back the whole generation (never a partial set), with a loud error. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index 1596ec72c2..ab72bfa164 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -36,7 +36,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc | 字段 | 传输 | 必填 | 描述 | |---|---|---|---| | `transport` | 两者 | 是 | `"stdio"` 或 `"streamable-http"` | -| `serverName` | 两者 | 是 | 该服务器面向模型工具名称的 namespace;`[A-Za-z0-9_-]{1,32}`,在存活实例中唯一 | +| `serverName` | 两者 | 是 | 该服务器面向模型工具名称的 namespace;`[A-Za-z0-9_-]{1,32}`,在单个 registration scope 内唯一 | | `command` | stdio | 是 | 要 spawn 的可执行文件 | | `args` | stdio | 否 | 传给命令的参数 | | `env` | stdio | 否 | 合并到已清理环境中的额外环境变量 | @@ -55,7 +55,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc 每个 MCP 工具都有两个名称:通过 `tools/call` 在协议上传送的原始 MCP 名称,以及公开名称 `mcp____`,后者注册到 `ctx.tools`。公开名称会规范化为 DeepSeek 函数名称约定(64 个字符、`[A-Za-z0-9_-]`);如果替换或截断改变名称,就会追加 `(serverName, rawName)` 的确定性 12 位十六进制 hash,确保不同工具绝不会折叠为同一个名称。名称是 `(serverName, rawName)` 的纯函数:连接顺序、重新同步和其他服务器永远不会重命名工具。 - 发布相同原始名称(例如 `search`)的两个服务器会在各自 namespace 下共存。 -- 存活实例中的重复 `serverName` 会使后加载的插件实例失败。 +- 同一个存活 registration scope 内重复的 `serverName` 会使后加载的插件实例失败。独立 Agent scope 的工具和传输彼此隔离,因此可以复用同一 namespace。 - 服务器在工具列表中两次列出同一工具名称时,该列表会作为无效工具列表被拒绝。 - 外部注册抢占该服务器 namespace 时,会回滚整个世代(绝不保留部分集合),并明确报错。 diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index e7d218161f..37af73f583 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", @@ -50,6 +51,7 @@ "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 5a2a52f55d..59ef928751 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -15,6 +15,7 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { scopeOf } from '@deepseek-ai/dsh-scope' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from './connection.ts' import type { ReconnectConfig } from './connection.ts' @@ -37,12 +38,11 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** - * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps - * in one process — tests — must not see each other's names). A duplicate - * namespace is a configuration error surfaced at plugin load, never silent - * shadowing. + * Live `serverName` reservations per registration scope. Agent-scoped MCP + * servers may reuse a namespace in another Agent, while global instances and + * duplicates inside one Agent remain mutually exclusive. */ -const activeServerNames = new WeakMap>() +const activeServerNames = new WeakMap>() // ---- Config ---- @@ -97,6 +97,12 @@ export interface StreamableHttpConfig { /** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig +type StdioConfigInput = Omit + & Partial> +type StreamableHttpConfigInput = Omit + & Partial> +type ConfigInput = StdioConfigInput | StreamableHttpConfigInput + const Reconnect: z = z.object({ enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled), initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs), @@ -125,7 +131,7 @@ export const Config = z.union([ failOnStartupError: z.boolean().default(false), reconnect: Reconnect, }), -]) as unknown as z +]) as unknown as z // ---- Plugin apply ---- @@ -146,10 +152,11 @@ export async function apply(ctx: Context, config: Config): Promise { // Reserve the namespace next: a duplicate `serverName` fails THIS instance // at load with an actionable error and leaves the earlier instance intact. ctx.effect(() => { - let names = activeServerNames.get(ctx.root) + const owner = scopeOf(ctx) ?? ctx.root + let names = activeServerNames.get(owner) if (!names) { names = new Set() - activeServerNames.set(ctx.root, names) + activeServerNames.set(owner, names) } if (names.has(config.serverName)) { throw new Error( diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 9cda54aeab..1b1716e51b 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' +import { createScope } from '@deepseek-ai/dsh-scope' import type { Config } from '@deepseek-ai/dsh-mcp-client' // ---- Mock MCP SDK ---- @@ -208,6 +209,16 @@ describe('apply (plugin lifecycle)', () => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + it('allows one serverName in each independent registration scope', async () => { + const first = createScope(ctx, {}) + const second = createScope(ctx, {}) + + await Promise.all([apply(first.ctx, stdioConfig), apply(second.ctx, stdioConfig)]) + + expect(mockConnect).toHaveBeenCalledTimes(2) + await Promise.all([first.dispose(), second.dispose()]) + }) + it('releases the serverName reservation on dispose', async () => { const first = new Context() await first.plugin(SystemPrompt) diff --git a/packages/mcp/mcp-client/tests/http-fixture.ts b/packages/mcp/mcp-client/tests/http-fixture.ts new file mode 100644 index 0000000000..32ef516a0e --- /dev/null +++ b/packages/mcp/mcp-client/tests/http-fixture.ts @@ -0,0 +1,52 @@ +/** Keyless stateless Streamable HTTP MCP fixture for integration tests. */ + +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http' +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' + +/** Running HTTP fixture and the request headers it observed. */ +export interface HttpMcpFixture { + url: string + authorization: Array + close: () => Promise +} + +/** Start a local stateless MCP endpoint exposing one `ping` tool. */ +export async function startHttpMcpFixture(): Promise { + const authorization: Array = [] + const handleRequest = async (request: IncomingMessage, response: ServerResponse): Promise => { + authorization.push(request.headers.authorization) + const mcp = new McpServer( + { name: 'http-fixture', version: '1.0.0' }, + { capabilities: { tools: {} } }, + ) + mcp.registerTool('ping', { description: 'Replies pong.', inputSchema: {} }, async () => ({ + content: [{ type: 'text', text: 'pong' }], + })) + const transport = new StreamableHTTPServerTransport({}) + response.on('close', () => { + void transport.close() + void mcp.close() + }) + await mcp.connect(transport as Transport) + await transport.handleRequest(request, response) + } + const server = createServer((request, response) => { + handleRequest(request, response).catch((error: unknown) => { + response.writeHead(500).end(String(error)) + }) + }) + const listening: PromiseWithResolvers = Promise.withResolvers() + server.listen(0, '127.0.0.1', listening.resolve) + await listening.promise + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('HTTP MCP fixture has no TCP address') + return { + url: `http://127.0.0.1:${address.port}/mcp`, + authorization, + close: () => new Promise((resolve, reject) => { + server.close((error) => { if (error === undefined) resolve(); else reject(error) }) + }), + } +} diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index 7dbe351b30..b48aea0922 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -21,6 +21,9 @@ { "path": "../../core/tools" }, + { + "path": "../../core/scope" + }, { "path": "../../subprocess/subprocess" }, diff --git a/packages/session/session-persistence-jsonl/README.i18n.yaml b/packages/session/session-persistence-jsonl/README.i18n.yaml index f9ff2ed252..099e407149 100644 --- a/packages/session/session-persistence-jsonl/README.i18n.yaml +++ b/packages/session/session-persistence-jsonl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-jsonl/README.md -README.md: 4cff3215cdb083d2fdb7c4a8f1b60e8c4028ba84 -README.zh.md: 1675f14a6b9fd2fedf50cca0484aff68ab06f975 +README.md: 0301691acbe42c7973274e717ee9ae6f405ebea1 +README.zh.md: c05c380166b65a9826b6cb7f31729a51628ab49b diff --git a/packages/session/session-persistence-jsonl/README.md b/packages/session/session-persistence-jsonl/README.md index 4cff3215cd..0301691acb 100644 --- a/packages/session/session-persistence-jsonl/README.md +++ b/packages/session/session-persistence-jsonl/README.md @@ -40,7 +40,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the ## Durability and crash semantics - **Bound storage identity.** Lookup requires one matching session directory across the readable project directories, then verifies that the header id equals the requested id and that the header's id/cwd derive the selected transcript path. Listing applies the same path check and rejects duplicate ids. Identity failures occur before repair or append. -- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`. +- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list` unless a lifecycle consumer explicitly calls `ensureMaterialized`, which publishes one header frame without adding an event. - **Append-only.** Flushed events are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length. - **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. An existing compressed artifact with no complete header frame, a checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end` is corruption and rejects. - **Non-mutating inspection.** `inspect()` returns an immutable balanced logical view and may synthesize recovery closers in memory, without truncating an incomplete tail or changing the lightweight revision. diff --git a/packages/session/session-persistence-jsonl/README.zh.md b/packages/session/session-persistence-jsonl/README.zh.md index 1675f14a6b..c05c380166 100644 --- a/packages/session/session-persistence-jsonl/README.zh.md +++ b/packages/session/session-persistence-jsonl/README.zh.md @@ -40,7 +40,7 @@ JSONL 持久会话存储后端:`SessionPersistence` 的一个具体实现(`d ## 持久性与崩溃语义 - **绑定存储身份。** 查找要求可读项目目录中只有一个匹配会话目录,然后验证 header id 等于请求 id,且 header id/cwd 派生所选 transcript 路径。列表应用同一路径检查,并拒绝重复 id。身份失败发生在修复或 append 前。 -- **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中。 +- **延迟实体化。**`create(meta)` 不写入;第一次 `append` 将编码 header 和第一批写入临时文件并执行 `fsync`。POSIX 通过硬链接无覆盖发布,并对父目录 `fsync`。Windows 通过 `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` 无覆盖发布,并通过同一 write-through pattern 创建缺失目录。已创建但从未 append 的会话不留下磁盘内容,不在 `list` 中;生命周期 consumer 显式调用 `ensureMaterialized` 时除外,此时会发布一个 header frame,但不添加事件。 - **仅追加。** 已 flush 事件绝不重写。后续原始批次 append 行;压缩批次 append 一个 frame。两条路径都执行 `fsync`,并在捕获到写入或同步失败时回滚到之前字节长度。 - **崩溃恢复:保留有效尾部工作。**`load` 验证每个完整压缩 frame,并扫描解压 JSONL。最后 frame 结构不完整时,读取器保留其完整解码记录,从 frame 开头截断,并使用共享[持久化约定](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md) 需要的合成工具、步骤和轮次 closer 重新编码这些记录。原始 mode 从第一个不完整行截断。已经存在却没有完整 header frame 的压缩工件、完整 frame 中的 checksum/解压失败,或位于最后已提交的 `turn/end` 处或之前的缺陷都属于损坏,会被拒绝。 - **非修改式检查。**`inspect()` 返回不可变、平衡的逻辑视图,并可在内存中合成恢复 closer,但不会截断不完整尾部或更改轻量修订。 diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index f05adbe942..2b0d5878b1 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -21,7 +21,7 @@ import { type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' -import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { encodeSegment, eventLines, logPath, logSuffix, parseHeaderMeta, projectDir, scanLog, sessionDir, SessionLogScanner, toHeaderLine, @@ -177,6 +177,10 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi return this.coordinator.create(meta) } + override ensureMaterialized(session: Session): Promise { + return this.coordinator.ensureMaterialized(session) + } + append(id: SessionId, events: readonly SessionEvent[]): Promise { return this.coordinator.append(id, events) } @@ -428,6 +432,11 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi } } + /** Materialize a header-only JSONL artifact for an explicitly durable empty session. */ + async materializeHeader(meta: SessionHeader): Promise { + await this.materialize(meta, []) + } + /** * Make a crash repair durable: truncate a torn tail, restore complete events * decoded from it, then append synthetic closers. Two fsync'd steps — the seam @@ -618,6 +627,9 @@ export class JsonlSessionPersistence extends SessionPersistence implements Persi /** Encode the header and first batch without combining their frame boundaries. */ private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise { const header = JSON.stringify(toHeaderLine(meta)) + '\n' + if (events.length === 0) { + return this.compression === 'none' ? header : compressZstdFrame(header) + } const body = eventLines(events, this.packChunks) + '\n' if (this.compression === 'none') return header + body const headerFrame = await compressZstdFrame(header) diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index 685a966650..3b10843562 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -286,6 +286,27 @@ describe('JsonlSessionPersistence: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('materializes an explicitly durable empty live session without an event row', async () => { + const id = SessionId('durable-empty') + const session = ctx.sessions.create(id, { meta: { cwd: '/work' } }) + + await ctx.sessionPersistence.ensureMaterialized(session) + + expect(await readFile(rawLogPath(root, '/work', id), 'utf8')).toBe(`${JSON.stringify(toHeaderLine(session.header))}\n`) + await expect(ctx.sessionPersistence.load(id)).resolves.toEqual({ meta: session.header, events: [] }) + }) + + it('delegates direct preparation through the JSONL provider', async () => { + const m = meta('direct-prepare', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + const preparation = await ctx.sessionPersistence.prepare(m.id) + + expect(preparation.session.header).toMatchObject(m) + preparation[Symbol.dispose]() + }) + it('readRaw returns the stored artifact text verbatim with its original filename', async () => { const m = meta('raw-read', '/work') await ctx.sessionPersistence.create(m) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b9cced0087..7741a8751d 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -332,6 +332,19 @@ describe('Zstandard frame structure', () => { }) describe('JsonlSessionPersistence: default Zstandard encoding', () => { + it('materializes an explicitly durable empty session as one header frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const session = ctx.sessions.create(SessionId('empty-zstd'), { meta: { cwd: '/work' } }) + + await ctx.sessionPersistence.ensureMaterialized(session) + + const buffer = await readFile(logPath(root, '/work', session.id, 'zstd')) + expect(scanZstdFrames(buffer).frames).toHaveLength(1) + expect((await decodeCompleteFrames(buffer)).toString()).toBe(`${JSON.stringify(toHeaderLine(session.header))}\n`) + await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] }) + }) + it('writes .jsonl.zstd by default with one header frame and one first-batch frame', async () => { const root = await freshRoot() const ctx = await mount(root) diff --git a/packages/session/session-persistence-sqlite/README.i18n.yaml b/packages/session/session-persistence-sqlite/README.i18n.yaml index b331013806..db1e569112 100644 --- a/packages/session/session-persistence-sqlite/README.i18n.yaml +++ b/packages/session/session-persistence-sqlite/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence-sqlite/README.md -README.md: ba005d79771bcbc2c0c1632da77d694aa3a18c07 -README.zh.md: 96a396d237a8abf263c50c46c8c7b23054d6e7aa +README.md: e105b01361f8129350bf820d50ef0acd1f8ee410 +README.zh.md: 579eb43e430668a1dbb5b9c2473e2fd7dd40a567 diff --git a/packages/session/session-persistence-sqlite/README.md b/packages/session/session-persistence-sqlite/README.md index ba005d7977..e105b01361 100644 --- a/packages/session/session-persistence-sqlite/README.md +++ b/packages/session/session-persistence-sqlite/README.md @@ -34,7 +34,7 @@ interface Config { } ``` -`journalMode` defaults to `wal`, `busyTimeoutMs` defaults to `5,000`, `preparedSessionCacheSize` defaults to `5`, and `writeBatchMaxDelayMs` defaults to `200`. The timeout bounds each synchronous SQLite lock wait. Because SQLite may return `SQLITE_BUSY` immediately while changing journal mode, cold open yields between attempts and starts no further attempt after an open-relative retry cutoff. An in-progress synchronous SQLite call may finish after that cutoff. The provider disables trusted schemas and memory-mapped I/O on every connection, then reads both settings back. The selected journal mode is also read back and must match; in-memory databases explicitly accept SQLite's `memory` result. After selecting the journal, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. On POSIX, the database parent and file must be owned by the current user, the parent must not be group/world-writable, and the file must have no group/world permissions. Symbolic links and non-regular files reject. Windows also rejects symbolic links and non-regular files, but deployments remain responsible for restricting the directory and file ACLs to the harness user. Path and ownership failures reject plugin initialization. Node SQLite loads lazily on the first persistence operation; the import suppresses only Node 22's exact SQLite `ExperimentalWarning`. Store-identity and schema failures reject that operation before data is exposed or mutated. +`journalMode` defaults to `wal`, `busyTimeoutMs` defaults to `5,000`, `preparedSessionCacheSize` defaults to `5`, and `writeBatchMaxDelayMs` defaults to `200`. The timeout bounds each synchronous SQLite lock wait. Because SQLite may return `SQLITE_BUSY` immediately while changing journal mode, cold open yields between attempts and starts no further attempt after an open-relative retry cutoff. An in-progress synchronous SQLite call may finish after that cutoff. The provider disables trusted schemas and memory-mapped I/O on every connection, then reads both settings back. The selected journal mode is also read back and must match; in-memory databases explicitly accept SQLite's `memory` result. After selecting the journal, the provider pins `synchronous=FULL` and verifies it so SQLite build defaults cannot weaken committed-append durability. On POSIX, the database parent and file must be owned by the current user, the parent must not be group/world-writable, and the file must have no group/world permissions. Symbolic links and non-regular files reject. Windows also rejects symbolic links and non-regular files, but deployments remain responsible for restricting the directory and file ACLs to the harness user. Path and ownership failures reject plugin initialization. Node SQLite loads lazily on the first persistence operation; the import suppresses only Node 22's exact SQLite `ExperimentalWarning`. Store-identity and schema failures reject that operation before data is exposed or mutated. `ensureMaterialized` inserts a metadata row with zero event rows, while ordinary `create` remains lazy until the first append. ## Model Experience diff --git a/packages/session/session-persistence-sqlite/README.zh.md b/packages/session/session-persistence-sqlite/README.zh.md index 96a396d237..579eb43e43 100644 --- a/packages/session/session-persistence-sqlite/README.zh.md +++ b/packages/session/session-persistence-sqlite/README.zh.md @@ -34,7 +34,7 @@ interface Config { } ``` -`journalMode` 默认为 `wal`,`busyTimeoutMs` 默认为 `5,000`,`preparedSessionCacheSize` 默认为 `5`,`writeBatchMaxDelayMs` 默认为 `200`。该超时限制每次同步 SQLite 锁等待的时长。SQLite 在切换 journal mode 时可能立即返回 `SQLITE_BUSY`,因此冷打开会在尝试之间让出执行,并在从打开时开始计算的重试截止点后不再发起新尝试。正在执行的同步 SQLite 调用可能在该截止点之后才完成。提供方会在每个连接上禁用可信 schema 与内存映射 I/O,然后读回这两项设置。提供方还会读回所选 journal mode 并要求它匹配;内存数据库显式接受 SQLite 返回的 `memory`。选择 journal 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。在 POSIX 上,数据库父目录和文件必须归当前用户所有,父目录不得允许组或其他用户写入,文件不得授予组或其他用户任何权限。符号链接和非普通文件会被拒绝。Windows 同样拒绝符号链接与非普通文件,但部署方仍负责把目录和文件 ACL 限制给 harness 用户。路径与所有权错误会拒绝插件初始化。Node SQLite 在第一次持久化操作时才加载;导入时只抑制 Node 22 精确的 SQLite `ExperimentalWarning`。存储身份与 schema 错误会在暴露或变更数据前拒绝该操作。 +`journalMode` 默认为 `wal`,`busyTimeoutMs` 默认为 `5,000`,`preparedSessionCacheSize` 默认为 `5`,`writeBatchMaxDelayMs` 默认为 `200`。该超时限制每次同步 SQLite 锁等待的时长。SQLite 在切换 journal mode 时可能立即返回 `SQLITE_BUSY`,因此冷打开会在尝试之间让出执行,并在从打开时开始计算的重试截止点后不再发起新尝试。正在执行的同步 SQLite 调用可能在该截止点之后才完成。提供方会在每个连接上禁用可信 schema 与内存映射 I/O,然后读回这两项设置。提供方还会读回所选 journal mode 并要求它匹配;内存数据库显式接受 SQLite 返回的 `memory`。选择 journal 后,提供方会把 `synchronous` 固定为 `FULL` 并验证该设置,避免 SQLite 构建默认值削弱已提交追加的持久性。在 POSIX 上,数据库父目录和文件必须归当前用户所有,父目录不得允许组或其他用户写入,文件不得授予组或其他用户任何权限。符号链接和非普通文件会被拒绝。Windows 同样拒绝符号链接与非普通文件,但部署方仍负责把目录和文件 ACL 限制给 harness 用户。路径与所有权错误会拒绝插件初始化。Node SQLite 在第一次持久化操作时才加载;导入时只抑制 Node 22 精确的 SQLite `ExperimentalWarning`。存储身份与 schema 错误会在暴露或变更数据前拒绝该操作。`ensureMaterialized` 会插入零事件行的元数据行;普通 `create` 仍保持惰性,直到第一次 append。 ## 模型体验 diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index 5d6c3bb674..50204be7ab 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -7,6 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { + Session, SessionEvent, SessionHeader, SessionId, @@ -98,6 +99,10 @@ export class SqliteSessionPersistence extends SessionPersistence { return this.coordinator.create(meta) } + override ensureMaterialized(session: Session): Promise { + return this.coordinator.ensureMaterialized(session) + } + append(id: SessionId, events: readonly SessionEvent[]): Promise { return this.coordinator.append(id, events) } diff --git a/packages/session/session-persistence-sqlite/src/store.ts b/packages/session/session-persistence-sqlite/src/store.ts index 8e3e578cce..c28a9e2fa7 100644 --- a/packages/session/session-persistence-sqlite/src/store.ts +++ b/packages/session/session-persistence-sqlite/src/store.ts @@ -198,6 +198,19 @@ export class SqliteStore implements PersistenceBackend { } } + async materializeHeader(meta: SessionHeader): Promise { + await this.open() + this.db.exec(sql('begin-immediate')) + try { + validateSchemaForMutation(this.databaseConstructor, this.db, this.databasePath) + this.writeRow(meta) + this.db.exec(sql('commit')) + } catch (error: unknown) { + /* v8 ignore next -- validate/write failure uses the same transaction rollback path covered by append and repair. */ + this.rollback(error, 'materialize empty session') + } + } + async commitRepair( meta: SessionHeader, tornMarker: number | undefined, diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index f49d5d8e69..c05d9aa779 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -662,6 +662,20 @@ describe('SessionPersistenceSqlite schema ownership', () => { }) describe('SessionPersistenceSqlite edge behavior', () => { + it('materializes an explicitly durable empty live session', async () => { + const path = await freshDbPath('dsh-sqlite-empty-') + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceSqlite, { path }) + const session = ctx.sessions.create(SessionId('empty'), { meta: { cwd: '/workspace' } }) + + await ctx.sessionPersistence.ensureMaterialized(session) + + await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header]) + await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] }) + await ctx.fiber.dispose() + }) + it('keeps a fresh database unopened until the first persistence operation', async () => { const path = await freshDbPath('dsh-sqlite-lazy-') const ctx = new Context() diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index d5623dca3f..9a9b20071e 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md -README.md: 335f40e7439fc4a49fa49bb4541f101250468d1b -README.zh.md: 7cda3f271398fca8279fcdfa92801aa219e9e8ed +README.md: 76df109936070e0dd7afb18e98c6c94855be4f21 +README.zh.md: 6c366bf8287e4c96052935e46410fd27d28714f5 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 335f40e743..76df109936 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -14,12 +14,13 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `supportsRawArtifacts: boolean` | State explicitly whether this backend exposes one verbatim artifact per session. Consumers check this capability before calling `readRaw`; `false` is not session absence. | | `readRaw(id, signal?): Promise` | Read a supported backend's own artifact text verbatim, decoded from its physical encoding but never reconstructed from events. `undefined` means only that the requested artifact is absent; an unsupported backend rejects. | | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | +| `ensureMaterialized(session): Promise` | Explicitly make an exact live session durable even with zero events, without inventing an event. Lifecycle frontends use this only when the empty session itself is a resumable resource; ordinary creation remains lazy. | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | | `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. | -| `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | +| `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event session is absent until a consumer explicitly materializes it. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | ## Invariants every backend must honor @@ -52,6 +53,7 @@ The `PersistenceBackend` hooks (the only contract between the coordi | `readStoredRevision(id, signal?)` | Read the current source-qualified revision for one id without loading its event log. It uses the same revision representation as `loadStored` and returns `undefined` when the id is absent. | | `loadStoredFrom?(id, fromSeq, signal?)` | Optional seek-capable suffix read behind the service's `readFrom`: the header plus stored events with `seq >= fromSeq`, non-mutating, no torn marker. SQLite implements it (`WHERE seq >= ?`); a backend that omits it gets the coordinator's fallback — `loadStored` plus a forward skip. | | `appendBatch(meta, events, isMaterialized)` | Durably append a contiguous batch, lazily materializing ATOMICALLY when not yet materialized. | +| `materializeHeader?(meta)` | Durably create a header-only artifact for `ensureMaterialized`; required by providers that support durable empty sessions. | | `commitRepair(meta, tornMarker, closers)` | Make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined` — a marker may be falsy, e.g. seq/offset `0`) and append `closers`. NOT required to be atomic. Used by load (truncate + closers) and live-adoption (truncate only). | | `list(signal?)` | List all stored metadata, observing optional cancellation. | | `close?()` | Optional lifecycle teardown (e.g. close a db handle), awaited after the dispose drain. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 7cda3f2713..6c366bf828 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -14,12 +14,13 @@ | `supportsRawArtifacts: boolean` | 明确说明该后端是否为每个会话暴露一份逐字工件。Consumer 在调用 `readRaw` 前检查此能力;`false` 并不表示会话缺失。 | | `readRaw(id, signal?): Promise` | 读取受支持后端自身的逐字工件文本;只解码物理编码,绝不从事件重建。`undefined` 仅表示所请求工件缺失;不支持的后端会拒绝。 | | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | +| `ensureMaterialized(session): Promise` | 在不虚构事件的情况下,显式使一个确切 live session 即使零事件也保持持久。只有当空会话本身是可恢复资源时,生命周期前端才使用它;普通创建仍保持延迟实体化。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose(资源释放)时将未发布 reservation 释放回有界缓存。 | | `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | | `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 | -| `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | +| `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件会话在 consumer 显式实体化前不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | ## 每个后端必须遵守的不变量 @@ -52,6 +53,7 @@ | `readStoredRevision(id, signal?)` | 在不加载事件日志的情况下读取一个 id 当前的来源限定修订值。它使用与 `loadStored` 相同的修订值表示;id 不存在时返回 `undefined`。 | | `loadStoredFrom?(id, fromSeq, signal?)` | 服务 `readFrom` 背后的可选可寻址后缀读取:返回 header 和 `seq >= fromSeq` 的已存储事件,非修改式、无撕裂标记。SQLite 实现它(`WHERE seq >= ?`);不实现的后端使用协调器回退——`loadStored` 加向前跳过。 | | `appendBatch(meta, events, isMaterialized)` | 持久追加连续批次;尚未实体化时以原子方式延迟实体化。 | +| `materializeHeader?(meta)` | 为 `ensureMaterialized` 持久创建仅含 header 的 artifact;支持持久空会话的 provider 必须实现。 | | `commitRepair(meta, tornMarker, closers)` | 使崩溃修复持久:截断撕裂尾部(当且仅当 `tornMarker !== undefined`;标记可为 falsy,例如 seq/offset `0`),并追加 `closers`。不要求原子性。由 load(截断 + closer)和活动会话接管(仅截断)使用。 | | `list(signal?)` | 列出全部已存储元数据,并遵循可选的取消信号。 | | `close?()` | 可选生命周期拆卸(例如关闭 db 句柄),在 dispose drain 后等待其完成。 | diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index 19a3128e9a..76afe626cf 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -175,6 +175,9 @@ export interface PersistenceBackend { */ loadStoredFrom?(id: SessionId, fromSeq: number, signal?: AbortSignal): Promise + /** Durably create an empty header-only session artifact. */ + materializeHeader?(meta: SessionHeader): Promise + /** * Durably append a CONTIGUOUS batch, lazily materializing the session first * when `!isMaterialized`. The materialize-write and the first event batch MUST @@ -642,6 +645,26 @@ export class PersistenceCoordinator { return this.serialize(snapshot.id, () => this.createCore(snapshot)) } + /** + * Materialize one exact live session without inventing a session event. + * @param session - live session already registered through the write path. + */ + async ensureMaterialized(session: Session): Promise { + await this.flush(session) + await this.serialize(session.id, async () => { + const state = this.states.get(session.id) + /* v8 ignore next -- successful live flush always initializes the exact session state. */ + if (state === undefined) throw new Error(`session "${session.id}" is not registered for persistence`) + if (state.materialized) return + if (this.backend.materializeHeader === undefined) { + throw new Error('session persistence backend cannot materialize an empty session') + } + await this.backend.materializeHeader(state.meta) + state.materialized = true + this.preparations.invalidate(session.id) + }) + } + private async createCore(meta: SessionHeader): Promise { // Do NOT clobber an existing session: the SessionId IS the identity. if (this.states.has(meta.id) || this.preparations.has(meta.id)) { @@ -977,7 +1000,7 @@ export class PersistenceCoordinator { const state = this.states.get(session.id) /* v8 ignore next -- successful flush always publishes this live session's durable state */ if (state === undefined) throw new Error(`session "${session.id}" lost persistence state during load`) - if (events.length === 0) throw new Error(`session "${session.id}" not found`) + if (events.length === 0 && !state.materialized) throw new Error(`session "${session.id}" not found`) if (interruptedTurnClosers(events).length > 0) { throw new Error(`cannot load session "${session.id}" while its live turn is open; use the live Session or wait for the turn to close`) } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index d579dc46d9..e9c75107bb 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -7,7 +7,7 @@ import { Context, Service } from '@deepseek-ai/cordis' import { SessionPreparation } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' // Re-export the metadata vocabulary so Consumers import it from the Service Definition. @@ -132,6 +132,16 @@ export abstract class SessionPersistence extends Service { */ abstract create(meta: SessionHeader): Promise + /** + * Ensure a live session has a durable header even when it has no events. + * Ordinary sessions remain lazily materialized; lifecycle frontends call + * this only when an empty session itself is a durable resumable resource. + * @param _session - exact live session whose registered header is materialized. + */ + ensureMaterialized(_session: Session): Promise { + return Promise.reject(new Error('this session persistence backend cannot materialize an empty session')) + } + /** * Durably persist a batch of events. Honors the append-only and contiguous- * seq contracts: the first event's `seq` MUST equal the stored next-seq diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index 935fea2903..d82627ffe9 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -97,6 +97,10 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend return this.coordinator.create(m) } + override ensureMaterialized(session: Session): Promise { + return this.coordinator.ensureMaterialized(session) + } + append(id: SessionId, events: readonly SessionEvent[]): Promise { return this.coordinator.append(id, events) } @@ -151,6 +155,11 @@ class MemoryPersistence extends SessionPersistence implements PersistenceBackend } } + materializeHeader(m: SessionHeader): Promise { + this.store.set(m.id, { meta: structuredClone(m), events: [] }) + return Promise.resolve() + } + async commitRepair(m: SessionHeader, _tornMarker: undefined, closers: readonly SessionEvent[]): Promise { // No torn tails in a Map store, so `_tornMarker` is always undefined; only the // synthetic closers are appended (the same DELETE+INSERT a DB backend does, @@ -1780,6 +1789,72 @@ describe('PersistenceCoordinator retirement', () => { }) describe('SessionPersistence service registration', () => { + it('materializes an explicitly durable live session without adding events', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + const session = ctx.sessions.create(SessionId('durable-empty'), { meta: { cwd: '/workspace' } }) + + await ctx.sessionPersistence.ensureMaterialized(session) + await ctx.sessionPersistence.ensureMaterialized(session) + + await expect(ctx.sessionPersistence.list()).resolves.toEqual([session.header]) + await expect(ctx.sessionPersistence.load(session.id)).resolves.toEqual({ meta: session.header, events: [] }) + await ctx.fiber.dispose() + }) + + it('fails loud when a direct backend does not support empty materialization', async () => { + const session = Session.create(SessionId('unsupported-empty')) + await expect(SessionPersistence.prototype.ensureMaterialized.call({} as SessionPersistence, session)) + .rejects.toThrow(/cannot materialize an empty session/) + }) + + it('fails loud when a coordinator backend omits empty materialization', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + let coordinator!: PersistenceCoordinator + await ctx.plugin(Object.assign((inner: Context) => { + coordinator = new PersistenceCoordinator(inner, new ControlledBackend()) + }, { inject: ['sessions'] })) + const session = ctx.sessions.create(SessionId('unsupported-coordinator')) + + await expect(coordinator.ensureMaterialized(session)).rejects.toThrow(/cannot materialize an empty session/) + await ctx.fiber.dispose() + }) + + it('accepts current aborted and error turn endings without legacy conversion', async () => { + const store: MemoryStore = new Map() + const ctx = new Context() + await ctx.plugin(SessionStore) + const endings: SessionEvent[] = [ + { + type: 'turn/end', seq: 5, time: 6, + data: { turn: 1, reason: { kind: 'aborted', reason: { kind: 'user' } } }, + }, + { + type: 'turn/end', seq: 5, time: 6, + data: { turn: 1, reason: { kind: 'error', error: { message: 'failed', code: 'UNKNOWN' } } }, + }, + ] + for (const [index, ending] of endings.entries()) { + const m = meta(`current-ending-${index}`) + store.set(m.id, { meta: m, events: [...oneTurnLog().slice(0, -1), ending] }) + } + await ctx.plugin(MemoryPersistence, { store }) + await Promise.all([...store.keys()].map(id => ctx.sessionPersistence.load(SessionId(id)))) + await ctx.fiber.dispose() + }) + + it('rejects preparing an id that already has a live Session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + const session = ctx.sessions.create(SessionId('live-prepare-conflict')) + + await expect(ctx.sessionPersistence.prepare(session.id)).rejects.toThrow(/while it is live/) + await ctx.fiber.dispose() + }) + it('provides a cancellation-aware default preparation for simple backends', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 5e9805e239..ff50f40a87 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -42,7 +42,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@agentclientprotocol/sdk": "0.25.1", + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5ba7bc1718..445e7d0de9 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -11,15 +11,11 @@ import { randomUUID } from 'node:crypto' import { Readable as NodeReadable, Writable as NodeWritable } from 'node:stream' import { - ClientSideConnection, + client as createAcpClientApp, + methods, ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type ContentBlock as AcpContentBlock, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, type StopReason, } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -239,8 +235,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { + const clientApp = createAcpClientApp({ name: 'deepseek-harness-subagent-acp' }) + .onNotification(methods.client.session.update, ({ params }) => { const update = params.update if (update.sessionUpdate === 'agent_message_chunk') { fold.pushText(acpContentText(update.content)) @@ -248,8 +244,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Other updates (thoughts, tool calls, plans) are consumed but not // surfaced — the subagent returns only its final answer. return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { // Auto-answer by the configured policy. `allow` selects the first option // whose kind is `allow_once` or `allow_always`; if the child offered none (or we // reject), answer `cancelled` so the child does not proceed. @@ -260,16 +256,13 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } } return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) + }) - const conn = new ClientSideConnection( - makeClient, - ndJsonStream( - NodeWritable.toWeb(child.stdin) as WritableStream, - NodeReadable.toWeb(child.stdout) as ReadableStream, - ), - ) + const connection = clientApp.connect(ndJsonStream( + NodeWritable.toWeb(child.stdin) as WritableStream, + NodeReadable.toWeb(child.stdout) as ReadableStream, + )) + const agent = connection.agent let sessionId: string | undefined // Cancellation settles the result without waiting for a cooperative child. @@ -281,7 +274,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe signalCancelSettled() // Best-effort ACP cancel; process teardown remains authoritative. /* v8 ignore next */ - if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ }) + if (sessionId !== undefined) { + void agent.notify(methods.agent.session.cancel, { sessionId }).catch(() => { /* child gone / no session */ }) + } } const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) @@ -294,16 +289,17 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe try { await Promise.race([ (async (): Promise => { - await conn.initialize({ + await agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION, // Advertise NO optional client capabilities (no fs, no terminal): the // child self-serves in its own process. clientCapabilities: {}, }) - const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) + const session = await agent.request(methods.agent.session.new, { cwd: spec.cwd, mcpServers: [] }) const returnedSessionId: unknown = Reflect.get(session, 'sessionId') if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id') sessionId = returnedSessionId + /* v8 ignore next -- cancelSettled wins the startup race before this post-response guard can settle it. */ if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), spawnFailed, @@ -326,7 +322,10 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // Race the remote turn against local cancellation. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. - const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) }) + const promptResult = await agent.request(methods.agent.session.prompt, { + sessionId: remoteSessionId, + prompt: toAcpPrompt(request.prompt), + }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index de5900906a..9e36353931 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -55,10 +55,11 @@ import { randomUUID } from 'node:crypto' import { existsSync, writeFileSync } from 'node:fs' import { Readable, Writable } from 'node:stream' import { - AgentSideConnection, + agent as createAcpAgentApp, + methods, ndJsonStream, PROTOCOL_VERSION, - type Agent, + type AgentContext, type CancelNotification, type AuthenticateRequest, type InitializeRequest, @@ -67,6 +68,7 @@ import { type NewSessionResponse, type PromptRequest, type PromptResponse, + type RequestPermissionResponse, type StopReason, } from '@agentclientprotocol/sdk' @@ -93,7 +95,7 @@ const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && proce ? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO } : undefined -function makeAgent(conn: AgentSideConnection): Agent { +function makeAgent() { // Pending cancel resolver for the HANG path: a `session/cancel` resolves the // prompt with `cancelled`. let resolveCancel: ((reason: StopReason) => void) | undefined @@ -104,7 +106,7 @@ function makeAgent(conn: AgentSideConnection): Agent { initialize(_params: InitializeRequest): Promise { return Promise.resolve({ protocolVersion: PROTOCOL_VERSION, - agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } }, + agentCapabilities: { promptCapabilities: { image: false, audio: false, embeddedContext: false } }, authMethods: [], }) }, @@ -124,7 +126,7 @@ function makeAgent(conn: AgentSideConnection): Agent { // No auth methods advertised; nothing to do. return Promise.resolve() }, - async prompt(params: PromptRequest): Promise { + async prompt(params: PromptRequest, conn: AgentContext): Promise { if (CRASH_ON_PROMPT) process.exit(1) if (WANT_PERMISSION) { // Ask the client to approve before answering; honor its decision. Under @@ -136,11 +138,11 @@ function makeAgent(conn: AgentSideConnection): Agent { { optionId: 'yes', name: 'Allow', kind: 'allow_once' as const }, { optionId: 'no', name: 'Reject', kind: 'reject_once' as const }, ] - const decision = await conn.requestPermission({ + const decision = await conn.request(methods.client.session.requestPermission, { sessionId: params.sessionId, toolCall: { toolCallId: 'mock-call', title: 'mock side effect' }, options, - }) + }) as RequestPermissionResponse if (decision.outcome.outcome === 'cancelled') { return { stopReason: 'cancelled' } } @@ -148,14 +150,14 @@ function makeAgent(conn: AgentSideConnection): Agent { // Optionally emit a NON-message update first (a thought), so the client's // sessionUpdate sees an update it must consume-but-not-accumulate. if (THOUGHT) { - await conn.sessionUpdate({ + await conn.notify(methods.client.session.update, { sessionId: params.sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } }, }) } // Stream the canned assistant text as one chunk (or, under MOCK_ECHO_CWD, // the observable process cwd + announced session cwd). - await conn.sessionUpdate({ + await conn.notify(methods.client.session.update, { sessionId: params.sessionId, update: { sessionUpdate: 'agent_message_chunk', @@ -195,13 +197,20 @@ function makeAgent(conn: AgentSideConnection): Agent { } } -new AgentSideConnection( - makeAgent, - ndJsonStream( +const implementation = makeAgent() +createAcpAgentApp({ name: 'dsh-subagent-acp-test-agent' }) + .onRequest(methods.agent.initialize, ({ params }) => implementation.initialize(params)) + .onRequest(methods.agent.authenticate, async ({ params }) => { + await implementation.authenticate(params) + return {} + }) + .onRequest(methods.agent.session.new, ({ params }) => implementation.newSession(params)) + .onRequest(methods.agent.session.prompt, ({ params, client }) => implementation.prompt(params, client)) + .onNotification(methods.agent.session.cancel, ({ params }) => implementation.cancel(params)) + .connect(ndJsonStream( Writable.toWeb(process.stdout) as WritableStream, Readable.toWeb(process.stdin) as ReadableStream, - ), -) + )) // Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process // neither quiesces on EOF nor dies on the graceful signal — exercising the diff --git a/packages/test-support/acp-snapshot/package.json b/packages/test-support/acp-snapshot/package.json index 9a2216fcf4..cffac8f1d3 100644 --- a/packages/test-support/acp-snapshot/package.json +++ b/packages/test-support/acp-snapshot/package.json @@ -32,7 +32,7 @@ ], "license": "MIT", "dependencies": { - "@agentclientprotocol/sdk": "0.25.1", + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/dsh-loader-smoke": "workspace:*", "vitest": "^4.1.8" }, diff --git a/packages/test-support/acp-snapshot/src/harness.ts b/packages/test-support/acp-snapshot/src/harness.ts index 21800862fc..9541c8db78 100644 --- a/packages/test-support/acp-snapshot/src/harness.ts +++ b/packages/test-support/acp-snapshot/src/harness.ts @@ -6,7 +6,7 @@ * It boots the REAL agent bin subprocess via the cordis Loader (so the * export-shape bug class stays guarded — see docs/postmortem/0001), drives it * over real ACP JSON-RPC stdio with a deterministic input script, tees raw - * stdout (for the expected-output and purity checks) into an SDK `ClientSideConnection`, + * stdout (for the expected-output and purity checks) into an SDK client app, * and — in record mode — harvests the persisted session JSONL after a graceful * shutdown flush. The pure normalizers in ./normalize.ts turn the captured * stdout frames and the session-log events into stable, snapshot-able text. @@ -23,14 +23,18 @@ import { tmpdir } from 'node:os' import { basename, dirname, join, delimiter } from 'node:path' import { vi } from 'vitest' import { - ClientSideConnection, PROTOCOL_VERSION, type ContentBlock as AcpContentBlock, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' -import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from './launcher.ts' +import { + launchAcpTestAgent, + type AcpTestClient, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from './launcher.ts' export type { AgentUnderTest } from './launcher.ts' @@ -377,7 +381,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise /** Drive one input step over the client connection. */ async function runStep( - client: ClientSideConnection, + client: AcpTestClient, step: InputStep, cwd: string, waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, diff --git a/packages/test-support/acp-snapshot/src/launcher.ts b/packages/test-support/acp-snapshot/src/launcher.ts index c4570c90d1..865835cbfd 100644 --- a/packages/test-support/acp-snapshot/src/launcher.ts +++ b/packages/test-support/acp-snapshot/src/launcher.ts @@ -11,12 +11,26 @@ import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { join } from 'node:path' import { Readable, Writable } from 'node:stream' import { - ClientSideConnection, + client as createAcpClientApp, + methods, ndJsonStream, - type Agent as AcpAgent, - type Client, + type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, + type InitializeRequest, + type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, type RequestPermissionRequest, type RequestPermissionResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' @@ -49,6 +63,19 @@ export interface AcpTestLaunchOptions { requestPermission?: (params: RequestPermissionRequest) => Promise } +/** Stable ACP methods used by the subprocess test harness. */ +export interface AcpTestClient { + readonly closed: Promise + initialize: (params: InitializeRequest) => Promise + newSession: (params: NewSessionRequest) => Promise + listSessions: (params: ListSessionsRequest) => Promise + resumeSession: (params: ResumeSessionRequest) => Promise + closeSession: (params: CloseSessionRequest) => Promise + setSessionConfigOption: (params: SetSessionConfigOptionRequest) => Promise + prompt: (params: PromptRequest) => Promise + cancel: (params: CancelNotification) => Promise +} + /** A running ACP test process and its captured client-side outputs. */ export interface LaunchedAcpTestAgent { /** The child process, exposed for process-level assertions. */ @@ -56,7 +83,7 @@ export interface LaunchedAcpTestAgent { /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */ spawned: Promise /** The SDK connection backed by the child's stdio. */ - client: ClientSideConnection + client: AcpTestClient /** Session updates in receive order. */ updates: SessionNotification['update'][] /** Decode all stdout bytes captured so far. */ @@ -152,8 +179,8 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe } const requestPermission = options.requestPermission ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } })) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { + const clientApp = createAcpClientApp({ name: 'deepseek-harness-acp-test-client' }) + .onNotification(methods.client.session.update, ({ params }) => { return trackClientCallback(() => { updates.push(params.update) for (let index = updateWaiters.length - 1; index >= 0; index--) { @@ -173,17 +200,34 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe waiter.resolve(params.update) } }) - }, - requestPermission: params => trackClientCallback(() => requestPermission(params)), - }) - const client = new ClientSideConnection(makeClient, stream) + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => ( + trackClientCallback(() => requestPermission(params)) + )) + const connection = clientApp.connect(stream) + const context = connection.agent + const client: AcpTestClient = { + closed: connection.closed, + initialize: params => context.request(methods.agent.initialize, params), + newSession: params => context.request(methods.agent.session.new, params), + /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */ + listSessions: params => context.request(methods.agent.session.list, params), + /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */ + resumeSession: params => context.request(methods.agent.session.resume, params), + /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */ + closeSession: params => context.request(methods.agent.session.close, params), + /* v8 ignore next -- exercised by the real-process ACP control-surface conformance e2e. */ + setSessionConfigOption: params => context.request(methods.agent.session.setConfigOption, params), + prompt: params => context.request(methods.agent.session.prompt, params), + cancel: params => context.notify(methods.agent.session.cancel, params), + } // `exit` only reports the parent process's status. Descendants may retain // inherited stdout/stderr handles and buffered ACP frames may still be // crossing the SDK parser. Node's `close` follows stdio closure; the SDK's // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + const drained = Promise.all([stdioClosed, connection.closed]).then(async () => { // The ACP SDK's readable loop dispatches client callbacks without awaiting // them. Once `closed` settles no new callbacks can start, but callbacks // already in flight still belong to this launch's teardown boundary. @@ -194,7 +238,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the // parser has dispatched every buffered frame. - void client.closed.then(closeUpdateStream) + void connection.closed.then(closeUpdateStream) return { child, diff --git a/packages/test-support/acp-snapshot/src/normalize.ts b/packages/test-support/acp-snapshot/src/normalize.ts index 9583809dfb..8e3919c662 100644 --- a/packages/test-support/acp-snapshot/src/normalize.ts +++ b/packages/test-support/acp-snapshot/src/normalize.ts @@ -7,6 +7,7 @@ */ const SESSION_ID = '{{sessionId}}' +const MESSAGE_ID = '{{messageId}}' const CWD = '{{cwd}}' const SYSTEM = '{{system}}' const TOOLS = '{{tools}}' @@ -184,6 +185,7 @@ function scrubString(value: string, ctx: NormalizeContext, cwdPathMode: CwdPathM /** Recursively scrub a parsed JSON value (strings replaced; structure kept). */ function scrubValue(value: unknown, ctx: NormalizeContext, cwdPathMode: CwdPathMode, key?: string): unknown { if (typeof value === 'string') { + if (key === 'messageId') return MESSAGE_ID const scrubbed = scrubString(value, ctx, cwdPathMode) return cwdPathMode === 'canonical' && key === 'path' ? scrubbed.replaceAll('\\', '/') : scrubbed } diff --git a/packages/test-support/acp-snapshot/tests/normalize.spec.ts b/packages/test-support/acp-snapshot/tests/normalize.spec.ts index c1dca6d618..465a2d0f4f 100644 --- a/packages/test-support/acp-snapshot/tests/normalize.spec.ts +++ b/packages/test-support/acp-snapshot/tests/normalize.spec.ts @@ -48,6 +48,26 @@ describe('normalizeStdout', () => { expect(out).not.toContain(ctx.sessionIds[0] as string) }) + it('keeps standard message identity distinct from session identity', () => { + const raw = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: ctx.sessionIds[0], + update: { + sessionUpdate: 'agent_message_chunk', + messageId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + content: { type: 'text', text: 'done' }, + }, + }, + }) + + const out = normalizeStdout(raw, ctx) + + expect(out).toContain('"sessionId":"{{sessionId}}"') + expect(out).toContain('"messageId":"{{messageId}}"') + }) + it('scrubs cwd at file URI and chained-punctuation boundaries', () => { const raw = JSON.stringify({ jsonrpc: '2.0', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1888628dbc..f8671de95e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -16,8 +16,8 @@ importers: .: devDependencies: '@agentclientprotocol/sdk': - specifier: 0.25.1 - version: 0.25.1(zod@4.4.3) + specifier: 1.4.0 + version: 1.4.0(zod@4.4.3) '@deepseek-ai/dsh-tool-session-query': specifier: workspace:^ version: link:packages/session-query/tool-session-query @@ -789,8 +789,8 @@ importers: packages/acp/acp: dependencies: '@agentclientprotocol/sdk': - specifier: 0.25.1 - version: 0.25.1(zod@4.4.3) + specifier: 1.4.0 + version: 1.4.0(zod@4.4.3) '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -816,9 +816,21 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-mcp-client': + specifier: workspace:^ + version: link:../../mcp/mcp-client '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -3946,6 +3958,9 @@ importers: specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@agentclientprotocol/sdk': + specifier: 1.4.0 + version: 1.4.0(zod@4.4.3) '@deepseek-ai/cordis': specifier: workspace:^ version: link:../../../vendor/cordis @@ -3958,6 +3973,9 @@ importers: '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../acp/acp + '@deepseek-ai/dsh-acp-snapshot': + specifier: workspace:^ + version: link:../../test-support/acp-snapshot '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3988,6 +4006,9 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt + '@deepseek-ai/dsh-token-meter': + specifier: workspace:^ + version: link:../../llm/token-meter '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -5890,6 +5911,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess @@ -7492,8 +7516,8 @@ importers: packages/subagent/subagent-acp: dependencies: '@agentclientprotocol/sdk': - specifier: 0.25.1 - version: 0.25.1(zod@4.4.3) + specifier: 1.4.0 + version: 1.4.0(zod@4.4.3) '@deepseek-ai/schemastery': specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery @@ -8122,8 +8146,8 @@ importers: packages/test-support/acp-snapshot: dependencies: '@agentclientprotocol/sdk': - specifier: 0.25.1 - version: 0.25.1(zod@4.4.3) + specifier: 1.4.0 + version: 1.4.0(zod@4.4.3) '@deepseek-ai/dsh-loader-smoke': specifier: workspace:* version: link:../loader-smoke @@ -9317,8 +9341,8 @@ importers: packages: - '@agentclientprotocol/sdk@0.25.1': - resolution: {integrity: sha512-jx2rF3bdpGwZ75Q/meyEDLLbYmbtxk82Uh9hDCdxDvcEedBnNSF5hZAnL/kJR5VNz56JqwOmqnAqasC84MwwkQ==} + '@agentclientprotocol/sdk@1.4.0': + resolution: {integrity: sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -14940,7 +14964,7 @@ packages: snapshots: - '@agentclientprotocol/sdk@0.25.1(zod@4.4.3)': + '@agentclientprotocol/sdk@1.4.0(zod@4.4.3)': dependencies: zod: 4.4.3