Merge remote-tracking branch 'origin/master' into codex/pr48-repo-hardening-rfcs
# Conflicts: # docs/adr/README.md # docs/rfc/009-session-persistence-and-resumability.md # docs/rfc/README.md # docs/rfc/implemented/2026-06-11-doc-sync-enforcement.md # docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md # examples/acp-agent/tests/acp.e2e.ts # packages/acp/README.md # packages/acp/src/index.ts # packages/acp/tests/stream-update.spec.ts # packages/agent-loop/src/loop.ts # packages/tools/src/index.ts
This commit is contained in:
@@ -5,10 +5,12 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing
|
||||
- **Effect-based registrations**: every contribution (tool, section, adapter, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and `register()` methods return disposers. Never use bare arrays or manual cleanup.
|
||||
- **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants.
|
||||
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning.
|
||||
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races.
|
||||
- **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
|
||||
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races. A plugin shipped via `cordis.yml` also needs at least one test that drives it through the REAL Loader/export path (hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape) — see AGENTS.md § Defensive patterns "Line coverage is not behavior coverage". Real-API (with-key) e2e tests are cheap here (we are DeepSeek) and welcome — write many, especially smoke tests; see AGENTS.md § Secrets / .env.
|
||||
|
||||
Naming notes:
|
||||
- Files `src/index.ts` export the service default + all public types
|
||||
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above)
|
||||
- `src/types.ts` contain only types — no runtime code
|
||||
- Tests live at package level under `tests/`, not `src/__tests__/`
|
||||
- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/README.md`, verifies the event-taxonomy table, and checks markdown wrapping across this file too — but it does NOT catch prose drift (config keys, defaults, error codes), so those stay on the author.
|
||||
|
||||
@@ -20,7 +20,7 @@ dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
|
||||
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge)
|
||||
```
|
||||
|
||||
The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [ADR 0009](../docs/adr/0009-capability-seams.md)).
|
||||
The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/2026-06-13-capability-seams.md)).
|
||||
|
||||
## What goes where
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
# @deepseek-ai/dsh-acp
|
||||
|
||||
The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (RFC 011): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
|
||||
The **Agent Client Protocol (ACP)** bridge: exposes the DeepSeek Harness coding agent as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive it — streaming render, tool-call display, and resumable sessions. **N concurrent sessions per connection** (see [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md)): each maps to its own `LoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave.
|
||||
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT an [ADR 0009](../../docs/adr/0009-capability-seams.md) capability seam. It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../docs/rfc/implemented/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`.
|
||||
|
||||
## Service / plugin
|
||||
|
||||
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
|
||||
|
||||
`inject: ['agents', 'sessions', 'sessionPersistence']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`.
|
||||
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation).
|
||||
|
||||
### Config
|
||||
|
||||
@@ -23,14 +23,14 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
|
||||
|
||||
| ACP method | Harness seam | Notes |
|
||||
|---|---|---|
|
||||
| `initialize` | static | negotiate `protocolVersion`; advertise baseline text/resource-link prompt support, no image/audio/embedded resources, and `loadSession: true` |
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed (RFC 011), keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` are rejected until those scopes are implemented |
|
||||
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, and the requested `cwd` must match it so editor UI and bash execution agree on the workspace. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
|
||||
| `session/prompt` | `agent.send()` | accepts ACP baseline `text` and `resource_link` blocks; rejects image/audio/embedded resources and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `initialize` | static | negotiate `protocolVersion`; advertise baseline prompt capabilities (`text`, plus `resource_link` rendered as text) and `loadSession: true` |
|
||||
| `session/new` | `ctx.agents.create({ sessionId, meta:{cwd} })` | creates a new session/agent; N concurrent sessions are allowed, keyed by id; `cwd` must be absolute (it becomes the session's workspace — see Per-session cwd); non-empty `additionalDirectories` and `mcpServers` rejected |
|
||||
| `session/load` | `ctx.agents.resume(...)` | replays the persisted event log to the client as `session/update` — the USER side (`user/message` → `user_message_chunk`), assistant text/reasoning (`assistant/chunk`), and tool calls/results (`tool/call` + `tool/result`). Re-loading an already-live id is rejected; the id's load slot is reserved (`loadingIds`) BEFORE the async resume so a pipelined load of the SAME id can't leak a second agent (distinct ids load concurrently). The resumed session keeps its PERSISTED header `cwd`, so its bash tools run in the original workspace; the requested `cwd` must be absolute and match the persisted `cwd`. After the async resume a `closed` re-check refuses to install a record if the bridge tore down mid-load |
|
||||
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
|
||||
| `session/cancel` | `agent.abort()` | aborts a running step + settles the prompt `cancelled` for ONLY that session — a cancel never touches another session's stream or prompt (see limitation below) |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay only), `tool_call`/`tool_call_update` |
|
||||
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (title/kind/rawInput/content owned by the TOOL via `presentCall`/`presentResult` — see Tool-call presentation) |
|
||||
|
||||
## Multi-session (RFC 011)
|
||||
## Multi-session
|
||||
|
||||
The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map<sessionId, SessionRecord>` (forward) with a `WeakMap<Agent, sessionId>` reverse map so `agent/*` events — which carry only the `Agent` — demux in O(1). Every `session/event` and `agent/status` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. (Per-session *permission* ownership is reserved for the deferred permission gate — `TODO(rfc010-permission-gate)`.)
|
||||
|
||||
@@ -38,7 +38,22 @@ Background-task isolation rides on `dsh-tool-bash`: bash task ids are global and
|
||||
|
||||
## Per-session cwd
|
||||
|
||||
Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must match it (a mismatch is rejected up front) so the editor never believes tools run in one workspace while bash runs in another. A load whose persisted session has no absolute cwd is also rejected via a metadata-only `list()` check, BEFORE resume constructs an agent. `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` and `mcpServers` are still rejected: widening tool/filesystem/protocol scope is separate work.)
|
||||
Each session runs in its own workspace, recorded as the session's `SessionHeader.cwd`. On `session/new` the (absolute) request `cwd` becomes that header cwd; on `session/load` the resumed session keeps its PERSISTED header cwd and the request `cwd` must be absolute and equal to it, so the editor and bash executor agree on the workspace before an agent is constructed. A load whose persisted session has no absolute cwd is REJECTED up front via a metadata-only `list()` check, BEFORE resume constructs an agent (else bash would silently fall back to the server's launch dir, and a post-resume reject would leak the registered agent). `dsh-tool-bash` then defaults the bash workdir to the calling agent's `session.header.cwd` (an explicit model `workdir` still wins; a relative one resolves against the session cwd; with no session cwd the executor falls back to its own config / `process.cwd()`). So the server no longer has to be launched in the workspace — an editor can open any project folder, and N sessions over one connection can each target a different directory. (`additionalDirectories` is still rejected: widening the tool/filesystem scope beyond the single cwd is a separate sandbox concern.)
|
||||
|
||||
## Tool-call presentation
|
||||
|
||||
How a tool call renders in the editor is owned by the TOOL, not the bridge — the bridge never special-cases tool names. Each tool may declare `presentCall(args)` (pending state: a human-readable `title`, a `kind` for the icon, the salient `rawInput` to show in a detail view, and optional `content` blocks shown alongside) and `presentResult(args, result)` (completed state: an optional replacement `title` and reformatted `content`) on its `dsh-tools` definition. The bridge looks the definition up by name in `ctx.tools` and maps the neutral `ToolCallPresentation`/`ToolResultPresentation` to the ACP `tool_call`/`tool_call_update` wire shapes. A tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` sets the title to the exact `command` ("ls -la src"), `kind: 'execute'`, the `command` as `rawInput`, the model `description` as a `content` text block, and wraps the completed output in a fenced ` ```console ` block. (The command is the title because an editor hides `rawInput` for execute-kind cards — Zed renders it only for non-terminal tools — and the reference adapters likewise use the command as an execute tool's title.)
|
||||
|
||||
The `tool/result` session event carries only `{ callId, content, isError }` — not the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones.
|
||||
|
||||
## Terminal card (capability-gated)
|
||||
|
||||
A tool whose call IS a shell command (`bash`) can render as a real **terminal card** — a working-directory header with the command's output and an exit-status pill — rather than a plain text block. The tool asks for this with the neutral `terminal` field on its presentation (`dsh-tools`: a `{ cwd?, output?, exitCode?, signal? }` shape on `ToolCallPresentation`/`ToolResultPresentation`); the bridge maps it to the Zed `_meta` convention, gated on the client advertising `clientCapabilities._meta.terminal_output` in `initialize`:
|
||||
|
||||
- `tool_call`: `content:[…, {type:'terminal', terminalId}]` + `_meta.terminal_info.{terminal_id, cwd}` — the terminal id is the harness `callId`; the cwd is the tool's explicit absolute `terminal.cwd`, else a relative `terminal.cwd` resolved against the session cwd, else the session's workspace cwd (the bridge fills the default, since the pure tool presenter can't see it). Any pending `content` the tool supplied (e.g. bash's `description`) renders BEFORE the terminal block, so the description sits above the card.
|
||||
- `tool_call_update`: `_meta.terminal_output.{terminal_id, data}` (the captured output) plus `_meta.terminal_exit.{terminal_id, exit_code | signal}` when the tool reported a structured exit. In terminal mode the update's `content` is OMITTED — an ACP `tool_call_update.content` REPLACES the call's content, so sending the fenced text block would clobber the terminal content block from the call.
|
||||
|
||||
When the client does NOT advertise the capability, none of the `_meta`/terminal content is emitted: the `tool_call` shows the `description` content block and the `tool_call_update` carries the ` ```console ` text block (above) as the rendering — so a non-Zed client is never worse off. The `_meta` object is ACP's spec-blessed extensibility point; the specific `terminal_info`/`terminal_output`/`terminal_exit` keys are a Zed convention, not the ACP `terminal/create` sub-protocol (which would make the editor execute the command, bypassing `dsh-bash`'s sandbox/env-scrub/ownership/cwd). Live incremental streaming and command classification are follow-ups. See [the terminal-rendering RFC](../../docs/rfc/implemented/2026-06-18-acp-terminal-and-tool-rendering.md).
|
||||
|
||||
## Settle-exactly-once
|
||||
|
||||
@@ -50,14 +65,14 @@ Teardown reaches quiescence: for EVERY live session settle any pending prompt as
|
||||
|
||||
## Known limitations (tracked TODOs)
|
||||
|
||||
- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. RFC 010/011 stay `proposed` until the gate (and per-session permission ownership) land.
|
||||
- **`TODO(rfc010-permission-gate)`** — the `tools/execute` permission gate (`session/request_permission`) is NOT implemented; tools run with the executor's full authority. The `agent→sessionId` reverse map is in place so the gate can route a permission request (which receives only `exec.agent`) back to its originating session. [ACP support](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md) and [ACP multi-session](../../docs/rfc/proposed/2026-06-14-acp-multi-session.md) stay `proposed` until the gate (and per-session permission ownership) land.
|
||||
- **`TODO(rfc010-cancel-prestep)`** — `session/cancel` (and teardown/disconnect) is honest RPC/UI cancellation plus best-effort abort: a *running* step is aborted, but a turn that is queued-but-not-yet-started (the gap before `agent.abort()` has an `AbortController` to signal) may still run to completion. This same window means disposal/disconnect can return while one short queued turn per session still runs, and a prompt accepted right after a pre-step cancel can be batched into the cancelled turn (the loop merges queued messages into one turn). A loop-level queue-aware cancel will close this; the single-in-flight-per-session rule bounds the worst case to one extra prompt per session.
|
||||
- **`TODO(rfc010-agent-disposal)`** — the factory (`ctx.agents.create`/`resume`) returns no per-agent disposer, so teardown aborts+drains each agent but cannot individually unregister it; on a bare client disconnect (no host dispose) the idled agents linger in `ctx.agents` until the host context disposes. A reconnect spins up a fresh context, so this strands no work; a per-agent disposal seam is the follow-up.
|
||||
- **`additionalDirectories` / `mcpServers`** — rejected. A session operates in its single `cwd` and no MCP bridge is wired yet; silently ignoring requested roots or servers would desync client expectations.
|
||||
- **`additionalDirectories`** — rejected. A session operates in its single `cwd` (see Per-session cwd); widening the tool/filesystem scope to extra roots is a separate sandbox concern, not yet implemented.
|
||||
|
||||
## stdout is the protocol
|
||||
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and RFC 010 § Risks. A stderr exporter is fine for logging.
|
||||
The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loads **no stdout logger** (the console logger writes to stdout and would corrupt the frames). The guarantee is config-only — see `examples/acp-agent` (no console logger) and [ACP support risks](../../docs/rfc/proposed/2026-06-14-acp-agent-client-protocol.md#risks). A stderr exporter is fine for logging.
|
||||
|
||||
## Running
|
||||
|
||||
@@ -68,8 +83,7 @@ The JSON-RPC frames go on stdout, so this plugin MUST run in an example that loa
|
||||
"agent_servers": {
|
||||
"DeepSeek Harness": {
|
||||
"command": "pnpm",
|
||||
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"],
|
||||
"env": { "DEEPSEEK_API_KEY": "sk-…" }
|
||||
"args": ["--dir", "/path/to/deepseek-harness", "run", "demo:acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,16 +29,19 @@
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-loop": "workspace:^",
|
||||
"@deepseek-ai/dsh-bash-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tool-bash": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory,
|
||||
* and `dsh-session-persistence` (for `session/load`). It maps:
|
||||
*
|
||||
* - `initialize` → protocol-version negotiation, baseline prompt capabilities
|
||||
* - `initialize` → protocol-version negotiation, text-only capabilities
|
||||
* - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })`
|
||||
* - `session/load` → `ctx.agents.resume(...)` then replay the event log
|
||||
* - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn
|
||||
@@ -34,7 +34,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { Readable, Writable } from 'node:stream'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { isAbsolute } from 'node:path'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import Schema from 'schemastery'
|
||||
import {
|
||||
AgentSideConnection,
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { ToolCallKind, ToolCallPresentation, ToolRegistry, ToolResultPresentation, ToolTerminal } from '@deepseek-ai/dsh-tools'
|
||||
// Side-effect type import: declaration-merges `ctx.sessionPersistence` onto
|
||||
// Context (the bridge injects it and reads `list()` for load cwd validation).
|
||||
import type {} from '@deepseek-ai/dsh-session-persistence'
|
||||
@@ -73,8 +74,10 @@ import {
|
||||
export const name = 'acp'
|
||||
// The bridge programs against the interface packages only (architecture rule:
|
||||
// plugins never depend on dsh-agent-loop). `sessionPersistence` is required
|
||||
// because `initialize` advertises `loadSession: true`.
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence']
|
||||
// because `initialize` advertises `loadSession: true`. `tools` lets a tool own
|
||||
// how its calls render (`presentCall`/`presentResult`); the bridge looks up the
|
||||
// definition by name and falls back to a generic presentation when absent.
|
||||
export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools']
|
||||
|
||||
/**
|
||||
* Build an ACP "invalid params" error whose human detail rides in the message.
|
||||
@@ -131,6 +134,24 @@ export const Config: Schema<AcpConfig> = Schema.object({
|
||||
interface SessionRecord {
|
||||
sessionId: string
|
||||
agent: Agent
|
||||
/**
|
||||
* Resolves tool-owned presentation for THIS session's tool calls and remembers
|
||||
* each in-flight call's `(name, args)` so the matching `tool/result` can find
|
||||
* its tool. Per-session so two concurrent sessions never cross their in-flight
|
||||
* tool state.
|
||||
*/
|
||||
presenter: ToolPresenter
|
||||
/**
|
||||
* Whether THIS session renders shell tools as terminal cards — snapshotted
|
||||
* from the client's `_meta.terminal_output` capability at session creation
|
||||
* (`session/new`/`session/load`), NOT re-read live. A capability snapshot per
|
||||
* session means the `tool_call` (which registers the terminal) and the matching
|
||||
* `tool_call_update` (which streams its output) ALWAYS agree, even if a later
|
||||
* `initialize` mutates the connection-level capability between them — otherwise
|
||||
* a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal,
|
||||
* result terminal) or clobber the card (call terminal, result non-terminal).
|
||||
*/
|
||||
terminalEnabled: boolean
|
||||
/**
|
||||
* The in-flight `session/prompt`, or `undefined` when none is pending. A
|
||||
* prompt resolves with a {@link StopReason} or rejects with an Error (a
|
||||
@@ -172,6 +193,21 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
const agentName = config.agentName ?? 'deepseek-harness-acp'
|
||||
const agentVersion = config.agentVersion ?? '0.0.1'
|
||||
|
||||
// Capture the injected services NOW, during apply(), while we are inside this
|
||||
// plugin's fiber (where `inject` grants access). The ACP method handlers run
|
||||
// LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is
|
||||
// NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` /
|
||||
// `ctx.sessionPersistence` lazily inside a handler throws "cannot get property
|
||||
// … without inject". Resolving the references here and closing over them keeps
|
||||
// the handlers working regardless of which fiber later invokes them.
|
||||
const agents = ctx.agents
|
||||
const sessionPersistence = ctx.sessionPersistence
|
||||
const logger = ctx.logger
|
||||
const tools = ctx.tools
|
||||
// A new ToolPresenter per session (and a throwaway per load replay), each given
|
||||
// this warn sink so a throwing tool presenter is logged, not propagated.
|
||||
const makePresenter = (): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) })
|
||||
|
||||
// Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId
|
||||
// reverse map so `agent/*` events (which carry only the Agent) demux in O(1).
|
||||
// The two stay in lockstep: a record is added to `sessions` and the agent to
|
||||
@@ -187,6 +223,11 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// await and NOT install a record (which would resurrect a live agent/listeners
|
||||
// after the bridge closed). Checked after every load await.
|
||||
let closed = false
|
||||
// Whether the client advertised the Zed `_meta.terminal_output` capability in
|
||||
// `initialize`. When true, a tool's terminal presentation is rendered as a
|
||||
// terminal card (content + `_meta.terminal_*`); when false, the bridge uses
|
||||
// the tool's text fallback. Set once in `initialize`, read on every tool event.
|
||||
let terminalOutputCap = false
|
||||
|
||||
// Assigned at the bottom, before any agent event can fire (a session only
|
||||
// exists after `newSession`, which the client calls after construction), so
|
||||
@@ -225,7 +266,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
failure (closed pipe), which the in-memory test transport never induces;
|
||||
the swallow is a defensive best-effort guard like the loop's emit traps */
|
||||
void Promise.resolve(conn.sessionUpdate(notification)).catch((error: unknown) => {
|
||||
ctx.logger.warn(`acp: session/update failed: ${String(error)}`)
|
||||
logger.warn(`acp: session/update failed: ${String(error)}`)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -259,7 +300,10 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
ctx.on('session/event', (session, event: SessionEvent) => {
|
||||
const rec = sessions.get(session.header.id)
|
||||
if (rec === undefined) return
|
||||
streamSessionEventUpdate(rec.sessionId, event, notify, { includeUserMessages: false })
|
||||
streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, {
|
||||
enabled: rec.terminalEnabled,
|
||||
cwd: session.header.cwd,
|
||||
}, { includeUserMessages: false })
|
||||
const inflight = rec.inflight
|
||||
if (inflight === undefined) return
|
||||
if (event.type === 'turn/start') {
|
||||
@@ -355,12 +399,18 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// exactly PROTOCOL_VERSION; any other requested version negotiates
|
||||
// down to ours (the client disconnects if it can't speak it).
|
||||
const protocolVersion = params.protocolVersion === PROTOCOL_VERSION ? params.protocolVersion : PROTOCOL_VERSION
|
||||
// Remember the Zed terminal-output `_meta` capability: when set, bash and
|
||||
// other shell tools render as a terminal card (see streamSessionEventUpdate
|
||||
// + the terminal-rendering RFC). `_meta` is `{[k]: unknown} | null`, so
|
||||
// narrow defensively to a strict boolean true.
|
||||
terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true
|
||||
return Promise.resolve({
|
||||
protocolVersion,
|
||||
agentInfo: { name: agentName, version: agentVersion },
|
||||
agentCapabilities: {
|
||||
loadSession: true,
|
||||
// Baseline text/resource_link only: no image/audio/embedded resource, no mcpCapabilities.
|
||||
// Baseline prompt blocks only: text plus resource_link rendered as
|
||||
// text. No image/audio/embeddedContext, no mcpCapabilities.
|
||||
promptCapabilities: { image: false, audio: false, embeddedContext: false },
|
||||
},
|
||||
authMethods: [],
|
||||
@@ -376,15 +426,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
assertOpen()
|
||||
validateWorkspaceParams(params)
|
||||
validateMcpServers(params)
|
||||
const sessionId = randomUUID()
|
||||
const agent = ctx.agents.create({
|
||||
const agent = agents.create({
|
||||
agentId: sessionId,
|
||||
sessionId,
|
||||
meta: { cwd: params.cwd },
|
||||
agentOptions: agentOptions(config),
|
||||
})
|
||||
bySession.set(agent, sessionId)
|
||||
sessions.set(sessionId, { sessionId, agent, inflight: undefined })
|
||||
sessions.set(sessionId, { sessionId, agent, presenter: makePresenter(), terminalEnabled: terminalOutputCap, inflight: undefined })
|
||||
return Promise.resolve({ sessionId })
|
||||
},
|
||||
|
||||
@@ -394,6 +445,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
throw invalidParams(`session ${params.sessionId} is already loaded`)
|
||||
}
|
||||
validateWorkspaceParams(params)
|
||||
validateMcpServers(params)
|
||||
// Reserve THIS id's load slot BEFORE the await. Without it, two pipelined
|
||||
// loads for the same id could both pass the guard above while the first
|
||||
// resume() is pending, then both install a record and leak a second
|
||||
@@ -413,7 +465,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
// always has a cwd (session/new requires it); reject the rest loudly.
|
||||
// (An id unknown to `list()` falls through to resume, which rejects with
|
||||
// the backend's not-found error.)
|
||||
const meta = (await ctx.sessionPersistence.list()).find(m => m.id === params.sessionId)
|
||||
const meta = (await sessionPersistence.list()).find(m => m.id === params.sessionId)
|
||||
if (meta !== undefined && (meta.cwd === undefined || !isAbsolute(meta.cwd))) {
|
||||
throw invalidParams(
|
||||
`session ${params.sessionId} has no absolute persisted cwd; cannot determine its workspace (it predates per-session cwd, or was created without one)`,
|
||||
@@ -422,7 +474,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
if (meta !== undefined && meta.cwd !== params.cwd) {
|
||||
throw invalidParams(`session ${params.sessionId} cwd mismatch: persisted ${meta.cwd}, requested ${params.cwd}`)
|
||||
}
|
||||
const agent = await ctx.agents.resume({
|
||||
const agent = await agents.resume({
|
||||
agentId: params.sessionId,
|
||||
resumeSessionId: params.sessionId,
|
||||
agentOptions: agentOptions(config),
|
||||
@@ -440,14 +492,34 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
throw invalidParams('connection closed during session/load')
|
||||
}
|
||||
bySession.set(agent, params.sessionId)
|
||||
sessions.set(params.sessionId, { sessionId: params.sessionId, agent, inflight: undefined })
|
||||
// Snapshot the terminal capability ONCE for this session (used by both
|
||||
// the replay below and the post-load live stream) so a later
|
||||
// `initialize` can't desync the call/result of a tool card.
|
||||
const terminalEnabled = terminalOutputCap
|
||||
const record: SessionRecord = {
|
||||
sessionId: params.sessionId, agent, presenter: makePresenter(), terminalEnabled, inflight: undefined,
|
||||
}
|
||||
sessions.set(params.sessionId, record)
|
||||
// Replay the persisted event log to the client as session/update. Use
|
||||
// the raw event log (NOT deriveMessages, which drops assistant/chunk
|
||||
// and trace events): RFC 010's load contract reconstructs the streamed
|
||||
// turns — user prompts (user/message → user_message_chunk), assistant
|
||||
// text and reasoning (assistant/chunk), and tool calls/results.
|
||||
//
|
||||
// Replay through a THROWAWAY presenter, NOT `record.presenter`: a
|
||||
// historical turn that was interrupted mid-tool (a `tool/call` with no
|
||||
// matching `tool/result` in the persisted log) would otherwise leave a
|
||||
// stale in-flight entry on the live presenter, which then serves all
|
||||
// future live events for this session. The throwaway pairs call→result
|
||||
// as the log replays in order (same as live) and is discarded after,
|
||||
// so the record's presenter starts clean for the post-load live stream.
|
||||
const replayPresenter = makePresenter()
|
||||
const replayTerminal: TerminalRendering = {
|
||||
enabled: terminalEnabled,
|
||||
cwd: agent.session.header.cwd,
|
||||
}
|
||||
for (const event of agent.session.events) {
|
||||
streamSessionEventUpdate(params.sessionId, event, notify)
|
||||
streamSessionEventUpdate(params.sessionId, event, notify, replayPresenter, replayTerminal)
|
||||
}
|
||||
return {}
|
||||
} finally {
|
||||
@@ -462,7 +534,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
throw invalidParams('a prompt is already in flight for this session')
|
||||
}
|
||||
if (promptHasUnsupportedContent(params.prompt)) {
|
||||
throw invalidParams('only text and resource_link prompt content is supported; image/audio/resource blocks are rejected rather than silently dropped')
|
||||
throw invalidParams('only text and resource_link prompt content is supported; image/audio/embedded resource blocks are rejected rather than silently dropped')
|
||||
}
|
||||
const text = acpPromptToText(params.prompt)
|
||||
if (text.trim().length === 0) {
|
||||
@@ -543,13 +615,19 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
* loop-level change); the single-in-flight-per-session rule bounds the worst
|
||||
* case to one short queued turn per session.
|
||||
*
|
||||
* The agents themselves are NOT individually disposed/unregistered here — the
|
||||
* factory (`ctx.agents.create`/`resume`) registers each on the AgentLoop fiber
|
||||
* and returns no per-agent disposer, so registry entries are reclaimed when
|
||||
* the host context disposes. On a bare client disconnect (without a host
|
||||
* dispose) the idled agents linger in `ctx.agents` until shutdown; a reconnect
|
||||
* spins up a fresh context, so this does not strand work. A per-agent disposal
|
||||
* seam is a follow-up (TODO(rfc010-agent-disposal)).
|
||||
* The agents are NOT individually disposed/unregistered here. The factory
|
||||
* (`ctx.agents.create`/`resume`) registers each via `AgentLoop.start`'s
|
||||
* `this.ctx.effect(...)`; because the factory is reached through this bridge's
|
||||
* traceable service proxy, that effect's `this.ctx` is the CALLER context (the
|
||||
* bridge fiber), so every registry entry is bound to the bridge fiber and is
|
||||
* reclaimed when the bridge fiber disposes (whole-context dispose, or an
|
||||
* ACP-only HMR `acpFiber.dispose()` — both unregister all the bridge's
|
||||
* agents). What this teardown path handles is a bare client disconnect, which
|
||||
* resolves `conn.closed` WITHOUT disposing the fiber: each live agent is
|
||||
* idled+aborted here but stays in `ctx.agents` until the fiber is disposed.
|
||||
* Since a reconnect spins up a fresh context, the lingering idle agents strand
|
||||
* no work. A per-agent disposal seam (unregister on disconnect) is a follow-up
|
||||
* (TODO(rfc010-agent-disposal)).
|
||||
*/
|
||||
let quiescing: Promise<void> | undefined
|
||||
const quiesce = (): Promise<void> => {
|
||||
@@ -587,7 +665,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
|
||||
mid-run), and there is nothing else to act on once the connection is gone —
|
||||
the swallow mirrors notify(). */
|
||||
void conn.closed.then(quiesce).catch((error: unknown) => {
|
||||
ctx.logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
|
||||
logger.warn(`acp: connection-close teardown failed: ${String(error)}`)
|
||||
})
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -609,28 +687,31 @@ export function agentOptions(config: AcpConfig): { model?: string; systemPrompt?
|
||||
/**
|
||||
* Validate the `cwd`/`additionalDirectories` contract shared by `session/new`
|
||||
* and `session/load`: `cwd` must be absolute (a relative path would be ambiguous
|
||||
* as a workspace root). What the cwd is USED for differs by method, and this
|
||||
* validator only enforces shape:
|
||||
* as a workspace root). The persisted-cwd equality check for `session/load`
|
||||
* happens after the metadata lookup; this validator only enforces request shape:
|
||||
* - `session/new`: the validated `cwd` becomes the session's `SessionHeader.cwd`
|
||||
* (via `agents.create({meta:{cwd}})`) and thus the default bash workdir.
|
||||
* - `session/load`: the request `cwd` is shape-checked only; the RESUMED
|
||||
* session keeps its PERSISTED `header.cwd`, which stays authoritative for the
|
||||
* bash workdir — the request cwd does not override it.
|
||||
* - `session/load`: the request `cwd` must be absolute AND must match the
|
||||
* PERSISTED `header.cwd`, which stays authoritative for the bash workdir —
|
||||
* the request cwd does not override it.
|
||||
* Any absolute path is accepted (the per-session cwd flows to the bash executor
|
||||
* — see `dsh-tool-bash`), so the server no longer has to launch in the
|
||||
* workspace. `additionalDirectories` and `mcpServers` must still be empty:
|
||||
* widening tool/filesystem/protocol scope is separate, unimplemented work, and
|
||||
* silently ignoring requested roots/servers would desync the client's UI. Both
|
||||
* request shapes carry the same workspace/scope fields, so one validator covers
|
||||
* both.
|
||||
* workspace. `additionalDirectories` must still be empty: widening the
|
||||
* tool/filesystem scope beyond the single cwd is a separate, unimplemented
|
||||
* concern (a sandbox seam), and silently ignoring extra roots would desync the
|
||||
* client's filesystem-scope UI. Both request shapes carry `cwd: string` and
|
||||
* `additionalDirectories?: string[]`, so one validator covers both.
|
||||
*/
|
||||
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[]; mcpServers?: unknown[] }): void {
|
||||
function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] }): void {
|
||||
if (!isAbsolute(params.cwd)) {
|
||||
throw invalidParams(`cwd must be an absolute path: ${params.cwd}`)
|
||||
}
|
||||
if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) {
|
||||
throw invalidParams('additionalDirectories is not supported in this MVP')
|
||||
}
|
||||
}
|
||||
|
||||
function validateMcpServers(params: { mcpServers?: unknown[] }): void {
|
||||
if (params.mcpServers !== undefined && params.mcpServers.length > 0) {
|
||||
throw invalidParams('mcpServers is not supported in this MVP')
|
||||
}
|
||||
@@ -649,6 +730,14 @@ function validateWorkspaceParams(params: { cwd: string; additionalDirectories?:
|
||||
* - `tool/call` → `tool_call` (pending)
|
||||
* - `tool/result` → `tool_call_update` (completed/failed)
|
||||
*
|
||||
* Tool-call presentation (title/kind/rawInput, and the completed-state content)
|
||||
* is owned by each TOOL via `presentCall`/`presentResult` — the bridge never
|
||||
* special-cases tool names. `presenter` resolves those from the tool registry
|
||||
* and remembers each call's `(name, args)` so the completed `tool/result` (which
|
||||
* carries neither) can find its tool. A {@link nullToolPresenter} gives the
|
||||
* generic fallback (title = tool name, raw args as input) when no registry is
|
||||
* available (e.g. pure translator tests).
|
||||
*
|
||||
* Other event types (turn/step boundaries, context/message, usage, …) produce
|
||||
* no client update.
|
||||
*/
|
||||
@@ -656,6 +745,8 @@ export function streamSessionEventUpdate(
|
||||
sessionId: string,
|
||||
event: SessionEvent,
|
||||
notify: (notification: SessionNotification) => void,
|
||||
presenter: Pick<ToolPresenter, 'call' | 'result'> = nullToolPresenter,
|
||||
terminal: TerminalRendering = noTerminalRendering,
|
||||
options: { includeUserMessages?: boolean } = {},
|
||||
): void {
|
||||
const includeUserMessages = options.includeUserMessages ?? true
|
||||
@@ -683,27 +774,64 @@ export function streamSessionEventUpdate(
|
||||
return
|
||||
}
|
||||
case 'tool/call': {
|
||||
const present = presenter.call(event.data.callId, event.data.name, event.data.arguments)
|
||||
// A terminal-rendered call (a shell command) gets a terminal CARD when the
|
||||
// client supports it: a `terminal` content block plus `_meta.terminal_info`
|
||||
// (the cwd header). Otherwise it is an ordinary tool_call and the output
|
||||
// arrives as text on the result. See the terminal-rendering RFC.
|
||||
const asTerminal = present.terminal !== undefined && terminal.enabled
|
||||
// The tool's pending content (e.g. bash's `description`) renders ABOVE the
|
||||
// card; when the card is shown, append the terminal block AFTER it so the
|
||||
// description sits over the command (Zed renders content blocks in order).
|
||||
// Without the capability the description still renders as the card's body.
|
||||
const callContent: ({ type: 'content'; content: AcpContentBlock } | { type: 'terminal'; terminalId: string })[] = [
|
||||
...present.content !== undefined ? toolResultContent(present.content) : [],
|
||||
...asTerminal ? [{ type: 'terminal' as const, terminalId: event.data.callId }] : [],
|
||||
]
|
||||
notify({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: event.data.callId,
|
||||
title: event.data.name,
|
||||
kind: toolKindFor(event.data.name),
|
||||
title: present.title,
|
||||
kind: present.kind,
|
||||
status: 'in_progress',
|
||||
rawInput: parseToolArguments(event.data.arguments),
|
||||
...present.rawInput !== undefined ? { rawInput: present.rawInput } : {},
|
||||
...callContent.length > 0 ? { content: callContent } : {},
|
||||
...asTerminal
|
||||
? { _meta: { terminal_info: { terminal_id: event.data.callId, cwd: terminalCwd(present.terminal, terminal.cwd) } } }
|
||||
: {},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
case 'tool/result': {
|
||||
const present = presenter.result(event.data.callId, event.data.content, event.data.isError)
|
||||
const term = present.terminal
|
||||
// When the call rendered as a terminal AND the client is capable, the output
|
||||
// and exit status ride on `_meta` (the terminal card consumes them) and the
|
||||
// text `content` is OMITTED: a `tool_call_update.content` REPLACES the call's
|
||||
// content collection in Zed, so sending the fenced ```console block here
|
||||
// would clobber the terminal content block the call installed. The incapable
|
||||
// path keeps sending `content` (the fenced fallback is the only rendering).
|
||||
const asTerminal = term?.output !== undefined && terminal.enabled
|
||||
const terminalResultMeta = asTerminal
|
||||
? {
|
||||
_meta: {
|
||||
terminal_output: { terminal_id: event.data.callId, data: term.output },
|
||||
...terminalExitMeta(event.data.callId, term),
|
||||
},
|
||||
}
|
||||
: {}
|
||||
notify({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: event.data.callId,
|
||||
status: event.data.isError ? 'failed' : 'completed',
|
||||
content: toolResultContent(event.data.content),
|
||||
...asTerminal ? {} : { content: toolResultContent(present.content) },
|
||||
...present.title !== undefined ? { title: present.title } : {},
|
||||
...terminalResultMeta,
|
||||
},
|
||||
})
|
||||
return
|
||||
@@ -715,8 +843,155 @@ export function streamSessionEventUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-connection terminal-rendering context threaded into
|
||||
* {@link streamSessionEventUpdate}: whether the client advertised the
|
||||
* `_meta.terminal_output` capability, and the session's workspace cwd (the
|
||||
* default terminal-card header when a tool doesn't supply its own). Kept out of
|
||||
* the pure translator's required params so the no-capability / no-presenter
|
||||
* tests stay terse.
|
||||
*/
|
||||
export interface TerminalRendering {
|
||||
enabled: boolean
|
||||
/** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */
|
||||
cwd: string | undefined
|
||||
}
|
||||
|
||||
/** Default: terminal rendering off (the ` ```console ` text fallback path). */
|
||||
const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined }
|
||||
|
||||
/**
|
||||
* Resolved pending-state presentation the bridge feeds into a `tool_call`
|
||||
* update: a title is always present (tool name when the tool gives none), `kind`
|
||||
* and `rawInput` are optional.
|
||||
*/
|
||||
interface ResolvedCallPresentation {
|
||||
title: string
|
||||
kind: ToolCallKind
|
||||
rawInput?: unknown
|
||||
/** UI content shown on the pending call (e.g. a bash description text block above the card). */
|
||||
content?: ContentBlock[]
|
||||
/** Tool's request to render as a terminal (the pending side carries the cwd). */
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/** Resolved completed-state presentation fed into a `tool_call_update`. */
|
||||
interface ResolvedResultPresentation {
|
||||
/** UI content for the result (harness blocks; the tool may reformat, else the raw result). */
|
||||
content: ContentBlock[]
|
||||
/** Optional replacement title for the completed call. */
|
||||
title?: string
|
||||
/** Tool's terminal output/exit for a terminal-rendered call (the result side). */
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves tool-owned presentation for a session's tool-call events. A tool
|
||||
* declares `presentCall`/`presentResult` (see `dsh-tools`); this looks them up
|
||||
* by name in the registry and applies the generic fallback when a tool defines
|
||||
* neither.
|
||||
*
|
||||
* The `tool/result` session event carries only `{ callId, content, isError }` —
|
||||
* NOT the tool name or args — so to call a tool's `presentResult` (which needs
|
||||
* both), the presenter remembers each `tool/call`'s `{ name, args }` keyed by
|
||||
* callId and looks it up on the matching result. The map is bridge-LOCAL (not a
|
||||
* change to the event schema or a core service): one presenter per live session
|
||||
* (and a throwaway per `session/load` replay), and each entry is removed when
|
||||
* its result arrives. In the normal loop a `tool/call` is always followed by a
|
||||
* `tool/result` (the registry turns even a thrown tool into an isError result),
|
||||
* so the map holds only currently-in-flight calls. The one exception is a step
|
||||
* torn down mid-tool (an abort between `tool/call` and `tool/result`), which can
|
||||
* leave a single stale entry per such call; this is bounded by the session
|
||||
* lifetime (the whole presenter is dropped on teardown) and never affects
|
||||
* correctness — a later result for a different callId is unaffected, and the
|
||||
* stale entry's only cost is one map slot until the session ends.
|
||||
*/
|
||||
export class ToolPresenter {
|
||||
private readonly pending = new Map<string, { name: string; args: unknown; isTerminal: boolean }>()
|
||||
|
||||
/**
|
||||
* @param tools the registry to resolve tool definitions by name.
|
||||
* @param onError invoked when a tool's `presentCall`/`presentResult` THROWS;
|
||||
* the presenter swallows the error and falls back to the generic
|
||||
* presentation so a buggy display callback can never fail a live turn or a
|
||||
* `session/load` replay (AGENTS.md "contain callback exceptions at the
|
||||
* boundary"). Defaults to a no-op for callers that don't supply a logger.
|
||||
*/
|
||||
constructor(
|
||||
private readonly tools: Pick<ToolRegistry, 'get'>,
|
||||
private readonly onError: (message: string) => void = () => {},
|
||||
) {}
|
||||
|
||||
/** Pending-state presentation for a `tool/call`; remembers `(name, args)` for the matching result. */
|
||||
call(callId: string, name: string, argsJson: string): ResolvedCallPresentation {
|
||||
const args = parseToolArguments(argsJson)
|
||||
let present: ToolCallPresentation | undefined
|
||||
try {
|
||||
present = this.tools.get(name)?.presentCall?.(args)
|
||||
} catch (error: unknown) {
|
||||
// A throwing presentCall must not break streaming: log and fall back.
|
||||
this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`)
|
||||
present = undefined
|
||||
}
|
||||
if (present === undefined) {
|
||||
// No tool-owned presentation: fall back to the tool name as the title and
|
||||
// the full parsed args as the raw input (the pre-seam behavior). A generic
|
||||
// call is never a terminal, so a later result can't emit terminal output.
|
||||
this.pending.set(callId, { name, args, isTerminal: false })
|
||||
return { title: name, kind: toolKindFor(name), rawInput: args }
|
||||
}
|
||||
// Remember whether THIS call rendered as a terminal, so `result()` only emits
|
||||
// terminal output/exit for a call that actually registered a terminal — a
|
||||
// `presentResult().terminal` without a matching `presentCall().terminal`
|
||||
// would otherwise orphan `_meta.terminal_output` to a terminal Zed never made.
|
||||
this.pending.set(callId, { name, args, isTerminal: present.terminal !== undefined })
|
||||
return {
|
||||
title: present.title,
|
||||
kind: present.kind ?? 'other',
|
||||
rawInput: present.rawInput,
|
||||
...present.content !== undefined ? { content: present.content } : {},
|
||||
...present.terminal !== undefined ? { terminal: present.terminal } : {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Completed-state presentation for a `tool/result`; consumes the remembered `(name, args)`. */
|
||||
result(callId: string, content: ContentBlock[], isError: boolean): ResolvedResultPresentation {
|
||||
const call = this.pending.get(callId)
|
||||
this.pending.delete(callId)
|
||||
// No remembered call (unknown/late callId) → nothing to present from; raw content.
|
||||
if (call === undefined) return { content }
|
||||
let present: ToolResultPresentation | undefined
|
||||
try {
|
||||
present = this.tools.get(call.name)?.presentResult?.(call.args, { content, isError })
|
||||
} catch (error: unknown) {
|
||||
// A throwing presentResult must not break streaming/replay: log + fall back.
|
||||
this.onError(`acp: tool "${call.name}" presentResult threw, using raw result: ${String(error)}`)
|
||||
present = undefined
|
||||
}
|
||||
if (present === undefined) return { content }
|
||||
return {
|
||||
content: present.content ?? content,
|
||||
...present.title !== undefined ? { title: present.title } : {},
|
||||
// Only propagate terminal output/exit when the PENDING call registered a
|
||||
// terminal (finding: orphan terminal output otherwise). A result-only
|
||||
// terminal with no matching call-side terminal is dropped.
|
||||
...present.terminal !== undefined && call.isTerminal ? { terminal: present.terminal } : {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The no-op presenter used when no tool registry is available (e.g. the pure
|
||||
* translator tests): every tool gets the generic fallback presentation, and
|
||||
* results pass their raw content through unchanged.
|
||||
*/
|
||||
export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = {
|
||||
call: (_callId, name, argsJson) => ({ title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }),
|
||||
result: (_callId, content) => ({ content }),
|
||||
}
|
||||
|
||||
/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */
|
||||
function toolKindFor(name: string): 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other' {
|
||||
function toolKindFor(name: string): ToolCallKind {
|
||||
if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute'
|
||||
if (name === 'read' || name.startsWith('read')) return 'read'
|
||||
if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit'
|
||||
@@ -745,4 +1020,35 @@ function toolResultContent(blocks: ContentBlock[]): { type: 'content'; content:
|
||||
return out
|
||||
}
|
||||
|
||||
export default apply
|
||||
/**
|
||||
* Resolve the terminal card's header cwd. The tool's `terminal.cwd` (a model
|
||||
* `workdir`) wins when ABSOLUTE; a RELATIVE one resolves against the session
|
||||
* cwd (matching how `dsh-tool-bash` resolves a relative workdir for execution,
|
||||
* so the header matches where the command actually ran); when the tool gives no
|
||||
* cwd, the session workspace cwd is the default. Returns `undefined` only when
|
||||
* neither the tool nor the session supplies one (Zed then shows "current
|
||||
* directory").
|
||||
*/
|
||||
function terminalCwd(term: ToolTerminal | undefined, sessionCwd: string | undefined): string | undefined {
|
||||
const toolCwd = term?.cwd
|
||||
if (toolCwd === undefined) return sessionCwd
|
||||
if (isAbsolute(toolCwd)) return toolCwd
|
||||
return sessionCwd !== undefined ? resolvePath(sessionCwd, toolCwd) : toolCwd
|
||||
}
|
||||
|
||||
/** The `terminal_exit` `_meta` entry for a completed terminal call. */
|
||||
interface TerminalExitMeta {
|
||||
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the optional `terminal_exit` portion of a `tool_call_update`'s `_meta`
|
||||
* from the tool's terminal result: a `signal` death yields `{signal}`, an
|
||||
* `exitCode` yields `{exit_code}`, and neither yields nothing (the card simply
|
||||
* shows no exit pill). Spread into the `_meta` object alongside `terminal_output`.
|
||||
*/
|
||||
function terminalExitMeta(callId: string, term: ToolTerminal): TerminalExitMeta {
|
||||
if (term.signal !== undefined) return { terminal_exit: { terminal_id: callId, signal: term.signal } }
|
||||
if (term.exitCode !== undefined) return { terminal_exit: { terminal_id: callId, exit_code: term.exitCode } }
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
// stay up and the transport is still live. A late session/new must hit the
|
||||
// `closed` guard and reject — NOT create an agent the disposed bridge can no
|
||||
// longer stream or settle. Verify the world: no agent appeared.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [], childFiber: true })
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const before = harness.ctx.agents.list().length
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
@@ -48,6 +48,25 @@ describe('acp bridge — disposal & HMR safety', () => {
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('an agent created through the bridge is unregistered when ONLY the bridge fiber is disposed', async () => {
|
||||
// The factory (`ctx.agents.create`) is reached through the bridge's
|
||||
// traceable service proxy, so `AgentLoop.start`'s `this.ctx.effect(...)`
|
||||
// registration binds to the CALLER context — the bridge fiber — not the
|
||||
// AgentLoop fiber. Disposing JUST the bridge fiber (an ACP-only HMR reload)
|
||||
// must therefore reclaim the agent's registry entry, even though agents/
|
||||
// agent-loop stay up. This pins the fiber-ownership the bridge's teardown
|
||||
// doc comment relies on; if a refactor rebinds the registration to the
|
||||
// AgentLoop fiber, the agent would survive bridge dispose and this fails.
|
||||
const harness = await makeBridgeHarness({ storageDir, script: [] })
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
expect(harness.ctx.agents.get(sessionId)).toBeDefined()
|
||||
|
||||
await harness.acpFiber.dispose() // tear down ONLY the bridge
|
||||
expect(harness.ctx.agents.get(sessionId)).toBeUndefined()
|
||||
await harness.dispose()
|
||||
})
|
||||
|
||||
it('no agent is created by a session/new after the bridge has closed (closed guard)', async () => {
|
||||
// After teardown (here a client disconnect sets `closed`), a late
|
||||
// `session/new` must NOT create an orphan agent the bridge can no longer
|
||||
|
||||
@@ -18,6 +18,8 @@ import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
|
||||
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
|
||||
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
|
||||
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
@@ -148,8 +150,14 @@ export async function makeBridgeHarness(options: {
|
||||
script?: (StreamChunk[] | 'hang')[]
|
||||
config?: Partial<AcpConfig>
|
||||
storageDir: string
|
||||
/** Mount the bridge in a disposable child fiber (for the ACP-only-HMR test). */
|
||||
childFiber?: boolean
|
||||
/**
|
||||
* Plug the REAL `dsh-bash-local` executor + `dsh-tool-bash` tools (instead of
|
||||
* a test's own inline tool). Lets a test drive the actual `bash` tool — its
|
||||
* real `presentCall`/`presentResult` — through the bridge, so tool-call UI
|
||||
* tests verify the SHIPPING tool, not a stand-in (AGENTS.md "prefer the real
|
||||
* implementation over a mock in tests").
|
||||
*/
|
||||
withBash?: boolean
|
||||
} = { storageDir: '' }): Promise<BridgeHarness> {
|
||||
const adapter = new MockAdapter(options.script ?? [])
|
||||
|
||||
@@ -161,6 +169,10 @@ export async function makeBridgeHarness(options: {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(AgentLoop, { agents: [] })
|
||||
await ctx.plugin(SessionPersistenceJsonl, { root: options.storageDir })
|
||||
if (options.withBash) {
|
||||
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
|
||||
await ctx.plugin(ToolBash)
|
||||
}
|
||||
ctx.llm.registerAdapter(['mock'], adapter)
|
||||
|
||||
// Two identity byte pipes cross-wired into the two ndJsonStreams: bytes the
|
||||
@@ -225,21 +237,24 @@ export async function makeBridgeHarness(options: {
|
||||
// override means "no model at all".
|
||||
const cfg: AcpConfig = { stream: agentStream, ...options.config }
|
||||
if (!(options.config && 'model' in options.config)) cfg.model = 'mock'
|
||||
// By default apply the bridge directly on the root ctx (services ungated). For
|
||||
// the ACP-only-HMR test, `childFiber: true` mounts it in a CHILD fiber instead
|
||||
// so the test can dispose JUST the bridge while the rest of the harness stays
|
||||
// up — its disposer (`harness.acpFiber.dispose()`) tears down only the
|
||||
// bridge's listeners/effect. (Child-fiber service tracing gates the async
|
||||
// persistence path, so the load-replay tests use the default direct mount.)
|
||||
if (options.childFiber) {
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
inject: ['agents', 'sessions', 'sessionPersistence'],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
} else {
|
||||
AcpPlugin.apply(ctx, cfg)
|
||||
}
|
||||
// Mount the bridge the way production does: as a cordis PLUGIN (via
|
||||
// `ctx.plugin` with the real `inject`), NOT `AcpPlugin.apply(ctx, cfg)`
|
||||
// directly on the root ctx. The plugin fiber is the faithful reproduction —
|
||||
// the bridge's `apply` runs inside the fiber's injection scope, and its ACP
|
||||
// handlers later run from the JSON-RPC read loop OUTSIDE that scope, exactly
|
||||
// as under the example's cordis.yml. (Mounting directly on root made every
|
||||
// service an ungated property and hid the "cannot get property … without
|
||||
// inject" failure that bit a real Zed session.) `harness.acpFiber.dispose()`
|
||||
// tears down JUST the bridge (its listeners + effect) for the HMR test.
|
||||
harness.acpFiber = await ctx.plugin({
|
||||
name: 'acp-test',
|
||||
// Use the bridge's REAL exported `inject` so this never drifts from the
|
||||
// plugin's actual dependency list (adding a service to the bridge must not
|
||||
// require editing the harness — a hardcoded list silently broke when `tools`
|
||||
// was added). The bridge programs against the interface packages only.
|
||||
inject: [...AcpPlugin.inject],
|
||||
apply: (inner: Context) => { AcpPlugin.apply(inner, cfg) },
|
||||
})
|
||||
harness.client = new ClientSideConnection(makeClient, clientStream)
|
||||
|
||||
return harness
|
||||
|
||||
@@ -4,7 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts'
|
||||
|
||||
/** Concatenate the text of all agent_message_chunk updates. */
|
||||
function messageText(updates: CapturedUpdate[]): string {
|
||||
@@ -56,6 +56,79 @@ describe('acp bridge — session/load replay', () => {
|
||||
expect(userText).toBe('remember this')
|
||||
})
|
||||
|
||||
it('replays a persisted tool call with the TOOL-OWNED presentation (title/rawInput/console output)', async () => {
|
||||
// A turn with a REAL bash tool call is persisted, then loaded by a fresh
|
||||
// bridge. The replayed tool_call/tool_call_update must carry the tool's OWN
|
||||
// presentation — identical to how it streamed live — via a throwaway
|
||||
// presenter that pairs call→result as the log replays in order. Uses the
|
||||
// shipping tool (withBash), not a stand-in (AGENTS.md "prefer the real
|
||||
// implementation over a mock in tests").
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
// A fresh bridge — also with the real bash tool, since the presentation is
|
||||
// resolved from the live registry at replay time — loads the session.
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
expect(call).toMatchObject({ toolCallId: 'c1', title: 'echo hello', kind: 'execute', rawInput: 'echo hello' })
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Capability OFF on this loader: the description renders as a content block, no terminal block.
|
||||
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
|
||||
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update?.sessionUpdate).toBe('tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
const content = update.content as { content: { text: string } }[]
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
})
|
||||
|
||||
it('replays a persisted bash call as a TERMINAL card when the loader advertises the capability', async () => {
|
||||
// The presentation is resolved at replay time, so a loader that advertised
|
||||
// _meta.terminal_output must reconstruct the terminal card (content + _meta)
|
||||
// from the persisted log — identical to how it would have streamed live.
|
||||
live = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
await live.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
const { sessionId } = await live.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
await live.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
await live.dispose()
|
||||
live = undefined
|
||||
|
||||
loader = await makeBridgeHarness({ storageDir, withBash: true, script: [] })
|
||||
await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
await loader.client.loadSession({ sessionId, cwd: process.cwd(), mcpServers: [] })
|
||||
|
||||
const call = loader.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Replay reconstructs the terminal card: description block, then terminal block.
|
||||
expect(call.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
])
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
|
||||
const update = loader.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// Terminal mode: content omitted, output + exit on _meta — matching live.
|
||||
expect(update.content).toBeUndefined()
|
||||
const meta = update._meta as { terminal_output?: { data: string }; terminal_exit?: { exit_code?: number } }
|
||||
expect(meta.terminal_output?.data).toBe('hi\n')
|
||||
expect(meta.terminal_exit?.exit_code).toBe(0)
|
||||
})
|
||||
|
||||
it('a load whose resume finishes after a client disconnect leaks no live session', async () => {
|
||||
// A session/load is mid-resume() when the client transport closes. The load
|
||||
// must NOT end up with a live registered agent for the connection that is
|
||||
|
||||
@@ -2,21 +2,29 @@ import { describe, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionNotification } from '@agentclientprotocol/sdk'
|
||||
import { streamSessionEventUpdate, agentOptions } from '../src/index.ts'
|
||||
import type { ToolDefinition, ToolRegistry } from '@deepseek-ai/dsh-tools'
|
||||
import { streamSessionEventUpdate, agentOptions, ToolPresenter } from '../src/index.ts'
|
||||
|
||||
/** Collect the updates a single event produces. */
|
||||
/** Collect the updates a single event produces (no presenter → generic fallback). */
|
||||
function updatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update))
|
||||
return out
|
||||
}
|
||||
|
||||
/** Collect the updates emitted by the live prompt stream (user echo suppressed). */
|
||||
function liveUpdatesFor(event: SessionEvent): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update), { includeUserMessages: false })
|
||||
streamSessionEventUpdate('s1', event, n => out.push(n.update), undefined, undefined, { includeUserMessages: false })
|
||||
return out
|
||||
}
|
||||
|
||||
/** A tiny tool registry stub exposing just `get` for {@link ToolPresenter}. */
|
||||
function registryOf(...tools: ToolDefinition[]): Pick<ToolRegistry, 'get'> {
|
||||
const map = new Map(tools.map(t => [t.name, t]))
|
||||
return { get: name => map.get(name) }
|
||||
}
|
||||
|
||||
function evt<T extends SessionEvent['type']>(type: T, data: Extract<SessionEvent, { type: T }>['data']): SessionEvent {
|
||||
return { type, seq: 0, time: 0, data } as SessionEvent
|
||||
}
|
||||
@@ -37,7 +45,7 @@ describe('streamSessionEventUpdate', () => {
|
||||
.toEqual([])
|
||||
})
|
||||
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput', () => {
|
||||
it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => {
|
||||
const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }))
|
||||
expect(updates).toEqual([{
|
||||
sessionUpdate: 'tool_call',
|
||||
@@ -112,6 +120,291 @@ describe('streamSessionEventUpdate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('ToolPresenter (tool-owned presentation via the tool registry)', () => {
|
||||
/** A tool whose presentCall/presentResult mirror what tool-bash declares. */
|
||||
const bashLike: ToolDefinition = {
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => {
|
||||
const a = args as { command: string; description: string }
|
||||
return { title: a.description, kind: 'execute', rawInput: a.command }
|
||||
},
|
||||
presentResult: (_args: unknown, result: { content: { type: string }[] }) => ({
|
||||
content: [{ type: 'text', text: `wrapped:${result.content.length}` }],
|
||||
}),
|
||||
}
|
||||
|
||||
function updatesWith(presenter: ToolPresenter, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter)
|
||||
return out
|
||||
}
|
||||
|
||||
it('tool/call uses the tool: description→title, command→rawInput, tool kind', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
const [update] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'bash',
|
||||
arguments: JSON.stringify({ command: 'ls -la', description: 'List files' }),
|
||||
}))
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'List files',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: 'ls -la',
|
||||
})
|
||||
})
|
||||
|
||||
it('tool/result uses the tool to reformat content (resolved by the remembered tool/call)', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false }),
|
||||
)
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'wrapped:1' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a result with NO preceding call (unknown callId) falls back to the raw content', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
// No tool/call for c9 → presenter has nothing remembered → generic fallback.
|
||||
const [update] = updatesWith(presenter, evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c9'), content: [{ type: 'text', text: 'raw' }], isError: false,
|
||||
}))
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c9',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'raw' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('a tool with no presentCall/presentResult gets the generic fallback (title = name)', () => {
|
||||
const plain: ToolDefinition = { name: 'plain', description: 'p', parameters: {}, execute: async () => [] }
|
||||
const presenter = new ToolPresenter(registryOf(plain))
|
||||
const [update] = updatesWith(presenter, evt('tool/call', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), name: 'plain', arguments: '{"a":1}',
|
||||
}))
|
||||
expect(update).toMatchObject({ title: 'plain', kind: 'other', rawInput: { a: 1 } })
|
||||
})
|
||||
|
||||
it('a presentation that omits kind/content/rawInput uses the defaults (kind other, raw result content kept)', () => {
|
||||
// A minimal tool-owned presentation: presentCall returns only a title (no
|
||||
// kind → defaults to `other`, no rawInput → omitted); presentResult returns
|
||||
// only a title (no content → the raw result content is kept).
|
||||
const minimal: ToolDefinition = {
|
||||
name: 'mini',
|
||||
description: 'm',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => ({ title: 'Doing a thing' }),
|
||||
presentResult: () => ({ title: 'Did the thing' }),
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(minimal))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'mini', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'kept' }], isError: false }),
|
||||
)
|
||||
// No kind → 'other'; no rawInput key at all.
|
||||
expect(updates[0]).toEqual({ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'Doing a thing', kind: 'other', status: 'in_progress' })
|
||||
// Title replaced; content falls back to the raw result content.
|
||||
expect(updates[1]).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'kept' } }],
|
||||
title: 'Did the thing',
|
||||
})
|
||||
})
|
||||
|
||||
it('holds ONLY in-flight calls: the callId entry is removed once its result is presented', () => {
|
||||
const presenter = new ToolPresenter(registryOf(bashLike))
|
||||
updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'x', description: 'd' }) }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'o' }], isError: false }),
|
||||
)
|
||||
// A SECOND result for the same callId now finds nothing remembered, so it
|
||||
// falls back to raw content (proving the first result consumed the entry —
|
||||
// the map does not retain finished calls).
|
||||
const [late] = updatesWith(presenter, evt('tool/result', {
|
||||
turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'late' }], isError: false,
|
||||
}))
|
||||
expect(late).toMatchObject({ content: [{ type: 'content', content: { type: 'text', text: 'late' } }] })
|
||||
})
|
||||
|
||||
it('a THROWING presentCall/presentResult is contained: generic fallback + onError, never propagates', () => {
|
||||
// A buggy tool whose display callbacks throw must NOT fail a live turn or a
|
||||
// session/load replay (AGENTS.md "contain callback exceptions at the
|
||||
// boundary"). The presenter swallows the throw, reports via onError, and
|
||||
// falls back to the generic presentation.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => { throw new Error('call boom') },
|
||||
presentResult: () => { throw new Error('result boom') },
|
||||
}
|
||||
const errors: string[] = []
|
||||
const presenter = new ToolPresenter(registryOf(boom), msg => errors.push(msg))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{"a":1}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
|
||||
)
|
||||
// tool/call fell back to title=name, raw args as rawInput.
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom', kind: 'other', rawInput: { a: 1 } })
|
||||
// tool/result fell back to the raw content.
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
// Both throws were reported, not propagated.
|
||||
expect(errors).toHaveLength(2)
|
||||
expect(errors[0]).toContain('presentCall threw')
|
||||
expect(errors[1]).toContain('presentResult threw')
|
||||
})
|
||||
|
||||
it('contains a throwing presenter even with the DEFAULT (no-op) onError sink', () => {
|
||||
// Constructed without an onError sink (the default `() => {}`): a throwing
|
||||
// presenter is still swallowed and falls back generically — the absence of a
|
||||
// logger must not turn a display bug into a propagated exception.
|
||||
const boom: ToolDefinition = {
|
||||
name: 'boom',
|
||||
description: 'b',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: () => { throw new Error('call boom') },
|
||||
presentResult: () => { throw new Error('result boom') },
|
||||
}
|
||||
const presenter = new ToolPresenter(registryOf(boom))
|
||||
const updates = updatesWith(
|
||||
presenter,
|
||||
evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'boom', arguments: '{}' }),
|
||||
evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'raw' }], isError: false }),
|
||||
)
|
||||
expect(updates[0]).toMatchObject({ sessionUpdate: 'tool_call', title: 'boom' })
|
||||
expect(updates[1]).toMatchObject({ sessionUpdate: 'tool_call_update', content: [{ type: 'content', content: { type: 'text', text: 'raw' } }] })
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal-card mapping (capability-gated)', () => {
|
||||
// A tool that asks to render as a terminal — a stand-in for tool-bash's shape,
|
||||
// letting us drive the bridge's terminal mapping without the real executor.
|
||||
type CallTerm = { cwd?: string } | undefined
|
||||
type ResultTerm = { output?: string; exitCode?: number; signal?: string } | undefined
|
||||
const termTool = (callTerminal: CallTerm, resultTerminal: ResultTerm): ToolDefinition => ({
|
||||
name: 'bash',
|
||||
description: 'run a command',
|
||||
parameters: {},
|
||||
execute: async () => [],
|
||||
presentCall: (args: unknown) => ({
|
||||
title: (args as { command: string }).command,
|
||||
kind: 'execute',
|
||||
rawInput: (args as { command: string }).command,
|
||||
content: [{ type: 'text', text: (args as { description: string }).description }],
|
||||
...callTerminal !== undefined ? { terminal: callTerminal } : {},
|
||||
}),
|
||||
presentResult: () => ({
|
||||
content: [{ type: 'text', text: 'fallback' }],
|
||||
...resultTerminal !== undefined ? { terminal: resultTerminal } : {},
|
||||
}),
|
||||
})
|
||||
|
||||
const callEvent = evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: JSON.stringify({ command: 'echo hi', description: 'Greet' }) })
|
||||
const resultEvent = evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'hi\n' }], isError: false })
|
||||
|
||||
function termUpdates(tool: ToolDefinition, enabled: boolean, cwd: string | undefined, ...events: SessionEvent[]): SessionNotification['update'][] {
|
||||
const presenter = new ToolPresenter(registryOf(tool))
|
||||
const out: SessionNotification['update'][] = []
|
||||
for (const event of events) streamSessionEventUpdate('s1', event, n => out.push(n.update), presenter, { enabled, cwd })
|
||||
return out
|
||||
}
|
||||
|
||||
it('capability ON: description content THEN terminal block; cwd from the session header when the tool gives none', () => {
|
||||
const [call, update] = termUpdates(termTool({}, { output: 'hi\n', exitCode: 0 }), true, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toMatchObject({
|
||||
sessionUpdate: 'tool_call',
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
],
|
||||
_meta: { terminal_info: { terminal_id: 'c1', cwd: '/work/proj' } },
|
||||
})
|
||||
// The update OMITS content (it would clobber the terminal block) and carries output + exit.
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
_meta: { terminal_output: { terminal_id: 'c1', data: 'hi\n' }, terminal_exit: { terminal_id: 'c1', exit_code: 0 } },
|
||||
})
|
||||
})
|
||||
|
||||
it('capability ON: an ABSOLUTE tool cwd wins; a RELATIVE one resolves against the session cwd', () => {
|
||||
const [absCall] = termUpdates(termTool({ cwd: '/explicit/abs' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
expect((absCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/explicit/abs')
|
||||
const [relCall] = termUpdates(termTool({ cwd: 'sub/dir' }, { output: 'x' }), true, '/work/proj', callEvent)
|
||||
// Relative workdir resolved against the session cwd — the card header matches
|
||||
// where execution actually ran (tool-bash resolves the same way).
|
||||
expect((relCall as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('/work/proj/sub/dir')
|
||||
// No session cwd to resolve against → the relative tool cwd is passed through as-is.
|
||||
const [noSessionCwd] = termUpdates(termTool({ cwd: 'rel/only' }, { output: 'x' }), true, undefined, callEvent)
|
||||
expect((noSessionCwd as unknown as { _meta: { terminal_info: { cwd: string } } })._meta.terminal_info.cwd).toBe('rel/only')
|
||||
})
|
||||
|
||||
it('capability ON: a signal kill maps to terminal_exit.signal', () => {
|
||||
const [, update] = termUpdates(termTool({}, { output: 'gone', signal: 'SIGKILL' }), true, '/w', callEvent, resultEvent)
|
||||
expect((update as unknown as { _meta: { terminal_exit: unknown } })._meta.terminal_exit).toEqual({ terminal_id: 'c1', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('capability ON: a terminal result with output but NO exit/signal emits terminal_output and NO exit pill', () => {
|
||||
// A terminal-rendering tool that reports no structured exit (neither exitCode
|
||||
// nor signal) — the card shows output but no exit pill.
|
||||
const [, update] = termUpdates(termTool({}, { output: 'partial' }), true, '/w', callEvent, resultEvent)
|
||||
const meta = (update as unknown as { _meta: { terminal_output?: unknown; terminal_exit?: unknown } })._meta
|
||||
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'partial' })
|
||||
expect(meta.terminal_exit).toBeUndefined()
|
||||
})
|
||||
|
||||
it('capability OFF: no terminal block or _meta; the description content and fenced result still render', () => {
|
||||
const [call, update] = termUpdates(termTool({}, { output: 'hi\n' }), false, '/work/proj', callEvent, resultEvent)
|
||||
expect(call).toEqual({
|
||||
sessionUpdate: 'tool_call',
|
||||
toolCallId: 'c1',
|
||||
title: 'echo hi',
|
||||
kind: 'execute',
|
||||
status: 'in_progress',
|
||||
rawInput: 'echo hi',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'Greet' } }],
|
||||
})
|
||||
expect(update).toEqual({
|
||||
sessionUpdate: 'tool_call_update',
|
||||
toolCallId: 'c1',
|
||||
status: 'completed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'fallback' } }],
|
||||
})
|
||||
})
|
||||
|
||||
it('orphan guard: a result-side terminal with NO call-side terminal is dropped (no orphan terminal_output)', () => {
|
||||
// presentCall declares NO terminal, but presentResult returns one — the
|
||||
// bridge must not emit _meta.terminal_output for a terminal Zed never made.
|
||||
const [call, update] = termUpdates(termTool(undefined, { output: 'hi\n', exitCode: 0 }), true, '/w', callEvent, resultEvent)
|
||||
// The call had no terminal → ordinary tool_call (description content, no _meta).
|
||||
expect((call as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((call as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'Greet' } }])
|
||||
// The result falls back to text content; NO terminal _meta.
|
||||
expect((update as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((update as { content: unknown }).content).toEqual([{ type: 'content', content: { type: 'text', text: 'fallback' } }])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agentOptions', () => {
|
||||
it('includes only the fields present in config', () => {
|
||||
expect(agentOptions({})).toEqual({})
|
||||
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
} from './harness.ts'
|
||||
|
||||
/** Boilerplate: initialize + create one session, returning its id. */
|
||||
async function newSession(h: BridgeHarness): Promise<string> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
async function newSession(h: BridgeHarness, clientCapabilities: Record<string, unknown> = {}): Promise<string> {
|
||||
await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities })
|
||||
const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
return sessionId
|
||||
}
|
||||
@@ -75,6 +75,145 @@ describe('acp bridge — turn outcomes', () => {
|
||||
expect(callIdx).toBeLessThan(updIdx)
|
||||
})
|
||||
|
||||
it('the REAL bash tool drives the tool-call UI end-to-end: command title + description block + console output', async () => {
|
||||
// Use the SHIPPING tool (dsh-tool-bash + dsh-bash-local), not an inline
|
||||
// stand-in, so this verifies the actual presentCall/presentResult the editor
|
||||
// sees (AGENTS.md "prefer the real implementation over a mock in tests").
|
||||
// The mock MODEL still scripts the tool call (no real LLM needed), but the
|
||||
// tool and executor are real: a real `echo` runs and its real output flows
|
||||
// back through the bridge.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [
|
||||
toolCallResponse('c1', 'bash', { command: 'echo hello', description: 'Print a greeting' }),
|
||||
textResponse('done'),
|
||||
],
|
||||
})
|
||||
const sessionId = await newSession(harness)
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
// presentCall: execute kind, title IS the command (an execute card hides
|
||||
// rawInput, so the command is the title), the description rides as a content
|
||||
// text block, the command is also rawInput for non-terminal UIs.
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
expect(call).toMatchObject({
|
||||
toolCallId: 'c1',
|
||||
title: 'echo hello',
|
||||
kind: 'execute',
|
||||
rawInput: 'echo hello',
|
||||
status: 'in_progress',
|
||||
})
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Capability OFF: the description renders as the only content block (no terminal block).
|
||||
expect(call.content).toEqual([{ type: 'content', content: { type: 'text', text: 'Print a greeting' } }])
|
||||
// presentResult: the REAL command output, wrapped in a fenced console block.
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update?.sessionUpdate).toBe('tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
const content = update.content as { content: { type: string; text: string } }[]
|
||||
expect(content[0]?.content.text).toBe('```console\nhello\n```')
|
||||
// Capability OFF (the default newSession): NO terminal _meta on either update.
|
||||
expect((call as { _meta?: unknown })._meta).toBeUndefined()
|
||||
expect((update as { _meta?: unknown })._meta).toBeUndefined()
|
||||
})
|
||||
|
||||
it('with the terminal_output capability ON, a real bash call renders as a TERMINAL card (content + _meta + exit)', async () => {
|
||||
// Drive the REAL bash tool, and advertise the Zed `_meta.terminal_output`
|
||||
// capability in initialize. The bridge must then emit the terminal CARD: the
|
||||
// description content block THEN a terminal content block + `_meta.terminal_info`
|
||||
// (cwd header) on the call, and `_meta.terminal_output`/`terminal_exit` on the
|
||||
// result — and OMIT the update's text content (it would clobber the card).
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
// Capability lives under clientCapabilities._meta.terminal_output.
|
||||
const sessionId = await newSession(harness, { _meta: { terminal_output: true } })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// The description content block FIRST (renders above the card), then a
|
||||
// terminal content block keyed by the callId; terminal_info carries the
|
||||
// session cwd (the bridge fills it from the session header).
|
||||
expect(call.content).toEqual([
|
||||
{ type: 'content', content: { type: 'text', text: 'Greet' } },
|
||||
{ type: 'terminal', terminalId: 'c1' },
|
||||
])
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toEqual({ terminal_id: 'c1', cwd: process.cwd() })
|
||||
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// In terminal mode the text content is OMITTED (a tool_call_update.content
|
||||
// REPLACES the call's content — it would clobber the terminal block).
|
||||
expect(update.content).toBeUndefined()
|
||||
// Output rides on _meta.terminal_output; the parsed exit on _meta.terminal_exit.
|
||||
const meta = update._meta as {
|
||||
terminal_output?: { terminal_id: string; data: string }
|
||||
terminal_exit?: { terminal_id: string; exit_code?: number; signal?: string }
|
||||
}
|
||||
expect(meta.terminal_output).toEqual({ terminal_id: 'c1', data: 'hi\n' })
|
||||
expect(meta.terminal_exit).toEqual({ terminal_id: 'c1', exit_code: 0 })
|
||||
})
|
||||
|
||||
it('the terminal capability is snapshotted per-session: a later initialize cannot desync a call/result', async () => {
|
||||
// The session is created with the capability ON. A SECOND initialize then
|
||||
// turns it OFF at the connection level — but this session keeps its snapshot,
|
||||
// so its bash call STILL renders as a terminal card (call + result agree).
|
||||
// Without the snapshot, the result path would re-read the now-OFF capability
|
||||
// and either clobber the card (content sent) or be inconsistent with the call.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
withBash: true,
|
||||
script: [toolCallResponse('c1', 'bash', { command: 'echo hi', description: 'Greet' }), textResponse('done')],
|
||||
})
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: { _meta: { terminal_output: true } } })
|
||||
const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })
|
||||
// A re-initialize that DROPS the capability after the session exists.
|
||||
await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
|
||||
await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'greet' }] })
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
if (call?.sessionUpdate !== 'tool_call') throw new Error('expected a tool_call')
|
||||
// Still a terminal card (the session's snapshot, not the mutated connection cap).
|
||||
expect((call._meta as { terminal_info?: unknown }).terminal_info).toBeDefined()
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
if (update?.sessionUpdate !== 'tool_call_update') throw new Error('expected a tool_call_update')
|
||||
// The result AGREES with the call: terminal output present, content omitted.
|
||||
expect(update.content).toBeUndefined()
|
||||
expect((update._meta as { terminal_output?: unknown }).terminal_output).toBeDefined()
|
||||
})
|
||||
|
||||
it('a throwing tool presenter does not break the turn: the bridge falls back generically', async () => {
|
||||
// A buggy tool whose presentCall throws must not fail the live turn — the
|
||||
// bridge's presenter contains the throw (logging via its onError sink) and
|
||||
// falls back to the generic title=name presentation. Exercises the real
|
||||
// bridge wiring of the per-session presenter's error sink.
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
script: [toolCallResponse('c1', 'kaboom', { x: 1 }), textResponse('done')],
|
||||
})
|
||||
harness.ctx.tools.register(defineTool({
|
||||
name: 'kaboom',
|
||||
description: 'explodes when presented',
|
||||
parameters: { x: { type: 'number' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
presentCall: () => { throw new Error('present boom') },
|
||||
}))
|
||||
const sessionId = await newSession(harness)
|
||||
const res = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] })
|
||||
expect(res.stopReason).toBe('end_turn') // the turn completed despite the throw
|
||||
|
||||
const call = harness.updates.find(u => u.sessionUpdate === 'tool_call')
|
||||
// Generic fallback: title is the tool name, raw args as rawInput.
|
||||
expect(call).toMatchObject({ toolCallId: 'c1', title: 'kaboom', kind: 'other', rawInput: { x: 1 } })
|
||||
const update = harness.updates.find(u => u.sessionUpdate === 'tool_call_update')
|
||||
expect(update).toMatchObject({ toolCallId: 'c1', status: 'completed' })
|
||||
})
|
||||
|
||||
it('a failing tool yields a failed tool_call_update', async () => {
|
||||
harness = await makeBridgeHarness({
|
||||
storageDir,
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ "path": "../llm" },
|
||||
{ "path": "../session" },
|
||||
{ "path": "../agent" },
|
||||
{ "path": "../tools" },
|
||||
{ "path": "../session-persistence" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ This is the only package in the harness that contains concrete loop logic. Every
|
||||
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
|
||||
|
||||
- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? })` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`.
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? })` — load a persisted session via `ctx.sessionPersistence` (RFC 009) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent).
|
||||
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? })` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent).
|
||||
|
||||
### Injected services
|
||||
|
||||
|
||||
@@ -152,12 +152,22 @@ export class AgentLoop extends Service implements AgentFactory {
|
||||
* by the time this runs the service exists.
|
||||
*/
|
||||
async resume(options: ResumeAgentOptions): Promise<Agent> {
|
||||
const persistence = this.ctx.sessionPersistence
|
||||
// `sessionPersistence` is declaration-merged onto Context as non-optional,
|
||||
// but the service is only present when a backend plugin is loaded — and
|
||||
// AgentLoop deliberately does NOT inject it (that would pend non-persistent
|
||||
// demos forever). So the runtime value can be undefined; the type cannot.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
// Read the service through `ctx.get('sessionPersistence')` — a direct
|
||||
// global-store lookup keyed by the isolate symbol — NOT
|
||||
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
|
||||
// `sessionPersistence` (injecting it would pend non-persistent demos
|
||||
// forever). The `ctx.<name>` property proxy resolves a service by an
|
||||
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
|
||||
// own fiber (which lacks the inject) that walk never reaches the sibling
|
||||
// backend fiber and throws "cannot get property … without inject". Worse,
|
||||
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
|
||||
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
|
||||
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
|
||||
// sidesteps the fiber walk entirely (a store lookup by the global isolate
|
||||
// key), so resume works from any caller fiber. It is strict by default: a
|
||||
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
|
||||
// and we reject below, rather than handing back an unusable handle.
|
||||
const persistence = this.ctx.get('sessionPersistence')
|
||||
if (persistence === undefined) {
|
||||
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export interface LoopHandle {
|
||||
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
|
||||
* STEP loop:
|
||||
* drain steering → session('steering/message') ⟵ catches late steering
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (ADR 0003)
|
||||
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
|
||||
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
|
||||
* req = {model, system, tools, messages: session.deriveMessages(), signal}
|
||||
* req = waterfall agent/request ⟵ hooks/compaction/model-switch
|
||||
@@ -161,7 +161,7 @@ export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle
|
||||
// before turn/start) — no turn/start was appended, so no turn is open and
|
||||
// none is owed. A session `error` here would land outside any turn (after
|
||||
// the previous turn/end), where the persistence backend drops it as a
|
||||
// crash tail (ADR 0017). Report via agent/error + the logger only; the
|
||||
// crash tail (the turn-enclosure RFC). Report via agent/error + the logger only; the
|
||||
// driver survives and moves on.
|
||||
const err = toError(error)
|
||||
ctx.logger.warn(`agent "${agent.id}": turn ${turn} failed before it started: ${err.message}`)
|
||||
@@ -202,7 +202,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
||||
// Close the open step exactly once (idempotent via stepOpen). The
|
||||
// agent/step-end emit is contained: a throwing step-end listener must not
|
||||
// abort finalization and strand the turn open (turn/end balance > notifying
|
||||
// one bad listener). Appended before the emit (ADR 0003 append-before-emit).
|
||||
// one bad listener). Appended before the emit (the event-sourcing RFC append-before-emit).
|
||||
const closeStep = (): boolean => {
|
||||
if (!stepOpen) return false
|
||||
stepOpen = false
|
||||
@@ -226,8 +226,11 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
||||
// a throwing listener from producing a silent "completed" turn when the step
|
||||
// itself succeeded, AND keeps finalization going when closeStep runs from
|
||||
// the outer catch.
|
||||
if (failure !== undefined) failTurn(toError(failure))
|
||||
return failure !== undefined
|
||||
if (failure !== undefined) {
|
||||
failTurn(toError(failure))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Record a step/turn failure exactly once: append the single `error` event
|
||||
@@ -242,7 +245,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
||||
// turn has already ended — the only way here is a throwing agent/turn-end
|
||||
// listener after closeTurn(true) already appended turn/end — appending now
|
||||
// would land the error AFTER the last turn/end, where the persistence
|
||||
// backend treats it as a crash tail and drops it on resume (ADR 0017). In
|
||||
// backend treats it as a crash tail and drops it on resume (the turn-enclosure RFC). In
|
||||
// that case report via agent/error + the logger only; the turn is balanced.
|
||||
if (!turnEnded) {
|
||||
// Set `reason` BEFORE the append: Session.append pushes the error event
|
||||
@@ -347,7 +350,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
||||
}
|
||||
|
||||
// The successful step's finish reason carries forward: a `max-tokens`
|
||||
// step makes the whole turn end `max-tokens` (RFC 010's rule "any
|
||||
// step makes the whole turn end `max-tokens` (the ACP RFC's rule "any
|
||||
// max-tokens step surfaces as max-tokens"). `stepFinishReason` returns
|
||||
// `max-tokens` or `undefined`, so a later ordinary step never resets a
|
||||
// max-tokens turn back to completed, and a never-truncated turn keeps the
|
||||
@@ -394,7 +397,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
||||
// so a throwing listener on the `turn/start` append leaves turn/start in the
|
||||
// log even though execution never reached the lines after that append.
|
||||
// Gating on a "turn started" boolean would skip turn/end and leave a
|
||||
// permanently OPEN turn that poisons the next turn/replay (ADR 0017). We
|
||||
// permanently OPEN turn that poisons the next turn/replay (the turn-enclosure RFC). We
|
||||
// check the log for THIS turn's turn/start: present means a turn/end is owed
|
||||
// (or was already appended — closeTurn/failTurn are idempotent, so running
|
||||
// them again is a safe no-op that still preserves the disposed/error reason
|
||||
@@ -429,7 +432,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
|
||||
// AFTER turn/end to be a checkpoint — so there is no in-turn position left
|
||||
// for a session `error` event. Appending one here would land it after the
|
||||
// last turn/end, where the persistence backend treats it as a crash tail
|
||||
// and drops it on resume (ADR 0017: every event is turn-enclosed). Report
|
||||
// and drops it on resume (the turn-enclosure RFC: every event is turn-enclosed). Report
|
||||
// the failure via agent/error + the logger only; persistence keeps the
|
||||
// buffered events for the next flush/dispose, so nothing is lost.
|
||||
const err = toError(error)
|
||||
@@ -498,32 +501,28 @@ async function runStep(
|
||||
const stepError = finishError(assembler.finish)
|
||||
if (stepError) throw stepError
|
||||
|
||||
if (assembler.finish.kind === 'max-tokens') {
|
||||
if (assembler.usage) {
|
||||
session.append('usage', { turn, step, usage: assembler.usage })
|
||||
}
|
||||
return { hadToolCalls: false, finish: assembler.finish }
|
||||
}
|
||||
|
||||
// The step-result waterfall runs BEFORE the session append so the log (the
|
||||
// source of truth for derived history and replay) records the message that
|
||||
// tool dispatch actually uses.
|
||||
let message: Message = assembler.message()
|
||||
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
|
||||
const finish = assembler.finish
|
||||
const messageForLog: Message = finish.kind === 'max-tokens'
|
||||
? { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
|
||||
: message
|
||||
|
||||
if (finish.kind !== 'max-tokens' || messageForLog.content.length > 0) {
|
||||
session.append('assistant/message', { turn, step, content: messageForLog.content })
|
||||
}
|
||||
session.append('assistant/message', { turn, step, content: message.content })
|
||||
if (assembler.usage) {
|
||||
session.append('usage', { turn, step, usage: assembler.usage })
|
||||
}
|
||||
|
||||
// --- Tool execution (sequential; parallel execution is a TODO) ---
|
||||
// ToolRegistry.execute converts tool failures (including aborts) into
|
||||
// isError results, so abort is re-checked around every call here. A
|
||||
// max-tokens step is cut off: any tool-call block in it may be partial, so it
|
||||
// is neither dispatched nor recorded in the derived-history assistant message
|
||||
// above. Raw assistant/chunk events still preserve the exact stream.
|
||||
const toolCalls = finish.kind === 'max-tokens'
|
||||
? []
|
||||
: message.content.filter(block => block.type === 'tool-call')
|
||||
// isError results, so abort is re-checked around every call here.
|
||||
const toolCalls = message.content.filter(block => block.type === 'tool-call')
|
||||
for (const call of toolCalls) {
|
||||
/* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
|
||||
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
|
||||
@@ -564,7 +563,7 @@ async function runStep(
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
|
||||
return { hadToolCalls: toolCalls.length > 0, finish }
|
||||
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
|
||||
}
|
||||
|
||||
/** The last turn number in a (possibly seeded) session log, or 0. */
|
||||
@@ -580,7 +579,7 @@ export function lastTurnNumber(session: Session): number {
|
||||
* before `turn/start`, or the post-`turn/end` flush window before status
|
||||
* returns to idle), so status is not a reliable open-turn signal. Used by
|
||||
* `inject()` to choose between appending into an open turn vs. wrapping the
|
||||
* injection in its own one-shot turn (ADR 0017).
|
||||
* injection in its own one-shot turn (the turn-enclosure RFC).
|
||||
*/
|
||||
export function isTurnOpen(session: Session): boolean {
|
||||
const last = session.events.findLast(e => e.type === 'turn/start' || e.type === 'turn/end')
|
||||
|
||||
@@ -301,7 +301,7 @@ describe('disposed vs aborted branching', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('structured tool error propagation (RFC 005 pt 2)', () => {
|
||||
describe('structured tool error propagation (the runtime-validation RFC, part 2)', () => {
|
||||
it('forwards a tool HarnessError onto the tool/result session event', async () => {
|
||||
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
|
||||
// First model turn calls the tool; second turn (after the tool result is
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Property-based tests for the agent loop's inbox/turn scheduling (RFC 001 →
|
||||
* ADR 0013). Deterministic by construction: schedules are driven through the
|
||||
* `agent/status` settle signal (no wall-clock sleeps), so a flake is a finding,
|
||||
* not timing noise.
|
||||
* Property-based tests for the agent loop's inbox/turn scheduling (the
|
||||
* property-testing RFC). Deterministic by construction: schedules are driven
|
||||
* through the `agent/status` settle signal (no wall-clock sleeps), so a flake
|
||||
* is a finding, not timing noise.
|
||||
*
|
||||
* Invariants: every sent message appears exactly once in the log (none lost);
|
||||
* turn numbers strictly increase; status transitions follow the legal machine
|
||||
|
||||
@@ -39,7 +39,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
|
||||
})
|
||||
}
|
||||
|
||||
describe('RFC 009: AgentLoop factory create/resume', () => {
|
||||
describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
|
||||
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
|
||||
const adapter = new MockAdapter([textResponse('hi')])
|
||||
const { ctx } = await persistentHarness(adapter)
|
||||
@@ -128,7 +128,7 @@ describe('RFC 009: AgentLoop factory create/resume', () => {
|
||||
|
||||
it('an idle inject() is flushed durably on its own (survives without explicit flush/dispose)', async () => {
|
||||
// Lifecycle 1: run a turn, then inject context while idle. The idle inject
|
||||
// wraps its context/message in a one-shot turn AND checkpoints it (ADR 0017)
|
||||
// wraps its context/message in a one-shot turn AND checkpoints it (the turn-enclosure RFC)
|
||||
// — without an explicit flush or clean dispose, the notice must still reach
|
||||
// disk, since a crash before the next turn would otherwise lose it.
|
||||
const adapter1 = new MockAdapter([textResponse('answer')])
|
||||
|
||||
@@ -619,7 +619,7 @@ describe('P1-6: step/start is appended before agent/step-start is emitted', () =
|
||||
const agent = ctx.agentLoop.create('a-step-order', { model: 'mock' })
|
||||
|
||||
// Capture, at the moment agent/step-start fires, whether the matching
|
||||
// step/start event is already in the log (append-before-emit, ADR 0003).
|
||||
// step/start event is already in the log (append-before-emit, the event-sourcing RFC).
|
||||
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
|
||||
ctx.on('agent/step-start', (subject, turn, step) => {
|
||||
if (subject !== agent) return
|
||||
@@ -828,7 +828,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
// loop must therefore still owe (and append) a turn/end — deciding "owed"
|
||||
// from the log via isTurnOpen, not a "turn started" flag that the throw
|
||||
// skipped. Otherwise the turn stays permanently open and poisons the next
|
||||
// turn/replay (ADR 0017). (Uses the plain harness — NOT the invariants
|
||||
// turn/replay (the turn-enclosure RFC). (Uses the plain harness — NOT the invariants
|
||||
// oracle — because the throwing listener is itself a session/event
|
||||
// subscriber.)
|
||||
const adapter = new MockAdapter([textResponse('turn 2')])
|
||||
@@ -868,7 +868,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
|
||||
// Regression: a normal turn completes, closeTurn(true) appends turn/end and
|
||||
// emits agent/turn-end whose listener throws. The error must NOT be appended
|
||||
// as a session event after turn/end — that would sit past the commit
|
||||
// boundary and be dropped as a crash tail on resume (ADR 0017). It is
|
||||
// boundary and be dropped as a crash tail on resume (the turn-enclosure RFC). It is
|
||||
// surfaced via agent/error instead, and the log's last event is turn/end.
|
||||
const adapter = new MockAdapter([textResponse('done'), textResponse('next ok')])
|
||||
const ctx = await balancedHarness(adapter)
|
||||
|
||||
@@ -18,7 +18,7 @@ Agent *creation* is provided by whichever plugin implements `AgentFactory` (phas
|
||||
|
||||
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
|
||||
- `ctx.agents.create(options: CreateAgentOptions): Agent` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<Agent>` — load a persisted session (RFC 009) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
|
||||
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<Agent>` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
|
||||
|
||||
### Events
|
||||
|
||||
@@ -53,7 +53,7 @@ The handle every plugin programs against:
|
||||
|
||||
- `agent.send(content, options?)` — queue a message; starts a turn when idle
|
||||
- `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle
|
||||
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed (ADR 0017)
|
||||
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/2026-06-15-turn-enclosure-invariant.md))
|
||||
- `agent.abort(reason?)` — abort the in-flight step
|
||||
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit), the signal a teardown awaits (`abort()` then `await whenIdle()`). Observes the transition without disposing the agent.
|
||||
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
|
||||
|
||||
@@ -64,7 +64,7 @@ export interface Agent {
|
||||
* request sees at its chronological position, rendered as tagged synthetic
|
||||
* context rather than a user prompt. Does not run the model.
|
||||
*
|
||||
* Turn-enclosure (ADR 0017): an inject while a turn is open joins that turn;
|
||||
* Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn;
|
||||
* an inject while idle wraps its `context/message` in a one-shot `injection`
|
||||
* turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for
|
||||
* durability, so every event stays inside a turn and a persistence backend
|
||||
|
||||
@@ -44,7 +44,7 @@ On any violation it throws `InvariantError` (`code: 'INVARIANT'`).
|
||||
|
||||
## Why runtime, not deep-readonly types
|
||||
|
||||
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [ADR 0012](../../docs/adr/0012-dev-invariants-over-deep-readonly.md).
|
||||
A `DeepReadonly<SessionEvent>` is high type-noise across every log consumer, and a plugin can cast straight through it. A dev-mode freeze plus these assertions catch real corruption at zero production cost and zero type noise. The always-on half of that defense — cloning derived messages so request/adapter mutation can't reach back into the log — lives in `dsh-session`'s `deriveMessages`. This package is the dev-mode tripwire. See [dev-mode invariants](../../docs/rfc/implemented/2026-06-11-dev-invariants-over-deep-readonly.md).
|
||||
|
||||
## Seeded sessions
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* taxonomy: the assertions below ARE the contract.
|
||||
*
|
||||
* Why runtime assertions instead of compile-time deep-readonly types? See
|
||||
* ADR 0012. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
|
||||
* the dev-invariants RFC. Briefly: a `DeepReadonly<SessionEvent>` is high type-noise across
|
||||
* every log consumer and a plugin casts straight through it; a dev-mode freeze
|
||||
* + assertions catch real corruption at zero production cost and zero type
|
||||
* noise. The always-on half of that defense (cloning derived messages) lives
|
||||
@@ -75,7 +75,7 @@ interface SessionTrace {
|
||||
* frozen: `Session.append()` accepts event data from arbitrary plugins/tools,
|
||||
* so a caller can hand us a SHALLOW-frozen object whose descendants are still
|
||||
* mutable. Skipping an already-frozen node (the obvious idempotence shortcut)
|
||||
* would leave exactly the kind of mutable history ADR 0012 means to catch. A
|
||||
* would leave exactly the kind of mutable history the dev-invariants RFC means to catch. A
|
||||
* `WeakSet` of visited objects keeps it terminating on cycles and avoids
|
||||
* re-walking shared subtrees / already-processed seed events.
|
||||
*/
|
||||
@@ -111,7 +111,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
|
||||
// Boundary/step-scoped events have explicit cases; every OTHER event type —
|
||||
// including plugin-added (merge-extensible) SessionEventMap keys — is caught
|
||||
// by the `default` and must be turn-enclosed (ADR 0017). No assertNever: an
|
||||
// by the `default` and must be turn-enclosed (the turn-enclosure RFC). No assertNever: an
|
||||
// unknown variant is valid, not a compile error.
|
||||
switch (event.type) {
|
||||
case 'turn/start': {
|
||||
@@ -182,7 +182,7 @@ function checkEvent(trace: SessionTrace, event: SessionEvent): void {
|
||||
}
|
||||
break
|
||||
}
|
||||
// Turn-enclosure (ADR 0017): EVERY session event not handled by a boundary
|
||||
// Turn-enclosure (the turn-enclosure RFC): EVERY session event not handled by a boundary
|
||||
// case above must sit inside an open turn. The durable session log uses the
|
||||
// turn as its commit/replay boundary (the JSONL backend treats anything
|
||||
// after the last turn/end as a crash tail), so a bare event between turns is
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('session-log invariants', () => {
|
||||
it('rejects a message event appended outside any open turn (turn-enclosure)', async () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// No turn open: every message-bearing event must be turn-enclosed (ADR 0017).
|
||||
// No turn open: every message-bearing event must be turn-enclosed (the turn-enclosure RFC).
|
||||
expect(() => session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
|
||||
.toThrow(/outside any open turn/)
|
||||
expect(() => session.append('context/message', { content: [{ type: 'text', text: 'ctx' }], source: { kind: 'user' } }))
|
||||
@@ -99,7 +99,7 @@ describe('session-log invariants', () => {
|
||||
const { ctx } = await setup({ freeze: false })
|
||||
const session = ctx.sessions.create()
|
||||
// usage and error are turn-scoped: outside a turn they would land past the
|
||||
// commit boundary and be dropped on resume (ADR 0017).
|
||||
// commit boundary and be dropped on resume (the turn-enclosure RFC).
|
||||
expect(() => session.append('usage', { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } }))
|
||||
.toThrow(/outside any open turn/)
|
||||
expect(() => session.append('error', { turn: 1, step: 1, message: 'boom' }))
|
||||
@@ -317,7 +317,7 @@ describe('dev-freeze', () => {
|
||||
// A caller hands in a SHALLOW-frozen block whose nested array is still
|
||||
// mutable. deepFreeze must descend into the already-frozen object and
|
||||
// freeze the descendant, not short-circuit on the frozen container —
|
||||
// otherwise dev-mode misses exactly the history mutation ADR 0012 catches.
|
||||
// otherwise dev-mode misses exactly the history mutation the dev-invariants RFC catches.
|
||||
// `append` snapshots `data`, so the freeze applies to the LOGGED clone, not
|
||||
// the caller's input — read the event back and assert on its data.
|
||||
const innerContent: { type: 'text'; text: string }[] = [{ type: 'text', text: 'inner' }]
|
||||
|
||||
@@ -43,4 +43,4 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
|
||||
|
||||
### Real adapters
|
||||
|
||||
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [ADR 0010](../../docs/adr/0010-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
|
||||
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* event so retry/sandbox plugins and replay can distinguish failure classes.
|
||||
*
|
||||
* Lives in dsh-llm (the leaf package every other imports) so a single base is
|
||||
* shared without a new dependency edge. See ADR 0015.
|
||||
* shared without a new dependency edge. See the error-taxonomy RFC.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm/error
|
||||
*/
|
||||
|
||||
@@ -169,7 +169,7 @@ describe('assertNever', () => {
|
||||
|
||||
describe('BlockAssembler regressions (property-test findings)', () => {
|
||||
it('first block-end wins: a duplicate block-end for a closed index is ignored', () => {
|
||||
// Found by fast-check (RFC 001): two block-ends at the same index made the
|
||||
// Found by fast-check (the property-testing RFC): two block-ends at the same index made the
|
||||
// streamed prefix (first block) disagree with final blocks() (second
|
||||
// block). The first close must win — same straggler rule as post-close
|
||||
// deltas — so streaming and one-shot assembly stay identical.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Property-based tests for the BlockAssembler (RFC 001 → ADR 0013).
|
||||
* Property-based tests for the BlockAssembler (the property-testing RFC).
|
||||
*
|
||||
* The assembler is protocol-shaped: arbitrary interleavings of block-start,
|
||||
* deltas, block-end, usage, and finish — valid and malformed (duplicate
|
||||
|
||||
@@ -24,7 +24,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
|
||||
|
||||
- **Lazy materialization.** `create(meta)` writes nothing; the `.jsonl` (header + first batch) is written atomically (temp-write + `fsync` + rename) on the first `append`. A created-but-never-appended session leaves nothing on disk and is absent from `has`/`list`.
|
||||
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent appends are line appends at EOF + `fsync`.
|
||||
- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See ADR 0018.
|
||||
- **Crash recovery — close, don't truncate.** A crash can leave a log whose final turn never closed (real events after the last `turn/end`). `load` PRESERVES those events (a turn can be huge — they are real work) and closes the orphaned turn by durably appending synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered (the loop logs the assistant message before running the tools, so a mid-tool crash leaves dangling calls — and `deriveMessages()` would replay an assistant tool-call with no result, which providers reject), then a `step/end` if a step was open, then `turn/end {kind:'interrupted'}`, returning a balanced log. Only a never-fully-written **torn tail fragment** (a final line with no newline / unparseable) is `ftruncate`d away before the closers are written. See [session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md).
|
||||
- **Contiguous-seq.** `load` rejects a mid-log parse error or `seq` gap (unloadable); `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
|
||||
- **Format version.** Only v1 is supported; `load` rejects an unknown version. A future format change requires a version bump + migration.
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ export function eventLine(event: SessionEvent): string {
|
||||
* fully-written events sit after the last `turn/end`. Those are PRESERVED (a
|
||||
* single turn can be huge in a long-horizon task — truncating it would destroy
|
||||
* real work); the backend closes the orphaned open turn with a synthetic
|
||||
* `turn/end {kind:'interrupted'}` on reload (ADR 0018). Only a TORN trailing
|
||||
* `turn/end {kind:'interrupted'}` on reload (the session-persistence RFC). Only a TORN trailing
|
||||
* fragment — a final line never fully flushed (no newline, unparseable, or a
|
||||
* seq gap) — is excluded; it bounds the preserved region. A parse error or seq
|
||||
* gap AT OR BEFORE the last committed `turn/end` is committed-data corruption
|
||||
@@ -208,7 +208,7 @@ export function scanLog(buffer: Buffer): { meta: SessionMeta; events: SessionEve
|
||||
// last turn/end — those are real, durably-written work and must NOT be
|
||||
// truncated (a single turn can be huge in a long-horizon task; the orphaned
|
||||
// open turn is closed with a synthetic turn/end on reload, not discarded —
|
||||
// ADR 0018). The walk stops at the first hole (unparseable line or seq gap):
|
||||
// the session-persistence RFC). The walk stops at the first hole (unparseable line or seq gap):
|
||||
// - if that hole is AT OR BEFORE the last committed turn/end, committed data
|
||||
// was damaged → the session is unloadable (throw);
|
||||
// - if it is AFTER (or there is no committed turn/end yet), it is the
|
||||
|
||||
@@ -282,7 +282,7 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
// continue with no special-casing. Synthesize the boundary events (a
|
||||
// step/end if a step was open, then a turn/end {kind:'interrupted'}); the
|
||||
// interrupted turn's real events are preserved, never truncated (a turn can
|
||||
// be huge — ADR 0018).
|
||||
// be huge — the session-persistence RFC).
|
||||
const closers = interruptedTurnClosers(events)
|
||||
const balanced = [...events, ...closers]
|
||||
|
||||
@@ -314,6 +314,34 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
return { meta: fullMeta, events: balanced }
|
||||
}
|
||||
|
||||
private async adoptLiveDiskPrefix(
|
||||
session: Session,
|
||||
seed: readonly SessionEvent[],
|
||||
file: { path: string; cwd: string | undefined },
|
||||
): Promise<void> {
|
||||
const buffer = await readFile(file.path)
|
||||
const { meta, events, committedBytes } = scanLog(buffer)
|
||||
this.assertVersion(meta)
|
||||
if (!seedCoversPrefix(seed, events)) {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
}
|
||||
|
||||
const summary = await this.readSidecar(session.header.id, meta.cwd)
|
||||
const state: SessionState = {
|
||||
meta: { ...meta, ...summary },
|
||||
cursor: events.length,
|
||||
materialized: true,
|
||||
owner: session,
|
||||
}
|
||||
this.states.set(session.header.id, state)
|
||||
|
||||
if (committedBytes < buffer.byteLength) {
|
||||
await this.repair(state, committedBytes)
|
||||
}
|
||||
const suffix = seed.slice(events.length)
|
||||
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
|
||||
}
|
||||
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
const metas: SessionMeta[] = []
|
||||
for (const dir of await this.listCwdDirs()) {
|
||||
@@ -814,28 +842,11 @@ export class SessionPersistenceJsonl extends SessionPersistence {
|
||||
|
||||
const onDisk = await this.findLog(id, session.header.cwd)
|
||||
if (onDisk !== undefined) {
|
||||
// Read the committed on-disk events and check they are a seq-aligned
|
||||
// prefix of the live session (HMR re-seeing its own session) vs. an
|
||||
// unrelated session colliding on the id.
|
||||
const { events: diskEvents } = scanLog(await readFile(onDisk.path))
|
||||
if (!seedCoversPrefix(seed, diskEvents)) {
|
||||
// case 3: genuine collision — fail loudly rather than clobber.
|
||||
throw new Error(`session "${id}" already has a persisted log on disk that does not match this live session (id collision)`)
|
||||
}
|
||||
// case 2: adopt. loadCore sets the state (cursor = committed length,
|
||||
// repair offset if a crash tail exists).
|
||||
await this.serialize(id, () => this.loadCore(id))
|
||||
const adopted = this.states.get(id)
|
||||
/* v8 ignore next -- loadCore always sets the state for the id */
|
||||
if (adopted !== undefined) adopted.owner = session
|
||||
// Persist the live SUFFIX beyond the on-disk prefix. These events live
|
||||
// ONLY in `seed` (the live session was ahead of disk — mid-turn at
|
||||
// reload, or events appended while the previous backend was disposed);
|
||||
// this backend never buffered them via session/event, so without this
|
||||
// they would be lost and the next flush (starting at a later seq) would
|
||||
// mismatch or skip them.
|
||||
const suffix = seed.slice(diskEvents.length)
|
||||
if (suffix.length > 0) await this.append(id, suffix)
|
||||
// case 2: adopt a LIVE prefix. Do NOT route through loadCore(): loadCore
|
||||
// crash-repairs open turns as interrupted, which is right for a true load
|
||||
// after a crash but wrong for HMR while the live Session is still the
|
||||
// authority and may append the real step/turn end later.
|
||||
await this.serialize(id, () => this.adoptLiveDiskPrefix(session, seed, onDisk))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -837,6 +837,30 @@ describe('SessionPersistenceJsonl: edge cases', () => {
|
||||
await ctx2.fiber.dispose()
|
||||
})
|
||||
|
||||
it('HMR adoption does not crash-repair an active open turn as interrupted', async () => {
|
||||
const dir = await freshRoot()
|
||||
const hmr = new Context()
|
||||
await hmr.plugin(SessionStore)
|
||||
const first = await hmr.plugin(SessionPersistenceJsonl, { root: dir })
|
||||
const session = hmr.sessions.create('hmr-open', { meta: { cwd: '/hmr' } })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
await hmr.parallel('session/flush', session)
|
||||
|
||||
await first.dispose()
|
||||
await appendFile(logPath(dir, '/hmr', SessionId('hmr-open')), '{"torn":')
|
||||
const second = await hmr.plugin(SessionPersistenceJsonl, { root: dir })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await hmr.parallel('session/flush', session)
|
||||
|
||||
const loaded = await hmr.sessionPersistence.load(SessionId('hmr-open'))
|
||||
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
|
||||
await second.dispose()
|
||||
await hmr.fiber.dispose()
|
||||
})
|
||||
|
||||
it('a NEW live session whose id collides with an on-disk log is rejected, not silently adopted', async () => {
|
||||
// Persist a session on disk.
|
||||
const s1 = ctx.sessions.create('collide', { meta: { cwd: '/a' } })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-session-persistence-sqlite
|
||||
|
||||
A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([ADR 0018](../../docs/adr/0018-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
|
||||
A SQLite durable session-persistence backend — a second `SessionPersistence` implementation ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)), built to validate that the abstract seam and the shared `runPersistenceContract` suite are genuinely backend-agnostic. It satisfies the SAME contract as `dsh-session-persistence-jsonl` (append-only, contiguous-seq, lazy materialization, interrupted-turn close on load), expressed over `node:sqlite` rows instead of file bytes.
|
||||
|
||||
> **TODO:** this backend talks to `node:sqlite` directly. If a cordis database service (`cordis/db` / a `@cordisjs` SQL driver plugin) is adopted, route through that instead of holding a raw `DatabaseSync` here — the contract surface (`SessionPersistence`) would not change, only the storage driver.
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
// agree — both append routes then continue with no special-casing. Synthesize
|
||||
// the boundary events (a step/end if a step was open, then a
|
||||
// turn/end {kind:'interrupted'}); the interrupted turn's real events are
|
||||
// preserved, never truncated (ADR 0018).
|
||||
// preserved, never truncated (the session-persistence RFC).
|
||||
const closers = interruptedTurnClosers(preserved)
|
||||
const balanced = [...preserved, ...closers]
|
||||
|
||||
@@ -285,6 +285,35 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
return { meta, events: balanced }
|
||||
}
|
||||
|
||||
private async adoptLiveStoredPrefix(session: Session, seed: readonly SessionEvent[]): Promise<void> {
|
||||
await this.ready
|
||||
const row = this.rowFor(session.header.id)
|
||||
/* v8 ignore next -- caller checked row existence */
|
||||
if (row === undefined) throw new Error(`session "${session.header.id}" not found`)
|
||||
const meta = rowToMeta(row)
|
||||
this.assertVersion(meta)
|
||||
|
||||
const rows = this.db
|
||||
.prepare('SELECT seq, type, time, data FROM events WHERE session_id = ? ORDER BY seq')
|
||||
.all(session.header.id) as unknown as EventRow[]
|
||||
const { preserved, tornFrom } = scanRows(rows)
|
||||
if (!seedCoversPrefix(seed, preserved)) {
|
||||
throw new Error(`session "${session.header.id}" already has a persisted log that does not match this live session (id collision)`)
|
||||
}
|
||||
|
||||
if (tornFrom !== undefined) {
|
||||
this.db.prepare('DELETE FROM events WHERE session_id = ? AND seq >= ?').run(session.header.id, tornFrom)
|
||||
}
|
||||
this.states.set(session.header.id, {
|
||||
meta: { ...meta },
|
||||
cursor: preserved.length,
|
||||
materialized: true,
|
||||
owner: session,
|
||||
})
|
||||
const suffix = seed.slice(preserved.length)
|
||||
if (suffix.length > 0) await this.appendCore(session.header.id, suffix)
|
||||
}
|
||||
|
||||
async list(): Promise<SessionMeta[]> {
|
||||
await this.ready
|
||||
// Every metadata row is a materialized session: the row is written only by
|
||||
@@ -489,16 +518,9 @@ export class SessionPersistenceSqlite extends SessionPersistence {
|
||||
|
||||
const row = this.rowFor(id)
|
||||
if (row !== undefined) {
|
||||
const stored = this.eventsFor(id)
|
||||
if (!seedCoversPrefix(seed, stored)) {
|
||||
throw new Error(`session "${id}" already has a persisted log that does not match this live session (id collision)`)
|
||||
}
|
||||
await this.serialize(id, () => this.loadCore(id))
|
||||
const adopted = this.states.get(id)
|
||||
/* v8 ignore next -- loadCore always sets the state for the id */
|
||||
if (adopted !== undefined) adopted.owner = session
|
||||
const suffix = seed.slice(stored.length)
|
||||
if (suffix.length > 0) await this.append(id, suffix)
|
||||
// Adopt a LIVE prefix without crash-repairing an open turn as interrupted;
|
||||
// HMR may still append the real completion from the live Session.
|
||||
await this.serialize(id, () => this.adoptLiveStoredPrefix(session, seed))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ export function rowToEvent(row: EventRow): SessionEvent {
|
||||
* fully-written rows sit after the last `turn/end`. Those are PRESERVED — a
|
||||
* single turn can be huge in a long-horizon task, so truncating it would
|
||||
* destroy real work; the backend closes the orphaned open turn with a synthetic
|
||||
* `turn/end {kind:'interrupted'}` on load (ADR 0018). The ONLY thing excluded is
|
||||
* `turn/end {kind:'interrupted'}` on load (the session-persistence RFC). The ONLY thing excluded is
|
||||
* a torn trailing fragment — a row whose `data` never parses, or a seq gap —
|
||||
* AFTER the last committed `turn/end`; that bounds the preserved region and its
|
||||
* seq is returned as `tornFrom` so `load` can physically delete it.
|
||||
|
||||
@@ -108,6 +108,35 @@ describe('scanRows', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistenceSqlite: HMR adoption', () => {
|
||||
it('does not crash-repair an active open turn as interrupted', async () => {
|
||||
const path = await freshDbPath()
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const first = await ctx.plugin(SessionPersistenceSqlite, { path })
|
||||
const session = ctx.sessions.create('hmr-open', { meta: { cwd: '/hmr' } })
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
await ctx.parallel('session/flush', session)
|
||||
|
||||
await first.dispose()
|
||||
const db = openDatabase(path)
|
||||
db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)')
|
||||
.run('hmr-open', 2, 'step/end', 2, '{"torn":')
|
||||
db.close()
|
||||
const second = await ctx.plugin(SessionPersistenceSqlite, { path })
|
||||
session.append('step/end', { turn: 1, step: 1 })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
await ctx.parallel('session/flush', session)
|
||||
|
||||
const loaded = await ctx.sessionPersistence.load(SessionId('hmr-open'))
|
||||
expect(loaded.events.map(e => e.type)).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
|
||||
expect(loaded.events.at(-1)).toMatchObject({ type: 'turn/end', data: { reason: { kind: 'completed' } } })
|
||||
await second.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SessionPersistenceSqlite: durability and crash semantics', () => {
|
||||
it('an interrupted turn (rows after the last turn/end) is PRESERVED and closed during load', async () => {
|
||||
const path = await freshDbPath()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-session-persistence
|
||||
|
||||
The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, list, and update sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([ADR 0009](../../docs/adr/0009-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface.
|
||||
The abstract durable session-persistence seam (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, list, and update sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../docs/rfc/implemented/2026-06-13-capability-seams.md)): an abstract service here, a concrete implementation in a sibling package, consumers that inject the interface.
|
||||
|
||||
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage) travels separately as `SessionMeta`, owned by `dsh-session` and re-exported here.
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export abstract class SessionPersistence extends Service {
|
||||
* fragment (a half-written final record) is discarded. Returned events are
|
||||
* contiguous (`events[i].seq === i`); a parse error or a `seq` gap in the
|
||||
* COMMITTED region (at or before the last real `turn/end`) makes the session
|
||||
* unloadable (reject). Rejects an unknown format `version`. See ADR 0018 for
|
||||
* unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for
|
||||
* the crash-recovery contract.
|
||||
*/
|
||||
abstract load(id: SessionId): Promise<{ meta: SessionMeta; events: SessionEvent[] }>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* JSON-serializability validation for session event data.
|
||||
*
|
||||
* The session event log is the durable source of truth (ADR 0003/0018): every
|
||||
* The session event log is the durable source of truth (the event-sourcing / session-persistence RFCs): every
|
||||
* `event.data` must round-trip losslessly through JSON so any persistence
|
||||
* backend can store and reload it byte-identically. This invariant belongs to
|
||||
* the log itself — `Session.append` enforces it at the source, so a
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* 3. a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason.
|
||||
*
|
||||
* The marker records that the turn was cut short by a crash, not completed by
|
||||
* the model. See ADR 0018.
|
||||
* the model. See the session-persistence RFC.
|
||||
*
|
||||
* Why the synthetic tool results matter: `deriveMessages()` renders the
|
||||
* `tool-call` blocks inside a durable `assistant/message` but only emits a
|
||||
@@ -79,6 +79,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
|
||||
openStep = event.data.step
|
||||
break
|
||||
case 'step/end':
|
||||
pendingCalls.clear()
|
||||
openStep = null
|
||||
break
|
||||
case 'assistant/message':
|
||||
|
||||
@@ -102,7 +102,7 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
|
||||
* still wins). It is distinct from `completed` so a consumer (e.g. the ACP
|
||||
* bridge mapping to `StopReason: 'max_tokens'`) can tell a clean stop from a
|
||||
* truncated one. The next variants to add — when an adapter/loop first emits
|
||||
* them — are `refusal` and `max_turn_requests` (both named by RFC 010 as ACP
|
||||
* them — are `refusal` and `max_turn_requests` (both named by the ACP RFC as ACP
|
||||
* stop reasons); no current adapter produces a `refusal` finish (unknown
|
||||
* DeepSeek finish reasons collapse to `error`), so it is deliberately omitted
|
||||
* until one does.
|
||||
@@ -121,7 +121,7 @@ export interface TurnEndReasonMap {
|
||||
* the crash) and are PRESERVED, not discarded: a single turn can be huge in a
|
||||
* long-horizon task (many steps, large tool output), so truncating it would
|
||||
* lose real work. The marker records that the turn was cut short, not that the
|
||||
* model completed it. See ADR 0018.
|
||||
* model completed it. See the session-persistence RFC.
|
||||
*/
|
||||
interrupted: { kind: 'interrupted' }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Property-based tests for the Session event log (RFC 001 → ADR 0013).
|
||||
* Property-based tests for the Session event log (the property-testing RFC).
|
||||
*
|
||||
* Generates arbitrary event logs and asserts the derivation invariants the
|
||||
* agent loop and replay depend on: deriveMessages is deterministic and
|
||||
|
||||
@@ -82,6 +82,21 @@ describe('interruptedTurnClosers', () => {
|
||||
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
|
||||
})
|
||||
|
||||
it('does NOT synthesize a result after the owning step already closed', () => {
|
||||
const events: SessionEvent[] = [
|
||||
userTurnStart(2, 0),
|
||||
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
|
||||
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
|
||||
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
|
||||
] } },
|
||||
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
|
||||
]
|
||||
|
||||
const closers = interruptedTurnClosers(events)
|
||||
expect(closers.map(e => e.type)).toEqual(['turn/end'])
|
||||
expect(closers[0]?.seq).toBe(4)
|
||||
})
|
||||
|
||||
it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
|
||||
// Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
|
||||
// with an unanswered call. Only turn 2's call must get a synthetic result.
|
||||
|
||||
@@ -32,6 +32,10 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[timed
|
||||
|
||||
The owning agent is recorded per task id at spawn and kept for the lifetime of the loaded plugin instance (it is **not** cleared on completion). `bash_output`/`bash_kill` reject a task owned by a *different* agent with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this ownership check is the fence that stops one session's agent from reading or killing another session's background task. (`TODO(tool-bash-owner-hmr)`: an independent HMR reload of this plugin starts a fresh map, so a task spawned before the reload becomes un-owned — acceptable as HMR is dev-only and the session boundary is one user's cooperative editor; a durable fix attaches ownership to the executor/task lifetime.)
|
||||
|
||||
## UI presentation
|
||||
|
||||
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam — a UI never special-cases tool names. For `bash`: the **title** is the exact `command` ("ls -la src") and `kind` is `execute` (terminal/run treatment), matching the reference ACP adapters (claude-agent-acp, codex-acp), which both use the bare command as an execute tool's title. The command is ALSO the **rawInput** for non-terminal UIs that render it (an execute-kind card hides rawInput — Zed shows it only for non-terminal tools — so the command must BE the title to be seen). The model-written `description` rides as a **content** text block shown ABOVE the card. (claude-agent-acp DROPS the description in terminal mode and shows only the card; surfacing it as a content block is a deliberate divergence — we keep the human summary visible alongside the card.) The completed output is wrapped in a fenced ` ```console ` block as the no-terminal-capability fallback — a UI-only affordance, so the model-facing result text stays unfenced. A FOREGROUND `bash` run also flags itself as a **terminal** (the neutral `terminal` field: `presentCall` sets a `cwd` from the model `workdir` when given — absolute as-is, relative for the UI bridge to resolve against the session cwd — else leaves it for the bridge to fill from the session cwd; `presentResult` carries the raw output plus the parsed `exitCode`/`signal`) so a capable client (Zed) renders a terminal card with an exit-status pill instead of the text block — see `packages/acp` ("Terminal card"). A `run_in_background` call is NOT a terminal (it returns a task id immediately and never streams a terminal — poll with `bash_output`), and an `isError` result (spawn failure / abort) carries no exit pill (there is no real process exit); both render as the ordinary execute card / fenced text. `bash_output`/`bash_kill` present a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") with the task id as rawInput. These methods are pure/display-only (they also run on `session/load` replay), and a malformed/older logged arg shape falls back to a generic presentation rather than throwing. See `packages/tools` ("Tool-owned UI presentation") and `packages/acp` ("Terminal card" / "Tool-call presentation").
|
||||
|
||||
## Background completion notices
|
||||
|
||||
When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`.
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import type { Context } from 'cordis'
|
||||
import { isAbsolute, resolve as resolvePath } from 'node:path'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
|
||||
|
||||
@@ -47,10 +48,11 @@ export const inject = ['tools', 'bash']
|
||||
|
||||
/**
|
||||
* Validate the constraints the SchemaSpec can't express. `defineTool` now
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (RFC 005
|
||||
* → ADR 0011), so type/required/enum checks are already done and `args` is
|
||||
* the validated `InferArgs` shape here. What remains are value constraints the
|
||||
* DSL has no vocabulary for: non-empty strings and a positive, finite timeout.
|
||||
* validates parsed args against the SchemaSpec before `execute` runs (the
|
||||
* arg-validation RFC), so type/required/enum checks are already done and `args`
|
||||
* is the validated `InferArgs` shape here. What remains are value constraints
|
||||
* the DSL has no vocabulary for: non-empty strings and a positive, finite
|
||||
* timeout.
|
||||
*/
|
||||
function validateBashArgs(args: {
|
||||
command: string
|
||||
@@ -72,7 +74,7 @@ function validateBashArgs(args: {
|
||||
|
||||
/**
|
||||
* Reject an empty `task_id`. Type and presence are guaranteed by the
|
||||
* SchemaSpec validation (ADR 0011); only the non-empty constraint, which the
|
||||
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
|
||||
* DSL can't express, is left to check here.
|
||||
*/
|
||||
function validateTaskId(value: string): string {
|
||||
@@ -123,6 +125,119 @@ export function renderResult(result: BashRunResult): string {
|
||||
return body + markers.join('\n')
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
|
||||
// renders a bash call's pending and completed states. They are display-only and
|
||||
// pure — a UI may call them during live streaming AND a session-log replay.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
|
||||
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
|
||||
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
|
||||
* = !is_terminal_tool`), so the command must BE the title to be seen. This
|
||||
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
|
||||
* use the bare command as an execute tool's title. The model-written
|
||||
* `description` (a readable summary) rides as a `content` text block shown ABOVE
|
||||
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
|
||||
* shows only the card; surfacing it as a content block is a deliberate
|
||||
* divergence here — we keep the human summary visible alongside the card.)
|
||||
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
|
||||
*
|
||||
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
|
||||
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
|
||||
* immediately (it never streams a terminal; its output is polled via
|
||||
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
|
||||
* execute card. For a foreground run the `terminal.cwd` (header) is the model
|
||||
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
|
||||
* against the session cwd; when omitted the bridge fills the session workspace
|
||||
* cwd (this PURE presenter, args only, can't see it).
|
||||
*/
|
||||
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
|
||||
|
||||
function presentBashCall(args: BashCallArgs): ToolCallPresentation {
|
||||
const base = {
|
||||
title: args.command,
|
||||
kind: 'execute' as const,
|
||||
rawInput: args.command,
|
||||
content: [{ type: 'text' as const, text: args.description }],
|
||||
}
|
||||
// A background start is not an interactive terminal — no terminal card.
|
||||
if (args.run_in_background === true) return base
|
||||
return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Completed-state presentation for a `bash` call. Two parallel renderings of the
|
||||
* same output: `terminal.output` for a UI that shows a terminal card (the run's
|
||||
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
|
||||
* newlines preserved, since a terminal renderer relies on exact bytes), and a
|
||||
* fenced ```console `content` block as the fallback for a UI without terminal
|
||||
* support (the fences are a UI-only affordance, so they live here, not in the
|
||||
* model-facing result; the fenced body is trimmed of trailing blank lines for a
|
||||
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
|
||||
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
|
||||
*
|
||||
* Terminal output/exit is suppressed for results that are NOT a finished
|
||||
* foreground run: a `run_in_background` start (`isBackground` — the text is a
|
||||
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
|
||||
* abort — there is no real process exit to pill, and the body is an error
|
||||
* message, not `renderResult` output, so parsing it would be meaningless). Those
|
||||
* fall back to the fenced `content` block with no terminal metadata. The bridge's
|
||||
* orphan guard also drops a result terminal when the call wasn't terminal, so a
|
||||
* background call (not marked terminal in `presentBashCall`) is doubly safe.
|
||||
* A non-text result (unexpected for bash) falls through to `undefined`.
|
||||
*/
|
||||
function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
const raw = block.text
|
||||
const fenced = raw.replace(/\n+$/, '')
|
||||
const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }]
|
||||
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
|
||||
// No exit pill / terminal output for a background ack or an errored run.
|
||||
if (isBackground || result.isError) return { content }
|
||||
return { content, terminal: { output: raw, ...parseExitStatus(raw) } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the structured exit status from a rendered `renderResult` string — the
|
||||
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
|
||||
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
|
||||
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
|
||||
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
|
||||
*
|
||||
* Why parse rendered text at all: `presentResult` is replay-safe and on a
|
||||
* `session/load` the ONLY thing persisted is this content text — the structured
|
||||
* `BashRunResult` is long gone — so unless the exit were added to the persisted
|
||||
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
|
||||
* is the only channel. The match is anchored to a LEADING newline + end-of-string
|
||||
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
|
||||
* a non-empty body: a real marker is therefore always its own final line. That
|
||||
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
|
||||
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
|
||||
*
|
||||
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
|
||||
* whose body's FINAL line is itself exactly the marker text — `[exit code: N]`
|
||||
* or `[killed by signal: SIG]`, printed by the program with nothing after — is
|
||||
* still indistinguishable from a real marker and would show a wrong pill. This is
|
||||
* display-only (execution and the model-facing text are unaffected) and narrow;
|
||||
* the complete fix is to persist a structured exit on the result event, which the
|
||||
* RFC names as the escape hatch.
|
||||
*/
|
||||
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
|
||||
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
|
||||
if (signal?.[1] !== undefined) return { signal: signal[1] }
|
||||
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
|
||||
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
|
||||
return { exitCode: 0 }
|
||||
}
|
||||
|
||||
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
|
||||
function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation {
|
||||
return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the working directory for a bash call. Precedence: an explicit model
|
||||
* `workdir` wins; otherwise default to the calling agent's session cwd
|
||||
@@ -241,6 +356,8 @@ export function apply(ctx: Context): void {
|
||||
if (result.aborted) throw new Error('command aborted')
|
||||
return [{ type: 'text', text: renderResult(result) }]
|
||||
},
|
||||
presentCall: presentBashCall,
|
||||
presentResult: presentBashResult,
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -265,6 +382,7 @@ export function apply(ctx: Context): void {
|
||||
text += `\n${statusLine(read.task)}`
|
||||
return Promise.resolve([{ type: 'text', text }])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Read output from', args),
|
||||
}))
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -282,5 +400,6 @@ export function apply(ctx: Context): void {
|
||||
text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
|
||||
}])
|
||||
},
|
||||
presentCall: args => presentTaskCall('Kill', args),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('bash tool', () => {
|
||||
})
|
||||
|
||||
// Type and required-key violations are now rejected by the harness
|
||||
// (defineTool validates against the SchemaSpec — ADR 0011) before execute.
|
||||
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
|
||||
it.each([
|
||||
[{}, /missing required property "command"/],
|
||||
[{ command: 42, description: 'd' }, /"command" must be a string/],
|
||||
@@ -562,3 +562,152 @@ describe('status lines', () => {
|
||||
expect(text(read)).toContain('[status: completed, exit code: 0]')
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
|
||||
it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => {
|
||||
const ctx = await setup()
|
||||
// No explicit workdir → the call still flags a terminal, but with no cwd (the
|
||||
// UI bridge fills the session cwd it owns; the pure presenter can't see it).
|
||||
// The command is the title (an execute card hides rawInput); the description
|
||||
// rides as a content text block (shown above the terminal card).
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
|
||||
.toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} })
|
||||
// An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
|
||||
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } })
|
||||
// A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
|
||||
// the session cwd, matching where execution runs) — not dropped.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
|
||||
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } })
|
||||
})
|
||||
|
||||
it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'echo hi', description: 'echo' },
|
||||
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
|
||||
)
|
||||
// The fenced ```console content trims trailing blank lines for a tidy block;
|
||||
// terminal.output keeps the RAW bytes (newlines intact) a terminal renderer
|
||||
// needs; exitCode is parsed back from the [exit code: N] marker.
|
||||
expect(present).toEqual({
|
||||
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
|
||||
terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
|
||||
expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 })
|
||||
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!
|
||||
// For each renderResult outcome, the rendered text fed back through
|
||||
// presentResult recovers the matching structured exit — the parse and the
|
||||
// marker emission co-evolve in one file, so this pins the pair.
|
||||
const base = {
|
||||
aborted: false,
|
||||
timeoutMs: 1000,
|
||||
stdout: { text: 'out', truncated: false },
|
||||
stderr: { text: '', truncated: false },
|
||||
}
|
||||
const cases = [
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
|
||||
{ result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
|
||||
{ result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
|
||||
// A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
|
||||
{ result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const rendered = renderResult(c.result)
|
||||
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
|
||||
const { output: _o, ...exit } = out?.terminal ?? {}
|
||||
expect(exit).toEqual(c.expect)
|
||||
}
|
||||
})
|
||||
|
||||
it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'printf "[exit code: 5]"', description: 'print' }
|
||||
// A successful command can print text that looks like a marker. renderResult
|
||||
// for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
|
||||
// own tail is `[exit code: 5]`. The parse requires a LEADING newline before
|
||||
// the marker (renderResult always inserts one before a REAL marker), so this
|
||||
// no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
|
||||
expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 })
|
||||
// Same for a fake signal marker with no leading newline.
|
||||
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
|
||||
expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 })
|
||||
})
|
||||
|
||||
it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => {
|
||||
const ctx = await setup()
|
||||
// The background start returns a task-id ack, not a streamed run — no terminal.
|
||||
const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
|
||||
expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
|
||||
expect((call as { terminal?: unknown }).terminal).toBeUndefined()
|
||||
// The ack result is fenced text only — no terminal output / exit pill.
|
||||
const result = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'sleep 100', description: 'wait', run_in_background: true },
|
||||
{ content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
|
||||
)
|
||||
expect(result?.terminal).toBeUndefined()
|
||||
expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }])
|
||||
})
|
||||
|
||||
it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => {
|
||||
const ctx = await setup()
|
||||
// A spawn failure / abort has no process exit — the body is an error message,
|
||||
// not renderResult output, so no terminal output/exit is emitted.
|
||||
const out = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
|
||||
)
|
||||
expect(out?.terminal).toBeUndefined()
|
||||
expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }])
|
||||
})
|
||||
|
||||
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
|
||||
const ctx = await setup()
|
||||
const present = ctx.tools.get('bash')!.presentResult!(
|
||||
{ command: 'x', description: 'x' },
|
||||
{ content: [{ type: 'image', url: 'https://x/y.png' }], isError: false },
|
||||
)
|
||||
expect(present).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bash presentResult: a result that is not exactly one block → undefined (no single text to fence)', async () => {
|
||||
const ctx = await setup()
|
||||
const args = { command: 'x', description: 'x' }
|
||||
// Empty content (no block) and multi-block content both fall through.
|
||||
expect(ctx.tools.get('bash')!.presentResult!(args, { content: [], isError: false })).toBeUndefined()
|
||||
expect(ctx.tools.get('bash')!.presentResult!(args, {
|
||||
content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }],
|
||||
isError: false,
|
||||
})).toBeUndefined()
|
||||
})
|
||||
|
||||
it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
|
||||
const ctx = await setup()
|
||||
expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
|
||||
.toEqual({ title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
|
||||
})
|
||||
|
||||
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
|
||||
const ctx = await setup()
|
||||
// defineTool wraps presentCall to soft-validate against the schema and fall
|
||||
// back to undefined (a generic UI presentation) rather than throwing on the
|
||||
// display path — it may run on replay of arbitrary logged args. The
|
||||
// ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
|
||||
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,9 +24,10 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
|
||||
|
||||
### Key types
|
||||
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`.
|
||||
- `ToolDefinition` — `ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
|
||||
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
|
||||
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay).
|
||||
- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
|
||||
|
||||
### Extension points
|
||||
|
||||
@@ -67,6 +68,39 @@ A `defineTool` tool also **validates the model-generated arguments against its `
|
||||
|
||||
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
|
||||
|
||||
### Tool-owned UI presentation
|
||||
|
||||
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
|
||||
|
||||
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
|
||||
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
|
||||
|
||||
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
|
||||
|
||||
```ts
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const bash = defineTool({
|
||||
name: 'bash',
|
||||
description: 'Run a shell command.',
|
||||
parameters: {
|
||||
command: { type: 'string', required: true, description: 'The command to run.' },
|
||||
description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' },
|
||||
},
|
||||
async execute(args) {
|
||||
return [{ type: 'text', text: `ran: ${args.command}` }]
|
||||
},
|
||||
// The command is the readable title; the description rides as a content block.
|
||||
presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }),
|
||||
// Wrap the output as a console block for the UI (not in the model-facing result).
|
||||
presentResult: (_args, result) => {
|
||||
const block = result.content.length === 1 ? result.content[0] : undefined
|
||||
if (block === undefined || block.type !== 'text') return undefined
|
||||
return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] }
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
### What is NOT here (TODO)
|
||||
|
||||
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.
|
||||
|
||||
@@ -50,9 +50,156 @@ declare module 'cordis' {
|
||||
// parallel execution — Claude Code partitions read-only tools; phase 1
|
||||
// executes sequentially).
|
||||
|
||||
/**
|
||||
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
|
||||
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
|
||||
* depending on any client protocol; a UI bridge maps it to its own enum. The
|
||||
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
|
||||
*/
|
||||
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
|
||||
|
||||
// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation /
|
||||
// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/
|
||||
// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/
|
||||
// output/exit) and the split of responsibility is now muddy: the call vs result
|
||||
// terminal fields overlap, the bridge has to reconcile a `content` block AND a
|
||||
// `terminal` block AND `rawInput` per call, and the "pending vs completed"
|
||||
// boundary doesn't cleanly map to how editors actually render (terminal card,
|
||||
// diff, generic card). Before more tools/UIs depend on this, redesign the type
|
||||
// so a tool declares its render INTENT once (e.g. a tagged union over card
|
||||
// kinds) rather than a bag of optional fields the bridge stitches together.
|
||||
// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together.
|
||||
|
||||
/**
|
||||
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card,
|
||||
* a CLI log line) BEFORE the result is known — the *pending* state. Provider-
|
||||
* neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI
|
||||
* plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its
|
||||
* own presentation — the UI must not special-case tool names.
|
||||
*/
|
||||
export interface ToolCallPresentation {
|
||||
/**
|
||||
* Human-readable, always-visible label describing what THIS call does (e.g.
|
||||
* the model-written one-line summary of a bash command). Keep it short — a UI
|
||||
* shows it as a card header / log line. Required: a presentation must have a
|
||||
* title (a UI falls back to the tool name only when `presentCall` is absent).
|
||||
*/
|
||||
title: string
|
||||
/** Category for icon/treatment; defaults to `other` when omitted. */
|
||||
kind?: ToolCallKind
|
||||
/**
|
||||
* The salient input to surface in a detail/expanded view — e.g. the bash
|
||||
* COMMAND itself (as a string), so the title can stay a readable summary
|
||||
* while the exact command is still visible. Omit to show nothing; a string is
|
||||
* rendered as-is, an object as pretty JSON. NOT the full raw args object
|
||||
* unless that is genuinely what a reader wants.
|
||||
*/
|
||||
rawInput?: unknown
|
||||
/**
|
||||
* UI-facing content to show on the PENDING call alongside the title/card —
|
||||
* harness {@link ContentBlock}s, in render order. A terminal tool uses this to
|
||||
* surface its human-readable `description` as a text block ABOVE the terminal
|
||||
* card (the card itself is requested via {@link terminal} and labelled by the
|
||||
* command in `title`), since the card has no description slot. Omit to show no
|
||||
* extra content. A UI maps these to its own content blocks and renders a
|
||||
* {@link terminal} block (if any) as a terminal card.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Ask a capable UI to render this call as a TERMINAL (a command running in a
|
||||
* working directory), not a generic tool card — set by a tool whose call IS a
|
||||
* shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
|
||||
* own terminal affordance and a UI that can't falls back to the normal card.
|
||||
* Pair with {@link ToolResultPresentation.terminal} for the output/exit.
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/**
|
||||
* A request to render a tool call as a terminal. The pending presentation
|
||||
* supplies the working directory; the result presentation (see
|
||||
* {@link ToolResultPresentation.terminal}) supplies the captured output and exit
|
||||
* status. Provider-neutral — no client-protocol types. A UI that supports
|
||||
* terminals shows a cwd-headed terminal card with the command, its output, and
|
||||
* an exit-status pill; a UI that does not ignores this and renders the ordinary
|
||||
* card/content.
|
||||
*/
|
||||
export interface ToolTerminal {
|
||||
/**
|
||||
* Working directory the command ran in, shown as the terminal header. An
|
||||
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
|
||||
* against the session workspace (the pure tool presenter can't see the
|
||||
* session cwd). Omit entirely to let the bridge use the session workspace.
|
||||
*/
|
||||
cwd?: string
|
||||
/** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
|
||||
output?: string
|
||||
/**
|
||||
* Process exit code, when the run ended by exiting (not a signal). Result-state
|
||||
* only; lets a capable UI show an exit-status pill on the terminal card. Omit
|
||||
* when the command was killed by a signal or the exit code is unknown.
|
||||
*/
|
||||
exitCode?: number
|
||||
/**
|
||||
* Signal name that killed the process (e.g. `SIGTERM`), when it died by signal
|
||||
* rather than exiting. Result-state only; mutually exclusive with `exitCode`.
|
||||
*/
|
||||
signal?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool wants the COMPLETED call shown — the *result* state, after
|
||||
* `execute` returns. Lets the tool reformat its result for a UI distinctly from
|
||||
* the model-facing text it returned from `execute` (e.g. wrap command output in
|
||||
* a fenced ```console block for monospace rendering, which the model-facing
|
||||
* result must NOT carry). All fields optional: a UI keeps the pending-state
|
||||
* title and renders the raw result content for anything left unset.
|
||||
*/
|
||||
export interface ToolResultPresentation {
|
||||
/** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */
|
||||
title?: string
|
||||
/**
|
||||
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
|
||||
* the model-facing result. Omit to let the UI render the raw result content.
|
||||
* Stays in harness vocabulary; the UI maps these to its own content blocks.
|
||||
*/
|
||||
content?: ContentBlock[]
|
||||
/**
|
||||
* Terminal output/exit for a call the pending presentation marked as a
|
||||
* terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
|
||||
* `output` in the terminal card and shows the exit status; an incapable UI
|
||||
* uses `content` (the tool should supply a text fallback there too).
|
||||
*/
|
||||
terminal?: ToolTerminal
|
||||
}
|
||||
|
||||
/** A registered tool: its schema plus the execution function. */
|
||||
export interface ToolDefinition extends ToolSchema {
|
||||
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI, derived
|
||||
* from the call's `args` (parsed arguments, `unknown` — the tool validates/
|
||||
* narrows its own input). Returning `undefined` (or omitting the method) tells
|
||||
* a UI to fall back to a generic presentation (title = tool name, raw args as
|
||||
* input). Pure and side-effect-free: a UI may call it during live streaming
|
||||
* AND a session-log replay, so it must depend only on `args`.
|
||||
*/
|
||||
presentCall?(args: unknown): ToolCallPresentation | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the same `args` and the
|
||||
* `result` (`execute`'s content + whether it errored). Returning `undefined`
|
||||
* (or omitting the method) tells a UI to keep the pending title and render the
|
||||
* raw result content. Pure and side-effect-free for the same replay reason.
|
||||
*/
|
||||
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
|
||||
}
|
||||
|
||||
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
|
||||
export interface ToolResult {
|
||||
/** The model-facing content `execute` returned (or the error text on failure). */
|
||||
content: ContentBlock[]
|
||||
/** Whether the call failed. */
|
||||
isError: boolean
|
||||
}
|
||||
|
||||
/** One pending tool call, as it flows through the execution waterfall. */
|
||||
@@ -166,16 +313,21 @@ export class ToolRegistry extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered tool schemas, stripped of their `execute` functions.
|
||||
* These are exactly what gets sent to the model via the system-prompt
|
||||
* assembly.
|
||||
* Return all registered tool schemas — exactly the model-facing fields
|
||||
* (`name`, `description`, `parameters`, and `strict` when set), as sent to the
|
||||
* model via the system-prompt assembly. Constructed EXPLICITLY rather than by
|
||||
* stripping known non-schema members: a `ToolDefinition` also carries
|
||||
* `execute` and the optional `presentCall`/`presentResult` UI callbacks, and
|
||||
* those (especially the functions) must never leak into a model request. An
|
||||
* allowlist can't drift when a new non-schema member is added to the
|
||||
* definition; a denylist (rest-destructure) would silently leak it.
|
||||
*/
|
||||
schemas(): ToolSchema[] {
|
||||
// Rest-destructure to drop `execute`; the unused binding is the idiom.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method, @typescript-eslint/no-unused-vars
|
||||
return [...this.store.values()].map(({ execute, ...schema }) => ({
|
||||
...schema,
|
||||
parameters: structuredClone(schema.parameters),
|
||||
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
|
||||
name,
|
||||
description,
|
||||
parameters: structuredClone(parameters),
|
||||
...strict !== undefined ? { strict } : {},
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -186,33 +338,33 @@ export class ToolRegistry extends Service {
|
||||
* an `isError` result so the loop never sees an uncaught exception; a thrown
|
||||
* {@link HarnessError} surfaces its `{ name, code }` on the result.
|
||||
*/
|
||||
execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
return this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
// Unknown tool routes through the same catch as a tool-thrown error, so
|
||||
// both failure classes get structured `{ name, code }` from one path.
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
return { callId: exec.callId, content, isError: false }
|
||||
} catch (error: unknown) {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
|
||||
try {
|
||||
return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
|
||||
try {
|
||||
const tool = this.store.get(exec.name)
|
||||
// Unknown tool routes through the same catch as a tool-thrown error, so
|
||||
// both failure classes get structured `{ name, code }` from one path.
|
||||
if (!tool) throw new ToolNotFoundError(exec.name)
|
||||
const content = await tool.execute(exec.arguments, exec)
|
||||
return { callId: exec.callId, content, isError: false }
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
}
|
||||
}).catch((error: unknown): ToolExecutionResult => {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId: exec.callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
}
|
||||
})
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return toolErrorResult(exec.callId, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
|
||||
const info = errorInfo(error)
|
||||
return {
|
||||
callId,
|
||||
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
|
||||
isError: true,
|
||||
...info ? { error: info } : {},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { ToolDefinition, ToolExecution } from './index.ts'
|
||||
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SchemaSpec — the author-facing per-property type
|
||||
@@ -287,6 +287,22 @@ export interface DefineToolOptions<S extends SchemaSpec> {
|
||||
* casts needed.
|
||||
*/
|
||||
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
|
||||
/**
|
||||
* Optional: how to present the PENDING state of one call in a UI (an editor
|
||||
* tool-call card, a CLI log line). `args` is the typed, schema-validated
|
||||
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
|
||||
* during live streaming AND a session-log replay, so depend only on `args`.
|
||||
* The tool owns its presentation so a UI never special-cases tool names. See
|
||||
* {@link ToolCallPresentation}.
|
||||
*/
|
||||
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
|
||||
/**
|
||||
* Optional: how to present the COMPLETED state, given the typed `args` and the
|
||||
* `result`. Use it to reformat result content for a UI distinctly from the
|
||||
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
|
||||
* free for the same replay reason. See {@link ToolResultPresentation}.
|
||||
*/
|
||||
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
|
||||
/** Whether the tool requires structured output (default false). */
|
||||
strict?: boolean
|
||||
}
|
||||
@@ -322,7 +338,11 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
// Object-literal execute methods don't use `this`; the reference is safe.
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userExecute = options.execute
|
||||
return {
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentCall = options.presentCall
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
const userPresentResult = options.presentResult
|
||||
const tool: ToolDefinition = {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
|
||||
@@ -337,4 +357,21 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
|
||||
return userExecute(args as InferArgs<S>, exec)
|
||||
},
|
||||
}
|
||||
// Presentation is display-only and may run on REPLAY of arbitrary logged args
|
||||
// (possibly from an older schema), so it must never throw: validate softly and
|
||||
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
|
||||
// than the hard `ToolArgsError` the execute path raises.
|
||||
if (userPresentCall) {
|
||||
tool.presentCall = (args: unknown): ToolCallPresentation | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentCall(args as InferArgs<S>)
|
||||
}
|
||||
}
|
||||
if (userPresentResult) {
|
||||
tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => {
|
||||
if (validateArgs(options.parameters, args).length > 0) return undefined
|
||||
return userPresentResult(args as InferArgs<S>, result)
|
||||
}
|
||||
}
|
||||
return tool
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Property-based tests for the tool-schema DSL (RFC 001 → ADR 0013), including
|
||||
* the RFC 001 ↔ 005 composition: generated args that satisfy a SchemaSpec must
|
||||
* Property-based tests for the tool-schema DSL (the property-testing RFC), including
|
||||
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
|
||||
* pass validateArgs, and targeted corruptions must be rejected. This closes the
|
||||
* validator/InferArgs drift risk noted in ADR 0011.
|
||||
* validator/InferArgs drift risk noted in the arg-validation RFC.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
@@ -108,7 +108,7 @@ describe('schema DSL properties', () => {
|
||||
}))
|
||||
})
|
||||
|
||||
it('RFC 001↔005: args satisfying the spec pass validateArgs', () => {
|
||||
it('the property-testing ↔ runtime-validation composition: args satisfying the spec pass validateArgs', () => {
|
||||
fc.assert(fc.property(
|
||||
specArb(2).chain(spec => fc.tuple(fc.constant(spec), validArgsForSpec(spec))),
|
||||
([spec, args]) => {
|
||||
@@ -117,7 +117,7 @@ describe('schema DSL properties', () => {
|
||||
))
|
||||
})
|
||||
|
||||
it('RFC 001↔005: dropping a required key is always rejected', () => {
|
||||
it('the property-testing ↔ runtime-validation composition: dropping a required key is always rejected', () => {
|
||||
fc.assert(fc.property(
|
||||
specArb(1)
|
||||
.filter(spec => requiredKeys(spec).length > 0)
|
||||
@@ -132,7 +132,7 @@ describe('schema DSL properties', () => {
|
||||
))
|
||||
})
|
||||
|
||||
it('RFC 001↔005: a non-object top level is always rejected', () => {
|
||||
it('the property-testing ↔ runtime-validation composition: a non-object top level is always rejected', () => {
|
||||
fc.assert(fc.property(
|
||||
specArb(1),
|
||||
fc.oneof(fc.string(), fc.integer(), fc.boolean(), fc.constant(null), fc.array(fc.anything())),
|
||||
|
||||
@@ -41,6 +41,39 @@ describe('ToolRegistry', () => {
|
||||
expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
|
||||
})
|
||||
|
||||
it('schemas() drops the UI presentation callbacks — they must never reach the model', async () => {
|
||||
const ctx = await setup()
|
||||
// A tool that declares presentCall/presentResult (functions). schemas() feeds
|
||||
// the system-prompt assembly → the model request, so those callbacks (and
|
||||
// `execute`) must be stripped: a function in the JSON tool schema would
|
||||
// corrupt the request. schemas() is an explicit allowlist, so it can't leak.
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'present',
|
||||
description: 'has presenters',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
presentCall: args => ({ title: args.x }),
|
||||
presentResult: (args, result) => ({ title: args.x, content: result.content }),
|
||||
}))
|
||||
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
|
||||
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
|
||||
expect(schema.presentCall).toBeUndefined()
|
||||
expect(schema.presentResult).toBeUndefined()
|
||||
expect(schema.execute).toBeUndefined()
|
||||
})
|
||||
|
||||
it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'strict-tool',
|
||||
description: 'd',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
strict: true,
|
||||
async execute() { return [] },
|
||||
}))
|
||||
expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
|
||||
})
|
||||
|
||||
it('executes a tool and returns its content', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(echoTool)
|
||||
@@ -657,7 +690,7 @@ describe('ToolRegistry.get', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateArgs (RFC 005 part 1)', () => {
|
||||
describe('validateArgs (the runtime-validation RFC, part 1)', () => {
|
||||
it('returns [] for valid args and is total over malformed input', () => {
|
||||
const spec = {
|
||||
path: { type: 'string', required: true },
|
||||
@@ -757,7 +790,7 @@ describe('validateArgs (RFC 005 part 1)', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool validation (RFC 005 part 1)', () => {
|
||||
describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
|
||||
it('returns an isError result with the violations when the model sends bad args', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register(defineTool({
|
||||
@@ -862,3 +895,53 @@ describe('defineTool validation (RFC 005 part 1)', () => {
|
||||
expect(result.isError).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
|
||||
const tool = defineTool({
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
|
||||
async execute() { return [{ type: 'text', text: 'ok' }] },
|
||||
presentCall(args) {
|
||||
// args is typed { path: string; n?: number } — zero casts.
|
||||
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
|
||||
return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
|
||||
},
|
||||
presentResult(args, result) {
|
||||
return { title: `Opened ${args.path}`, content: result.content }
|
||||
},
|
||||
})
|
||||
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
|
||||
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
|
||||
.toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
|
||||
})
|
||||
|
||||
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
|
||||
const tool = defineTool({
|
||||
name: 'plain',
|
||||
description: 'plain',
|
||||
parameters: { x: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
})
|
||||
expect(typeof tool.presentCall).toBe('undefined')
|
||||
expect(typeof tool.presentResult).toBe('undefined')
|
||||
})
|
||||
|
||||
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
|
||||
const tool = defineTool({
|
||||
name: 'demo',
|
||||
description: 'demo',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
async execute() { return [] },
|
||||
presentCall: args => ({ title: args.path }),
|
||||
presentResult: (args, result) => ({ title: args.path, content: result.content }),
|
||||
})
|
||||
// Unlike execute (which throws ToolArgsError on a mismatch), the display
|
||||
// methods soft-validate and fall back to undefined so a UI never crashes
|
||||
// replaying an old/foreign log entry. The ToolDefinition methods take
|
||||
// `unknown`, so malformed shapes pass without a cast.
|
||||
expect(tool.presentCall?.({})).toBeUndefined()
|
||||
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user