Merge remote-tracking branch 'origin/master' into codex/ask-user-question

# Conflicts:
#	docs/architecture.md
#	docs/cordis-catalog/events-and-services.md
#	docs/core-data-structures/core.md
#	docs/module-graph.md
#	docs/tool-catalog/tools.md
#	packages/README.md
#	packages/core/README.md
#	packages/core/tools/tests/gen-tool-catalog.spec.ts
#	packages/support/README.md
#	packages/support/ui-stdio/README.md
#	packages/ui/acp-agent/tests/built-bin.e2e.ts
#	packages/ui/acp/README.md
#	packages/ui/stdio-agent/README.md
#	packages/ui/stdio-agent/package.json
#	packages/ui/stdio-agent/src/index.ts
#	packages/ui/stdio-agent/src/stdio-chat.ts
#	packages/ui/stdio-agent/tests/built-bin.e2e.ts
#	packages/ui/stdio-agent/tests/readline.spec.ts
#	packages/ui/stdio-agent/tests/stdio-chat.spec.ts
#	packages/web/web/package.json
#	packages/web/web/tsconfig.json
#	pnpm-lock.yaml
#	scripts/gen-tool-catalog.ts
This commit is contained in:
Yichen Jiang
2026-07-05 17:05:33 +08:00
455 changed files with 29519 additions and 4645 deletions

View File

@@ -1,18 +1,16 @@
# AGENTS.md — Harness Packages
This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing code here, follow these conventions:
This directory contains all `@deepseek-ai/dsh-*` harness packages. Repo-wide conventions (effects, declaration merging, waterfall semantics, ESM, testing policy) are in the root [AGENTS.md](../AGENTS.md) § Conventions; the points below are packages-specific.
- **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.
- **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/<group>/<pkg>/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.
- **A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader/export path** — hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape. Full testing policy (tiers, with-key generosity, real-entry-path guards): [docs/testing.md](../docs/testing.md).
Naming notes:
- 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/*/*.md` and `packages/*/*/*.md`, regenerates the cordis events/services catalog from the `interface Events` / `interface Context` declarations (failing if the committed copy is stale), 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. A new event needs an `@mode` tag on its JSDoc (the catalog generator hard-errors without it — see the root AGENTS.md).
- 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 (the export-shape rule above).
- `src/types.ts` contains only types — no runtime code.
- Tests live at package level under `tests/`, not `src/__tests__/`.
- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; prose accuracy stays on the author ([the documentation standard](../docs/AGENTS.md)).
Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.

View File

@@ -1,10 +1,10 @@
# Packages
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin that gets registered via `ctx.plugin()`, declares its ctx key/events where applicable through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`.
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis plugin (microkernel-style): it exports either a default `Service` subclass or a functional plugin, declares its ctx key/events through declaration merging, and exposes extension points through `ctx.effect()`, `ctx.on()`, and `ctx.waterfall()`. Authoring conventions: [AGENTS.md](AGENTS.md) (subtree) and the root [AGENTS.md](../AGENTS.md) § Conventions.
## Hierarchy
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. Each group has a `README.md` describing its role and whether it is product or support infrastructure.
Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group directory is a pure container (no `package.json` of its own); the package name stays `@deepseek-ai/dsh-<pkg>` regardless of group. **Each group README is the canonical per-package map** — package roles, ctx keys, and the product-vs-support split live there, next to the code.
| Group | Role | Release expectation |
|---|---|---|
@@ -14,106 +14,20 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not have to treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and the hierarchy docs).
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).
## Dependency graph
## Dependencies
```
dsh-brand (no harness deps — type-only Branded<B> primitive)
dsh-llm ← dsh-brand (vocabulary; brands CallId)
dsh-bash ← dsh-brand (abstract executor seam; brands BashTaskId/OwnerToken)
dsh-session ← dsh-llm, dsh-brand
dsh-system-prompt ← dsh-llm
dsh-agent ← dsh-llm, dsh-session, dsh-brand
dsh-user-interaction ← dsh-agent, dsh-llm
dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred)
dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend)
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-tool-ask-user ← dsh-tools, dsh-user-interaction
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
dsh-fs ← dsh-llm, dsh-brand (filesystem provider seam + fs/* events)
dsh-fs-local ← dsh-fs (FileSystem impl)
dsh-fs-policy ← dsh-fs (observed-state + freshness policy gate, no service)
dsh-tool-fs ← dsh-fs, dsh-tools (file tools + executor)
dsh-llm-deepseek ← dsh-llm (DeepSeek adapter)
dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter)
dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent
dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks)
dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence, dsh-tools, dsh-user-interaction (ACP JSON-RPC bridge + user-interaction provider)
dsh-ui-stdio ← dsh-agent, dsh-session, dsh-user-interaction (stdio readline UI plugin + user-interaction provider)
dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests)
dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam)
dsh-subagent-inprocess ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (shared in-process run driver)
dsh-subagent-mock ← dsh-subagent, dsh-agent, dsh-llm (scripted provider for tests)
dsh-subagent-spawn ← dsh-subagent, dsh-subagent-inprocess (in-process fresh child backend)
dsh-subagent-fork ← dsh-subagent, dsh-subagent-inprocess, dsh-agent, dsh-session (in-process child seeded from parent log)
dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP)
dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool)
dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log)
dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin)
dsh-stdio-agent ← dsh-agent-core, dsh-user-interaction, dsh-tool-ask-user, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin)
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-user-interaction, dsh-tool-ask-user, dsh-session-persistence-jsonl (ACP server APP + bin)
```
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The rule: **extension** 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. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
## What goes where
| Package | Group | Role | ctx key |
|---|---|---|---|
| `llm/` | `llm` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `session/` | `core` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | `core` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | `core` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
| `user-interaction/` | `core` | Abstract human question/answer seam | `ctx.userInteraction` |
| `agent/` | `core` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | `core` | THE concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | `core` | Bundle plugin: the providerless/executor-less/UI-less spine as code (forwards `agent-loop`'s `agents`) | (loads the spine) |
| `bash/` | `bash` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
| `bash-local/` | `bash` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | `bash` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `fs/` | `fs` | Filesystem provider seam: text IO + atomic mutation primitives (optional version guard); owns the `fs/*` events | `ctx.fs` |
| `fs-local/` | `fs` | Local-filesystem `FileSystem` implementation | (registers `ctx.fs`) |
| `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) |
| `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
| `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` |
| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `llm-deepseek/` | `llm` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | `llm` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
| `session-persistence/` | `session-persistence` | Persistence seam + write coordinator | `ctx.sessionPersistence` |
| `session-persistence-jsonl/` | `session-persistence` | JSONL-sidecar persistence backend | (registers `ctx.sessionPersistence`) |
| `session-persistence-sqlite/` | `session-persistence` | SQLite persistence backend | (registers `ctx.sessionPersistence`) |
| `invariants/` | `support` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
| `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `tool-ask-user/` | `ui` | Model-facing `ask_user_question` tool | (registers on `ctx.tools`) |
| `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
| `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` |
| `subagent-inprocess/` | `subagent` | Shared in-process subagent run driver used by spawn/fork; pure library, registers nothing | (none) |
| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) |
| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) |
| `subagent-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (registers on `ctx.subagents`) |
| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) |
| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) |
| `tool-todo/` | `todo` | Model-facing `todo_write` tool; writes the whole task list to the session log (`todo/write`) | (registers on `ctx.tools`) |
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
The rule it must obey: **extension 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. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
## Conventions (applied across all harness packages)
- **Registrations are effects**: every contribution (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, so disposal and HMR clean up automatically. Every `register()` returns the disposer.
- **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism).
- **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging.
- **ESM everywhere**; imports use package names across package boundaries and explicit `.ts` relative specifiers within a package.
- **Tests**: vitest, colocated under `packages/<group>/<pkg>/tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests.

View File

@@ -12,6 +12,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L
timeoutMs: 120000 # default foreground timeout
maxTimeoutMs: 600000 # cap for per-call overrides
maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk
graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills
```
## Behavior (and where it came from)
@@ -19,11 +20,11 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L
Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices:
- **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools.
- **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file.
- **Model-friendly env** — `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results.
- **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
- **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload.
## Sandboxing
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Wrap the `tools/execute` waterfall (veto/ask) or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.
`TODO(permissions/sandbox)`: execution policy does NOT belong in this package. Use the `tools/pre-execute` deny/ask gate or implement a sandboxing `BashExecutor` — see docs/architecture.md § plugin checklist. Reference points: Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies seatbelt/landlock plus an execpolicy prefix-rule engine.

View File

@@ -4,8 +4,8 @@
* own process group (see `./run.ts` for the plumbing and the agent-tool
* survey notes), tracks background tasks, and kills everything on dispose.
*
* TODO(permissions/sandbox): execution policy does NOT belong here — wrap
* the `tools/execute` waterfall (see docs/architecture.md § plugin
* TODO(permissions/sandbox): execution policy does NOT belong here — use
* the `tools/pre-execute` deny/ask gate (see docs/architecture.md § plugin
* checklist) or implement a sandboxing `BashExecutor`. Reference points:
* Claude Code wraps commands in sandbox-exec/bubblewrap; Codex applies
* seatbelt/landlock plus an execpolicy prefix-rule engine.
@@ -17,7 +17,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import { runBash } from './run.ts'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
@@ -33,6 +33,8 @@ export interface Config {
maxTimeoutMs?: number
/** Per-stream in-memory output cap; overflow spills to a temp file. */
maxOutputBytes?: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs?: number
}
/** The shape after schemastery applied the defaults (cwd has none). */
@@ -57,7 +59,7 @@ interface TrackedTask extends BashTask {
* Local-subprocess bash executor. Defaults follow the agent-tool survey
* consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB
* in-memory output with full-stream spill files (pi, OpenCode),
* process-group SIGTERM→SIGKILL kills (OpenCode).
* process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode).
*/
export class LocalBashExecutor extends BashExecutor {
static Config: z<Config> = z.object({
@@ -65,11 +67,12 @@ export class LocalBashExecutor extends BashExecutor {
timeoutMs: z.number().default(120_000),
maxTimeoutMs: z.number().default(600_000),
maxOutputBytes: z.number().default(64_000),
graceMs: z.number().default(DEFAULT_GRACE_MS),
})
private tasks = new Map<BashTaskId, TrackedTask>()
private nextTaskId = 1
/** Test seam: timer/spill knobs forwarded to runBash. */
/** Test seam: spill knobs forwarded to runBash. */
internals: RunInternals = {}
/** Validated config (schemastery applied the defaults before construction). */
@@ -83,6 +86,7 @@ export class LocalBashExecutor extends BashExecutor {
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
assertPositiveFinite('graceMs', this.config.graceMs)
ctx.effect(() => async () => {
// Kill every live process group and WAIT for the processes to close so
// nothing outlives the fiber (HMR safety) — a TERM-trapping child is
@@ -116,6 +120,10 @@ export class LocalBashExecutor extends BashExecutor {
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
...request.signal ? { signal: request.signal } : {},
// Carry stdin/env through verbatim — optional, no config default (absent
// means none). env merges AFTER the scrub in run.ts.
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
// Carry the owner through verbatim (required-but-nullable on the spec):
// the executor never interprets it — the consumer's access policy does.
owner: request.owner,
@@ -128,7 +136,10 @@ export class LocalBashExecutor extends BashExecutor {
cwd: spec.workdir,
timeoutMs: spec.timeoutMs,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
return { ...outcome, timeoutMs: spec.timeoutMs }
}
@@ -144,7 +155,10 @@ export class LocalBashExecutor extends BashExecutor {
cwd: spec.workdir,
timeoutMs: 0,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals)
const id = BashTaskId(`bash-${this.nextTaskId++}`)

View File

@@ -15,7 +15,8 @@
* @module dsh-bash-local/run
*/
import { spawn } from 'node:child_process'
import { type ChildProcessByStdio, spawn } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -42,13 +43,26 @@ export const ENV_OVERRIDES = {
*/
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
/** process.env minus credential-shaped vars, plus the model-friendly overrides. */
export function childEnv(): NodeJS.ProcessEnv {
/**
* `process.env` minus credential-shaped vars, plus the model-friendly
* overrides, plus any caller-supplied `extra` entries.
*
* Layering matters: the scrub drops `process.env` credentials, then
* `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
* merged LAST so an explicit caller entry wins even when its name matches the
* scrub pattern (the scrub is the control that stops the HARNESS's ambient
* credentials leaking into a spawned command; a caller that explicitly sets a
* var named a value it already holds, not that ambient secret). `extra` is set
* by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
* builds its request from named fields only and does not forward model input
* here (see its README, § "The tool builds its request from named args only").
*/
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {}
for (const [key, value] of Object.entries(process.env)) {
if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
}
return { ...env, ...ENV_OVERRIDES }
return { ...env, ...ENV_OVERRIDES, ...extra }
}
/** What to run and under which limits (resolved — no defaults in here). */
@@ -59,8 +73,23 @@ export interface SpawnSpec {
timeoutMs: number
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
maxOutputBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs: number
/** Abort signal — kills the process group when fired. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
* leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
* the model-facing `dsh-tool-bash` tool does not thread model input here.
*/
stdin?: string | undefined
/**
* Extra environment entries, merged onto the scrubbed env AFTER the
* credential scrub and the model-friendly overrides (so an explicit entry
* wins). Set by in-process plugins; the model-facing tool does not forward
* model input here.
*/
env?: Record<string, string> | undefined
}
/** Raw outcome of one closed process (before result shaping). */
@@ -73,15 +102,13 @@ export interface SpawnOutcome {
stderr: CollectedOutput
}
/** Injectable knobs so tests can exercise escalation/spill without long waits. */
/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
export interface RunInternals {
/** Grace period between SIGTERM and SIGKILL on the process group. */
graceMs?: number
/** Directory for spill files (defaults to the OS temp dir). */
spillDir?: string
}
/** Default SIGTERM→SIGKILL grace period (matches OpenCode's 3s). */
/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
export const DEFAULT_GRACE_MS = 3_000
let spillCounter = 0
@@ -265,19 +292,26 @@ export interface RunningBash {
* no inherited shell state); revisit when real workflows demand it.
*/
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
const graceMs = internals.graceMs ?? DEFAULT_GRACE_MS
const spillDir = internals.spillDir ?? privateSpillDir()
if (spec.signal?.aborted) {
throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
}
const child = spawn('bash', ['-c', spec.command], {
cwd: spec.cwd,
env: childEnv(),
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
})
// stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
// (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
// and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
// socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
// /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
// (every model-driven call) must keep /dev/null rather than regress to a socket.
// Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
// typed `spawn` overload infer non-null stdout/stderr, which the
// `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
// stderr the non-null `Readable` the collectors attach to without a cast).
const env = childEnv(spec.env)
const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
: spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })
const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
@@ -296,7 +330,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
const kill = (): void => {
if (graceTimer !== undefined) return // escalation already in flight
killGroup(pid, 'SIGTERM')
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, graceMs)
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
if (spec.timeoutMs > 0) {
@@ -312,6 +346,24 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
}
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
// stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
// handler must exist whenever we write: an unhandled 'error' on the stream
// would throw and crash the host. We swallow the error rather than reject
// `done`, and that is correct for ANY stdin-write error, not just the common
// one — the stdin write is BEST-EFFORT, while the command's authoritative
// outcome is its exit code + captured output, which the `close` handler reports
// regardless of whether the write landed. The expected case is EPIPE (the child
// exited without reading, so closing our end of a still-full pipe fails); a rare
// non-EPIPE pipe fault means the command ran with incomplete stdin, and it
// surfaces that itself through its own exit/output (e.g. a hook that gets
// truncated JSON errors out) — rejecting here would instead discard that real
// output and turn it into an opaque infrastructure error, which is worse.
if (child.stdin !== null) {
child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
child.stdin.end(spec.stdin)
}
const done = new Promise<SpawnOutcome>((resolve, reject) => {
child.on('error', (error) => {
// Spawn-level failure (ENOENT cwd, EACCES, …): no close event with

View File

@@ -11,9 +11,10 @@ const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) {
const ctx = new Context()
await ctx.plugin(LocalBashExecutor, config)
// A short kill grace via the REAL config path, so escalation tests stay fast.
await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir, graceMs: 200 }
bash.internals = { spillDir }
return { ctx, bash }
}
@@ -80,12 +81,22 @@ describe('LocalBashExecutor.run', () => {
await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/)
await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/)
await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/)
await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/)
const { bash } = await setup()
expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/)
expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/)
})
it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
const { bash } = await setup() // setup pins graceMs: 200 via config
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
await new Promise(resolve => setTimeout(resolve, 100))
bash.kill(task.id)
await task.done
expect(task.signal).toBe('SIGKILL')
})
it('per-call timeout takes precedence under the cap and kills on expiry', async () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
@@ -106,6 +117,23 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup()
await expect(bash.run(bash.resolve({ command: 'true', workdir: '/nonexistent-dsh' }))).rejects.toThrow(/ENOENT/)
})
it('resolve() carries stdin/env onto the spec, and run() threads them to the command', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'cat; echo "[$DSH_SEAM_VAR]"', stdin: 'piped\n', env: { DSH_SEAM_VAR: 'env-ok' } })
// resolve() keeps the stdin/env fields verbatim (optional, no default).
expect(spec.stdin).toBe('piped\n')
expect(spec.env).toEqual({ DSH_SEAM_VAR: 'env-ok' })
const result = await bash.run(spec)
expect(result.stdout.text).toBe('piped\n[env-ok]\n')
})
it('resolve() omits stdin/env when the request supplies neither', async () => {
const { bash } = await setup()
const spec = bash.resolve({ command: 'true' })
expect('stdin' in spec).toBe(false)
expect('env' in spec).toBe(false)
})
})
describe('LocalBashExecutor background tasks', () => {
@@ -131,6 +159,19 @@ describe('LocalBashExecutor background tasks', () => {
await Promise.all([first.done, second.done])
})
it('threads stdin and extra env into a background task', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({
command: 'cat; echo "[$DSH_BG_VAR]"',
stdin: 'bg-stdin\n',
env: { DSH_BG_VAR: 'bg-env' },
}))
const read = await readUntil(bash, task.id, '[bg-env]')
expect(read.delta).toContain('bg-stdin')
await task.done
expect(task.exitCode).toBe(0)
})
it('readOutput returns increments without re-delivery', async () => {
const { bash } = await setup()
const task = bash.start(bash.resolve({ command: 'echo first; sleep 1; echo second' }))
@@ -241,9 +282,9 @@ describe('LocalBashExecutor background tasks', () => {
it('disposing with already-finished tasks only kills the running ones', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir, graceMs: 200 }
bash.internals = { spillDir }
const finished = bash.start(bash.resolve({ command: 'true' }))
await finished.done
@@ -258,9 +299,9 @@ describe('LocalBashExecutor background tasks', () => {
it('disposing the executor fiber kills running tasks (no orphans)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir, graceMs: 200 }
bash.internals = { spillDir }
const listener = vi.fn()
bash.onTaskDone(listener)
@@ -307,9 +348,9 @@ describe('review fixes: lifecycle hardening', () => {
it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 })
const bash = ctx.bash as LocalBashExecutor
bash.internals = { spillDir, graceMs: 200 }
bash.internals = { spillDir }
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
await new Promise(resolve => setTimeout(resolve, 100))

View File

@@ -28,6 +28,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
cwd: process.cwd(),
timeoutMs: 0,
maxOutputBytes: 64_000,
graceMs: 3_000,
...overrides,
}
}
@@ -106,7 +107,7 @@ describe('runBash', () => {
})
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60'), { graceMs: 200 })
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60', { graceMs: 200 }))
await waitForStdout(running, 'ready\n')
running.kill()
const result = await running.done
@@ -158,6 +159,62 @@ describe('runBash', () => {
})
})
describe('stdin and extra env (set by in-process plugins)', () => {
it('writes stdin to the command and closes it', async () => {
const result = await runBash(spec('cat', { stdin: 'hello from stdin\n' })).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('hello from stdin\n')
})
it('a command that reads stdin sees EOF when none is supplied', async () => {
// No stdin → fd 0 is /dev/null, so `cat` reads EOF and exits 0 with no
// output (it does NOT block).
const result = await runBash(spec('cat')).done
expect(result.exitCode).toBe(0)
expect(result.stdout.text).toBe('')
})
it('gives fd 0 the exact pre-seam type: /dev/null when no stdin, a pipe when supplied', async () => {
// The no-stdin path must stay observationally identical to the pre-seam
// `ignore` default: a command that probes stdin's file type sees a char
// device (/dev/null). Regressing to an always-open pipe would make fd 0 a
// socket (node's spawn pipe is an AF_UNIX socket, not a FIFO), flipping
// `test -c /dev/stdin` for every model-driven call. When bytes ARE supplied,
// fd 0 is that pipe (a socket), as it must be to carry them.
const none = await runBash(spec('test -c /dev/stdin && echo char || echo other')).done
expect(none.stdout.text).toBe('char\n')
const piped = await runBash(spec('test -S /dev/stdin && echo socket || echo other', { stdin: 'x' })).done
expect(piped.stdout.text).toBe('socket\n')
})
it('merges extra env entries onto the scrubbed environment', async () => {
const result = await runBash(spec('echo "$DSH_EXTRA_ONE/$DSH_EXTRA_TWO"', {
env: { DSH_EXTRA_ONE: 'alpha', DSH_EXTRA_TWO: 'beta' },
})).done
expect(result.stdout.text).toBe('alpha/beta\n')
})
it('an explicit extra env entry overrides the model-friendly override and the scrub', async () => {
// TERM is a model-friendly OVERRIDE (dumb); an explicit extra entry wins.
// DSH_OVERRIDE_KEY matches the credential scrub pattern, yet an explicit
// entry is still honored — the scrub only drops AMBIENT process.env creds.
const result = await runBash(spec('echo "$TERM/$DSH_OVERRIDE_KEY"', {
env: { TERM: 'xterm-256color', DSH_OVERRIDE_KEY: 'explicit-wins' },
})).done
expect(result.stdout.text).toBe('xterm-256color/explicit-wins\n')
})
it('does not crash or reject when the child ignores a large stdin (EPIPE)', async () => {
// The child exits immediately without reading; closing our end of a stdin
// pipe still holding ~1MiB triggers EPIPE on the write. The handler must
// swallow it: `done` resolves normally with the child's real exit.
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
expect(result.aborted).toBe(false)
})
})
describe('output truncation and spill', () => {
it('keeps the tail and spills the full stream to disk', async () => {
// 200 numbered lines of ~10 bytes; cap at 500 bytes keeps a late tail.

View File

@@ -28,4 +28,6 @@ Implementations subclass `BashExecutor`, implement the abstract methods, and cal
## Vocabulary
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
`BashExecRequest` (command, workdir?, timeoutMs?, signal?, stdin?, env?, owner?) resolves to `BashExecSpec` (command, workdir, timeoutMs, signal?, stdin?, env?, owner) before execution; `owner` is optional on the request and **required-but-nullable** (`OwnerToken | undefined`) on the resolved spec, so a forgotten owner is a visible `undefined` rather than a silently-absent property. The task id (`BashTaskId`) and the `owner` token (`OwnerToken`) are [branded](../../util/brand) — `OwnerToken` is a DISTINCT brand from `SessionId` (the seam never imports `dsh-session`; the `dsh-tool-bash` consumer is the single boundary that casts its `SessionId` into one). `run()` returns `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `start()`/`readOutput()` use `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
`stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so they would be redundant tool params. This is not a security boundary: the implementation's credential scrub (not these fields) is what keeps the harness's ambient secrets out of a spawned command. They are plain optionals on the resolved spec (unlike `owner`'s required-but-nullable): a missing one means "none", the safe default. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).

View File

@@ -77,16 +77,32 @@ export abstract class BashExecutor extends Service {
* call this, then pass the result to {@link run}/{@link start} — keeping
* defaulting in the implementation that owns the config while the seam type
* stays explicit (no hidden `?? default` inside run/start).
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
*/
abstract resolve(request: BashExecRequest): BashExecSpec
/** Run a command in the foreground; resolves when it finishes. */
/**
* Run a command in the foreground; resolves when it finishes.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
*/
abstract run(spec: BashExecSpec): Promise<BashRunResult>
/** Start a background task and return its handle immediately. */
/**
* Start a background task and return its handle immediately.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the live task handle; completion fires {@link onTaskDone}.
*/
abstract start(spec: BashExecSpec): BashTask
/** Look up a background task by id. */
/**
* Look up a background task by id.
* @param id - the task id to look up.
* @returns the tracked task, or undefined for an id this executor never issued.
*/
abstract get(id: BashTaskId): BashTask | undefined
/**
@@ -101,24 +117,38 @@ export abstract class BashExecutor extends Service {
* loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task").
* Storing ownership in the executor (disposed with ITS fiber) — not in the
* tool plugin — is what makes ownership survive a `tool-bash` HMR reload.
* @param id - the background task id to look up ownership for.
* @returns the token recorded at start, verbatim; undefined for an unknown
* id or a known-but-ownerless task.
*/
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
/** All tracked background tasks (insertion order). */
/**
* All tracked background tasks (insertion order).
* @returns every task this executor started, running or finished.
*/
abstract list(): BashTask[]
/** Read output produced since the previous read. Throws for unknown ids. */
/**
* Read output produced since the previous read. Throws for unknown ids.
* @param id - the task to read from.
* @returns the incremental read; consecutive reads never re-deliver output.
*/
abstract readOutput(id: BashTaskId): BashTaskRead
/**
* Kill a running background task. Returns false when it had already
* finished (no-op). Throws for unknown ids.
* @param id - the task to kill.
* @returns true when this call killed it, false when it had already finished.
*/
abstract kill(id: BashTaskId): boolean
/**
* Register a background-task completion listener (disposed with the
* calling fiber). Listeners never fire after this service is disposed.
* @param listener - called exactly once per task completion.
* @returns the disposer that unregisters the listener.
*/
onTaskDone(listener: BashTaskListener): () => void {
const dispose = this.ctx.effect(() => {

View File

@@ -45,6 +45,24 @@ export interface BashExecRequest {
timeoutMs?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
* closed/empty (the default for model-driven tool calls). Set by in-process
* plugins (e.g. the hooks bridges, which write a hook command's JSON payload
* to its stdin); the model-facing bash tool does not expose it as a parameter
* (a model that needs stdin uses shell syntax like a heredoc or a pipe).
*/
stdin?: string | undefined
/**
* Extra environment entries for the command, merged AFTER the
* implementation's credential scrub (so an explicit entry here is honored even
* when its name matches the scrub pattern — the caller named a value it holds,
* not the harness's ambient secret). Set by in-process plugins (the hooks
* bridges set `CLAUDE_PROJECT_DIR`, `CLAUDE_PLUGIN_ROOT`, …); the model-facing
* bash tool does not expose it as a parameter (a model that needs an env var
* uses shell syntax like `FOO=bar cmd`).
*/
env?: Record<string, string> | undefined
/**
* Opaque OWNER token for a background task — the consumer's isolation key
* (the tool layer passes the owning agent's `session.header.id`). The
@@ -70,6 +88,22 @@ export interface BashExecSpec {
timeoutMs: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin (then close it), carried through
* verbatim from {@link BashExecRequest.stdin}. OPTIONAL on the resolved spec
* (unlike `owner`): it has no config default, so a missing one means "no
* stdin" — the safe, ordinary case — not a silent footgun, so it stays a
* plain optional rather than required-but-nullable (see the request field).
*/
stdin?: string | undefined
/**
* Extra environment entries, carried through verbatim from
* {@link BashExecRequest.env} and merged by the implementation AFTER its
* credential scrub (an explicit entry wins even when its name matches the
* scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no
* config default, absent means "no extra env".
*/
env?: Record<string, string> | undefined
/**
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
* being required on the resolved spec): {@link BashExecutor.resolve} carries

View File

@@ -34,12 +34,16 @@ The owning agent's session token (`session.header.id`) is stamped onto the task
## 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/ui/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/core/tools` ("Tool-owned UI presentation") and `packages/ui/acp` ("Terminal card" / "Tool-call presentation").
These tools own how their calls render in a UI (an editor's tool-call card) via the `dsh-tools` `presentCall`/`presentResult` seam, each returning a `card`-tagged render intent — a UI never special-cases tool names. A FOREGROUND `bash` run declares a **terminal card**: `presentCall` returns `{ card: 'terminal', title, description?, cwd? }` — the **title** is the exact `command` ("ls -la src"), the model-written `description` rides along (rendered ABOVE the card), and `cwd` comes from the model `workdir` when given (absolute as-is, relative for the UI bridge to resolve against the session cwd; else left for the bridge to fill from the session cwd) — and `presentResult` returns `{ card: 'terminal', title?, output?, exitCode?, signal? }` carrying the raw output plus the parsed `exitCode`/`signal`, so a capable client (Zed) renders a terminal card with an exit-status pill. The result carries the raw `output`; the bridge DERIVES the ` ```console ` fenced fallback for a no-terminal-capability UI (the tool no longer encodes the fences itself), so the model-facing result text stays unfenced. 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 instead returns a **generic card** (`{ card: 'generic', title, kind: 'execute', rawInput: command, content: [description] }`); an `isError` result (spawn failure / abort) likewise returns a `generic` result view with no exit pill (there is no real process exit). `bash_output`/`bash_kill` return a `generic` card with a task-scoped title ("Read output from background task bash-3" / "Kill background task bash-3") and 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/core/tools` ("Tool-owned UI presentation") and `packages/ui/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'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get``onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. 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`.
## The tool builds its request from named args only
The `BashExecRequest` seam carries optional `stdin` and `env`, used by the hooks bridges to feed a hook command its JSON payload and `CLAUDE_*` env. This tool does **not** expose them as parameters: its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only, so a model that includes `env` or `stdin` keys in its tool arguments has them ignored. This is not a trust boundary — a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), and the real defense against leaking the harness's ambient secrets is `dsh-bash-local`'s credential scrub, which works regardless. A regression guard drives the real tool with those extra args and asserts the resulting request carries neither field — its job is to catch a future refactor that blindly spreads `...args` into the request (which would silently forward model input into the post-scrub `env` merge), not to defend a wall. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md).
## Permissions
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/execute` waterfall (veto or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.
`TODO(permissions)`: commands run with the executor's full authority. The permission/sandbox seam is the `tools/pre-execute` waterfall (deny or ask) plus sandboxing `BashExecutor` implementations — see docs/architecture.md. `@cordisjs/plugin-capability` (a named-permission service with a session `test()`) is a candidate building block for that work.

View File

@@ -31,7 +31,7 @@
* pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
*
* TODO(permissions): commands run with the executor's full authority. The
* permission/sandbox seam is the `tools/execute` waterfall (veto/ask) plus
* permission/sandbox seam is the `tools/pre-execute` waterfall (deny/ask) plus
* sandboxing `BashExecutor` implementations — see docs/architecture.md
* § plugin checklist.
*
@@ -41,7 +41,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 { GenericCallView, TerminalCallView, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
@@ -158,16 +158,26 @@ export function renderResult(result: BashRunResult): string {
*/
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 }],
function presentBashCall(args: BashCallArgs): GenericCallView | TerminalCallView {
// A background start is not an interactive terminal — a generic execute card
// with the command as rawInput and the description as a content block.
if (args.run_in_background === true) {
return {
card: 'generic',
title: args.command,
kind: 'execute',
rawInput: args.command,
content: [{ type: 'text', text: args.description }],
}
}
// A foreground run IS a terminal: the command titles the card, the description
// renders above it, and the cwd (when the model gave a workdir) heads it.
return {
card: 'terminal',
title: args.command,
description: args.description,
...args.workdir !== undefined ? { cwd: args.workdir } : {},
}
// 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 } : {} }
}
/**
@@ -186,21 +196,25 @@ function presentBashCall(args: BashCallArgs): ToolCallPresentation {
* 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`.
* return a `generic` result whose content is the fenced ```console block. A
* finished foreground run returns a `terminal` result carrying the RAW output
* and the parsed exit status; the BRIDGE derives the fenced fallback from
* `output` for a UI without terminal support, so the tool does not double-encode
* it. A non-text result (unexpected for bash) falls through to `undefined`.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
function presentBashResult(args: unknown, result: ToolResult): ToolResultView | 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) } }
// A background ack or an errored run is not a real terminal exit: render the
// fenced ```console fallback as generic content (no exit pill).
if (isBackground || result.isError) {
return { card: 'generic', content: [{ type: 'text', text: `\`\`\`console\n${raw.replace(/\n+$/, '')}\n\`\`\`` }] }
}
// A finished foreground run: RAW output + parsed exit for the terminal card.
// The bridge derives the no-capability fenced fallback from `output`.
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
}
/**
@@ -237,8 +251,8 @@ function parseExitStatus(text: string): { exitCode: number } | { signal: string
}
/** 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 }
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
}
/**

View File

@@ -123,7 +123,10 @@ describe('bash tool through the agent loop', () => {
textResponse('Background task finished.'),
])
// The second tool call needs the REAL task id from the first result;
// a tools/execute waterfall listener rewrites the scripted arguments.
// a tools/pre-execute listener rewrites the scripted arguments. (This uses
// the low-level capability to mutate `exec` before dispatch — the
// unadvertised mechanism behind a future first-class input-rewrite decision;
// here it is a test shim to thread the generated id, not a product feature.)
let taskId = ''
const ctx = await harness(adapter)
@@ -137,7 +140,7 @@ describe('bash tool through the agent loop', () => {
if (match) taskId = match[1]!
}
})
ctx.on('tools/execute', async (exec, next) => {
ctx.on('tools/pre-execute', async (exec, next) => {
if (exec.name === 'bash_output') {
exec.arguments = { task_id: taskId }
}

View File

@@ -21,8 +21,8 @@ async function setup() {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
return ctx
}
@@ -184,8 +184,8 @@ describe('bash tool', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' })
expect(text(result)).toContain('[output truncated; full output: ')
@@ -316,8 +316,8 @@ describe('background tools', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
await ctx.plugin(ToolBash)
const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true })
@@ -568,8 +568,8 @@ describe('background task ownership (cross-session isolation)', () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 }
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 })
;(ctx.bash as LocalBashExecutor).internals = { spillDir }
const fiber = await ctx.plugin(ToolBash)
const a = fakeAgent('sess-a')
@@ -716,45 +716,40 @@ describe('status lines', () => {
})
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 () => {
it('bash presentCall: a foreground run is a terminal card (command title, description, workdir → cwd absolute or relative)', 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).
// No explicit workdir → a terminal card with no cwd (the UI bridge fills the
// session cwd it owns; the pure presenter can't see it).
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: {} })
.toEqual({ card: 'terminal', title: 'ls -la src', description: 'List files in src' })
// 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' } })
.toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', 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' } })
.toEqual({ card: 'terminal', title: 'pwd', description: 'Print dir', cwd: 'sub' })
})
it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => {
it('bash presentResult: a terminal result carries RAW output (newlines intact) + 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 },
})
// A terminal result keeps the RAW bytes (newlines intact) a terminal renderer
// needs; the bridge derives the fenced fallback. exitCode is parsed back from
// the [exit code: N] marker.
expect(present).toEqual({ card: '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 })
expect(nonzero).toEqual({ card: 'terminal', 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' })
expect(killed).toEqual({ card: 'terminal', output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
})
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
@@ -779,7 +774,8 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
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 ?? {}
// Drop card + output; the remaining fields are the parsed exit.
const { card: _c, output: _o, ...exit } = out as { card: string; output?: string; exitCode?: number; signal?: string }
expect(exit).toEqual(c.expect)
}
})
@@ -793,44 +789,42 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
// 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 })
expect(out).toEqual({ card: 'terminal', 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 })
expect(sig).toEqual({ card: 'terminal', 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 () => {
it('bash presentCall/presentResult: a run_in_background call is a generic card 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.
// The background start returns a task-id ack, not a streamed run — a generic
// execute card with the command as rawInput and the description as content.
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.
expect(call).toEqual({ card: 'generic', title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
// The ack result is a generic fenced-text card — 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```' }])
expect(result).toEqual({ card: 'generic', content: [{ 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 () => {
it('bash presentResult: an isError result is a generic card (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.
// not renderResult output, so a generic fenced card, no terminal output/exit.
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```' }])
expect(out).toEqual({ card: 'generic', content: [{ 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 },
{ content: [{ type: 'reasoning', text: 'unexpected' }], isError: false },
)
expect(present).toBeUndefined()
})
@@ -849,9 +843,9 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
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' })
.toEqual({ card: 'generic', 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' })
.toEqual({ card: 'generic', 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 () => {
@@ -863,3 +857,105 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => {
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
})
})
describe('the model-facing bash tool builds its request from named args only (no {...args} forward)', () => {
/**
* Records every {@link BashExecRequest} the consumer hands to `resolve()`, so a
* test can assert what the model-facing tool DID and DID NOT forward. The `bash`
* tool does not expose `stdin`/`env` as parameters (bash syntax already gives a
* model that power), so it must build its request from named args only and
* never spread unknown tool-call keys into it. This guard's job is to catch a
* future refactor that blindly forwards `...args` — which would silently thread
* model input into the post-scrub `env` merge — NOT to defend a trust boundary
* (the credential scrub in dsh-bash-local is the security control; see the
* bash-stdin-env RFC). Foreground `run()` returns a canned result; `start()` is
* unused here.
*/
class RecordingBashExecutor extends BashExecutor {
readonly requests: BashExecRequest[] = []
resolve(request: BashExecRequest): BashExecSpec {
this.requests.push(request)
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
}
}
run(): Promise<BashRunResult> {
return Promise.resolve({
exitCode: 0, signal: null, timedOut: false, aborted: false, timeoutMs: 0,
stdout: { text: 'ok', truncated: false }, stderr: { text: '', truncated: false },
})
}
start(): BashTask { throw new Error('unused') }
get(): BashTask | undefined { return undefined }
ownerOf(): OwnerToken | undefined { return undefined }
list(): BashTask[] { return [] }
readOutput(): BashTaskRead { throw new Error('unused') }
kill(): boolean { return false }
}
async function setupRecording() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(RecordingBashExecutor)
await ctx.plugin(ToolBash)
return { ctx, bash: ctx.bash as RecordingBashExecutor }
}
it('does not forward env/stdin even when the model includes them as extra arguments', async () => {
const { ctx, bash } = await setupRecording()
// Extra args: the model includes `env` and `stdin` keys hoping they reach the
// executor. The bash tool's schema ignores unknown keys, and execute() builds
// the request from only command/workdir/timeoutMs/signal — so the recorded
// request carries NEITHER. (Not a security wall — the model could set an env
// var or feed stdin via shell syntax anyway; this just keeps the request
// shape honest so a future `...args` spread can't silently forward input.)
await ctx.tools.execute({
callId: CallId('no-forward-1'),
name: 'bash',
arguments: {
command: 'echo hi',
description: 'echo',
env: { SNEAKY_API_KEY: 'leak' },
stdin: 'malicious payload',
},
})
expect(bash.requests).toHaveLength(1)
const request = bash.requests[0]!
expect(request.command).toBe('echo hi')
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
})
it('a background bash call likewise carries no env/stdin', async () => {
const { ctx, bash } = await setupRecording()
// start() throws in this recorder, but resolve() runs first and records the
// request — which is all this no-forward assertion needs.
await ctx.tools.execute({
callId: CallId('no-forward-2'),
name: 'bash',
arguments: {
command: 'sleep 1',
description: 'sleep',
run_in_background: true,
env: { TOKEN: 'leak' },
stdin: 'x',
},
})
expect(bash.requests).toHaveLength(1)
const request = bash.requests[0]!
expect('env' in request).toBe(false)
expect('stdin' in request).toBe(false)
// The owner token IS set on a background call (the isolation fence) — proving
// the recorder sees the real request the consumer built, so the absent
// env/stdin above is a real negative, not a recorder that drops everything.
expect('owner' in request).toBe(true)
})
})

View File

@@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement
| Package | Role | ctx key |
|---|---|---|
| `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` |
| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) |
| `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) |
The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool.

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-compact-basic
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline.
The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization routed through the agent request pipeline.
This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design.
@@ -8,10 +8,10 @@ This is the implementation tier of the compaction capability — see the [interf
The abstract contract states only WHAT compaction does; this backend owns every HOW decision:
- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length).
- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length).
- **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check.
- **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it.
- **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event.
- **Surface mutation** — `compactRegion()` appends the `compact/start``compact/summary``compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README).
- **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface.
@@ -32,6 +32,7 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju
| `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. |
| `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. |
| `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. |
| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. |
## Usage

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-compact-basic",
"description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
"description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",

View File

@@ -2,7 +2,8 @@
* `BasicCompactService`: the first implementation of the
* `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy:
*
* - **Token estimation** — char/4 heuristic with per-block structural overhead.
* - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4)
* with per-block structural overhead.
* - **Retention policy** — walk surface nodes tail→head, keep recent nodes up
* to a token budget, compact everything older. The cutoff is snapped forward
* to the next balanced tool-pairing boundary so a compacted region never
@@ -44,9 +45,6 @@ export { resolveConfig } from './types.ts'
/** Per-block structural overhead for JSON framing / type tag. */
const BLOCK_OVERHEAD = 4
/** Heuristic token count for an image block (~85 tokens for low-res URL). */
const IMAGE_TOKEN_COST = 85
/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */
const ROLE_OVERHEAD = 4
@@ -148,9 +146,11 @@ function finishError(finish: FinishReason): Error | undefined {
}
/**
* Basic, dependency-light compaction backend. Defaults target a 128K context
* window, compacting at 80% utilization and retaining ~20K tokens of recent
* context.
* Basic, dependency-light compaction backend: estimates the surface's token
* footprint, summarizes the stale prefix through the model, and shadows it
* behind a durable checkpoint. Every threshold/budget knob is required config
* ({@link BasicCompactConfig}); the estimator's text density is the
* `charsPerToken` knob.
*/
export class BasicCompactService extends CompactService {
static inject = ['llm']
@@ -207,36 +207,36 @@ export class BasicCompactService extends CompactService {
// ---- Token estimation (overridable hooks) ----
// TODO: char/4 is a coarse heuristic. Replace with an exact count — a real
// tokenizer, or the provider's post-response `usage` (input tokens) fed back
// as a correction — so threshold decisions match the model's actual budget.
// TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact
// count — a real tokenizer, or the provider's post-response `usage` (input
// tokens) fed back as a correction — so threshold decisions match the
// model's actual budget.
/**
* Estimate the token count of content blocks — char/4 with per-block
* overhead. Override in a subclass to plug in a real tokenizer.
* Estimate the token count of content blocks — chars divided by the
* `charsPerToken` config, with per-block overhead. Override in a subclass to
* plug in a real tokenizer.
*/
estimateContentTokens(blocks: readonly ContentBlock[]): number {
const { charsPerToken } = this.config
let tokens = 0
for (const block of blocks) {
switch (block.type) {
case 'text':
case 'reasoning':
tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD
tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD
break
case 'tool-call':
tokens += Math.ceil(block.name.length / 4)
+ Math.ceil(block.arguments.length / 4)
tokens += Math.ceil(block.name.length / charsPerToken)
+ Math.ceil(block.arguments.length / charsPerToken)
+ BLOCK_OVERHEAD
break
case 'tool-result':
tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD
break
case 'image':
tokens += IMAGE_TOKEN_COST
break
default:
// Unknown block types (merge-extensible ContentBlockMap):
// estimate conservatively via JSON stringify.
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4)
tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken)
}
}
return tokens
@@ -266,7 +266,7 @@ export class BasicCompactService extends CompactService {
total += this.estimateContentTokens(msg.content)
total += ROLE_OVERHEAD
}
if (systemPrompt) total += Math.ceil(systemPrompt.length / 4)
if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken)
return total
}
@@ -706,10 +706,10 @@ export class BasicCompactService extends CompactService {
/**
* Render content blocks to a single plain-text string for the summarization
* prompt. Text and reasoning contribute their text; every other block type
* contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`,
* …) so the summarizer is told what non-text content existed in the region
* rather than silently losing it. Blocks join with newlines; empty-text
* blocks contribute nothing.
* contributes a type-tagged placeholder (`[tool-call: name(args)]`,
* `[tool-result: …]`, …) so the summarizer is told what non-text content
* existed in the region rather than silently losing it. Blocks join with
* newlines; empty-text blocks contribute nothing.
*/
private _blocksToText(blocks: readonly ContentBlock[]): string {
const parts: string[] = []
@@ -729,9 +729,6 @@ export class BasicCompactService extends CompactService {
parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]')
break
}
case 'image':
parts.push('[image]')
break
// ContentBlockMap is merge-extensible — render an unknown block as a
// bare type-tagged placeholder so a plugin-added block type is still
// signalled to the summarizer rather than dropped.

View File

@@ -10,10 +10,12 @@
*/
/**
* Backend configuration. Every knob is REQUIRED except `auto`: there is no
* concrete data yet to justify default thresholds/budgets, so a consumer must
* state each value explicitly rather than inherit a guessed default. `auto`
* alone defaults to `true` (auto-compaction is the intended posture).
* Backend configuration. Every knob is REQUIRED except `auto` and
* `charsPerToken`: there is no concrete data yet to justify default
* thresholds/budgets, so a consumer must state each value explicitly rather
* than inherit a guessed default. `auto` alone defaults to `true`
* (auto-compaction is the intended posture), and `charsPerToken` defaults to
* the English-text heuristic its estimator was calibrated on.
*/
export interface BasicCompactConfig {
/** Context window size in tokens. */
@@ -30,13 +32,21 @@ export interface BasicCompactConfig {
compactionRetries: number
/** Enable automatic compaction on the `agent/pre-step` seam (default true). */
auto?: boolean
/**
* Text density for the token estimator: estimated tokens = chars /
* `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy
* deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so
* the default UNDERestimates several-fold and compaction fires far too late.
* May be fractional.
*/
charsPerToken?: number
}
/** Resolved config with `auto` defaulted. */
/** Resolved config with `auto` and `charsPerToken` defaulted. */
export type ResolvedConfig = Required<BasicCompactConfig>
/**
* Default `auto` when unset and reject nonsensical numeric knobs.
* Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs.
*
* Convergence is not a static config invariant: provider generation caps can be
* spent on hidden or surfaced reasoning tokens, and the model may emit a summary
@@ -46,13 +56,14 @@ export type ResolvedConfig = Required<BasicCompactConfig>
* throwing if the surface still exceeds the threshold.
*/
export function resolveConfig(config: BasicCompactConfig): ResolvedConfig {
const resolved: ResolvedConfig = { auto: true, ...config }
const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config }
assertPositiveInteger('contextWindow', resolved.contextWindow)
assertRatio('thresholdRatio', resolved.thresholdRatio)
assertNonNegativeInteger('retainTokens', resolved.retainTokens)
assertPositiveInteger('maxTokens', resolved.maxTokens)
assertNonNegativeInteger('compactionRetries', resolved.compactionRetries)
assertPositiveFinite('charsPerToken', resolved.charsPerToken)
if (typeof resolved.summarizationModel !== 'string') {
throw new Error('BasicCompactConfig: summarizationModel must be a string.')
}
@@ -74,6 +85,12 @@ function assertNonNegativeInteger(name: string, value: number): void {
}
}
function assertPositiveFinite(name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`)
}
}
function assertRatio(name: string, value: number): void {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) {
throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`)

View File

@@ -811,15 +811,23 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => {
])).toBe(10)
})
it('estimates image blocks at fixed 85 tokens', () => {
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85)
})
it('returns 0 for empty content blocks', () => {
const svc = new BasicCompactService(new Context(), cfg({ auto: false }))
expect(svc.estimateContentTokens([])).toBe(0)
})
it('honors a configured charsPerToken (fractional densities included)', () => {
// 'this is a somewhat longer text block' = 36 chars.
const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }]
// charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate.
const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 }))
expect(dense.estimateContentTokens(blocks)).toBe(22)
// Fractional density is legal: ceil(36/1.5)+4 = 28.
const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 }))
expect(fractional.estimateContentTokens(blocks)).toBe(28)
// The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18.
expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18)
})
})
describe('BasicCompactService HMR safety', () => {
@@ -862,6 +870,10 @@ describe('BasicCompactService config validation', () => {
)).toThrow(/summarizationModel must be a string/)
expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>)))
.toThrow(/auto must be a boolean/)
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 })))
.toThrow(/charsPerToken .* positive finite number/)
expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN })))
.toThrow(/charsPerToken .* positive finite number/)
})
it('accepts a large retain budget because convergence is enforced dynamically', () => {
@@ -1324,7 +1336,7 @@ describe('BasicCompactService edge cases', () => {
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] },
{ type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] },
{ type: 'custom-widget', payload: 'x' } as unknown as ContentBlock,
{ type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' },
],
@@ -1343,7 +1355,7 @@ describe('BasicCompactService edge cases', () => {
const nodes = s.surface.nodes
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
const { text } = svc.summarizeCalls[0]!
expect(text).toContain('[tool-result: [image]]') // nested tool-result with content
expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content
expect(text).toContain('[custom-widget]') // unknown block placeholder
expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder
})
@@ -1510,25 +1522,28 @@ describe('BasicCompactService edge cases', () => {
it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => {
const svc = createTestService()
const s = new Session(SessionId('placeholders'))
// A plugin-added block type (merge-extensible ContentBlockMap) — the
// placeholder path must cover every message kind, not just assistant.
const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock)
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
// user/message with only an image block → '[image]' placeholder.
s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
// assistant/message with an image block AND the tool-call its tool/result
// answers (so the surface is tool-pairing balanced) → '[image]' placeholder.
// user/message with only a plugin-added block → '[chart]' placeholder.
s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' })
// assistant/message with a plugin-added block AND the tool-call its
// tool/result answers (so the surface is tool-pairing balanced).
s.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'image', url: 'https://x/z.png' },
chart('z'),
{ type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' },
],
}, { surfaceOp: 'append' })
// tool/result with an image block → '[image]' placeholder.
// tool/result with a plugin-added block → '[chart]' placeholder.
s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' })
// context/message and steering/message with image content.
s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' })
// context/message and steering/message with plugin-added content.
s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('step/end', { turn: 1, step: 1 })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
@@ -1537,11 +1552,11 @@ describe('BasicCompactService edge cases', () => {
await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm')
const { text } = svc.summarizeCalls[0]!
// Every non-text block surfaces as a placeholder rather than being dropped.
expect(text).toContain('User: [image]')
expect(text).toContain('Assistant: [image]')
expect(text).toContain('Tool result (call e1): [image]')
expect(text).toContain('[Context: [image]]')
expect(text).toContain('[Steering: [image]]')
expect(text).toContain('User: [chart]')
expect(text).toContain('Assistant: [chart]')
expect(text).toContain('Tool result (call e1): [chart]')
expect(text).toContain('[Context: [chart]]')
expect(text).toContain('[Steering: [chart]]')
})
})

View File

@@ -7,7 +7,7 @@ This package is the interface tier of the compaction capability, split so each c
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` |
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization |
| `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` |
Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md).
@@ -43,13 +43,7 @@ Compaction is serialized via a log-recorded lock: `compactRegion` refuses to sta
## Events
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`:
| Event | Payload | On surface? |
|---|---|---|
| `compact/start` | `{ turn }` | no (log-only) |
| `compact/summary` | `{ summary, shadowedRange, shadowedSeqs, shadowedTokenCount }` | no (log-only) |
| `compact/end` | `{ turn, error? }` | no (log-only) |
The `compact/*` events extend `SessionEventMap` (merge-extensible) via declaration merging — they are session events, not cordis `Events`, and all three are log-only (no `surfaceOp`). Per-event payloads and semantics are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md).
## Implementing a backend

View File

@@ -141,6 +141,7 @@ export abstract class CompactService extends Service {
* prior replace can leave the surface non-monotonic in seq order), or if
* either boundary is not a balanced tool-pairing cut (would split a step's
* tool-call/result pair).
* @returns what the compaction did (the replaced range and its summary node).
*/
abstract compactRegion(
session: Session,

View File

@@ -6,8 +6,8 @@ The packages every harness build is assembled from: the session log, the system-
|---|---|---|
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
| `user-interaction/` | Human question/answer seam for tools and permission flows | `ctx.userInteraction` |
| `tools/` | Tool registry + `tools/pre-execute`/`tools/post-execute` pipeline | `ctx.tools` |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |

View File

@@ -13,7 +13,7 @@ This is the package to read to see **the whole plugin tree at once** — the tea
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
@deepseek-ai/dsh-session event-sourced session log + store
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
@deepseek-ai/dsh-tools tool registry + tools/execute waterfall
@deepseek-ai/dsh-tools tool registry + tools/pre-execute/post-execute
@deepseek-ai/dsh-agent agent registry + agent/* event vocabulary
@deepseek-ai/dsh-invariants dev-mode event-contract assertions
@deepseek-ai/dsh-tool-bash the model-facing bash/bash_output/bash_kill schemas

View File

@@ -45,10 +45,14 @@ Agents listed in config are auto-created at startup.
One invocation of `runLoop()` drives one agent for its whole lifetime:
```
create agent → emit agent/session-start(source) ⟵ once, before turn 1
forever:
wait for queued messages (idle)
TURN (error-contained):
drain queued → 'turn/start' → session('user/message')
'turn/start'
each queued: waterfall agent/prompt-submit → allow (→ session('user/message'),
inject additionalContext) | block (→ session('prompt/blocked'), drop)
if every prompt blocked: 'turn/end'(rejected), no step ⟵ zero-step turn
STEP loop:
drain steering
assembly = systemPrompt.assemble()
@@ -58,10 +62,14 @@ forever:
stream llm.stream(request) → session('assistant/chunk')
message = waterfall agent/step-result
session('assistant/message')
each tool-call: session('tool/call') → tools.execute() → session('tool/result')
each tool-call: session('tool/call')
→ tools.execute() [waterfall tools/pre-execute → dispatch → tools/post-execute]
→ session('tool/result')
append buffered post-execute additionalContext as session('context/message')(s)
drain steering → session('steering/message')
cont = waterfall agent/turn-continuation
if !cont: break
cont = waterfall agent/turn-continuation → ContinuationDecision
({action:'continue', reason?} records reason as next-step steering)
if action==stop (and no pending steering): break
session('turn/end')
await session/flush
re-enqueue leftover steering as queued
@@ -75,9 +83,9 @@ Cancellation: `agent.cancel()` is the single public stop primitive — it clears
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:
- Hooks: `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/execute`, `agent/turn-continuation`
- Hooks: `agent/session-start`, `agent/prompt-submit`, `agent/pre-step`, `agent/request`, `agent/step-result`, `tools/pre-execute`, `tools/post-execute`, `agent/turn-continuation`
- Compaction: `agent/pre-step`
- Sandbox, permission, plan mode: `tools/execute`
- Sandbox, permission, plan mode: `tools/pre-execute` (deny/ask gate), `tools/post-execute`
- Sub-agents: implemented outside the loop as `ctx.subagents` providers; in-process providers use `ctx.agents.create()` and owned `AgentHandle` teardown, while child streaming/progress and background/poll collection remain deferred.
- Persistence: `session/event` + `session/flush`
- UI: `agent/stream-chunk` + `agent/*` events
- UI: `session/event` (assistant token stream, boundaries, tool activity) + `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`)

View File

@@ -79,7 +79,7 @@ export class ReactLoopAgent implements Agent {
// Release quiescence waiters on a transition OUT of running BEFORE emitting
// (the disposer handles the disposed transition separately). Settling first
// means a throwing `agent/status` subscriber cannot starve a `whenIdle()`
// waiter (AGENTS.md "contain callback exceptions" — a lifecycle await must
// waiter (docs/defensive-patterns.md "contain callback exceptions" — a lifecycle await must
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
try {

View File

@@ -10,7 +10,7 @@
import { Context, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, SessionStartSource } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
@@ -121,6 +121,9 @@ export class AgentLoop extends Service implements AgentFactory {
* deliberate resume-or-create policy (resume the prior session if one exists,
* else start fresh) or an explicit caller-chosen session id — revisit when the
* UI/ACP path owns session selection.
* @param id - the agent id; also seeds the generated session id.
* @param options - loop options (model, limits, …); defaults applied per option.
* @returns the running agent, owned by the calling fiber (no handle).
*/
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
@@ -129,7 +132,7 @@ export class AgentLoop extends Service implements AgentFactory {
// session + agent down as one ordered chain, capturing the loop's closing
// flush). The whole effect is owned by THIS fiber; no AgentHandle is needed.
const session = this.ctx.sessions.prepare(SessionId(`${id}-session-${randomUUID()}`), { meta: {} })
const { agent } = this.start(id, options, session)
const { agent } = this.start(id, options, session, 'startup')
return agent
}
@@ -142,6 +145,9 @@ export class AgentLoop extends Service implements AgentFactory {
* `seed` (a balanced completed-turn prefix of the parent's log) so the child
* starts with the parent's context. Returns an {@link AgentHandle} the owner
* disposes to tear down exactly this agent.
* @param options - agent id, caller-supplied session id, optional seed/meta,
* and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE preparing the session: register() would reject a
@@ -152,7 +158,9 @@ export class AgentLoop extends Service implements AgentFactory {
...options.seed !== undefined ? { seed: options.seed } : {},
meta: options.meta ?? {},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
// A seeded (forked) create is still a fresh start, NOT a resume — `resume`
// is reserved for reloading a PERSISTED session via resume()/resumeWith().
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'startup')
}
/**
@@ -166,6 +174,8 @@ export class AgentLoop extends Service implements AgentFactory {
* configured. NOT hard-injected (that would make non-persistent demos pend
* forever) — callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
* @param options - the persisted session id to reload, plus agent id/options.
* @returns the handle for the agent resumed on the reconstructed session.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
@@ -224,7 +234,7 @@ export class AgentLoop extends Service implements AgentFactory {
...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {},
},
})
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
return this.startOwned(options.agentId, options.agentOptions ?? {}, session, 'resume')
}
/**
@@ -261,14 +271,33 @@ export class AgentLoop extends Service implements AgentFactory {
* so a throwing `session/created`/`agent/created` listener unwinds the
* already-yielded disposers instead of leaking.
*
* `source` says why the session began ({@link SessionStartSource}); it is
* emitted as `agent/session-start` once, AFTER the agent is registered (so a
* listener can resolve the agent via `ctx.agents.get(id)` and `inject()` into
* it) and BEFORE the loop starts its first turn. The emit is contained: a
* throwing session-start listener must not abort agent construction — it is
* logged, and the agent still starts. (Unlike a turn-boundary throw, there is
* no open turn here to balance; the durable evidence of a session-start hook
* is whatever it `inject()`ed.)
*
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
*/
private start(id: AgentId, options: AgentOptions, session: Session): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
private start(
id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource,
): { agent: ReactLoopAgent; disposeAgent: () => Promise<void> } {
const agent = new ReactLoopAgent(this.ctx, id, options, session)
const dispose = this.ctx.effect(function* (this: AgentLoop) {
yield this.ctx.sessions.enter(session)
this.ctx.sessions.announce(session)
yield this.ctx.agents.register(agent)
// Fire AFTER register (a listener can ctx.agents.get(id) + inject()) and
// BEFORE the loop's first turn. Contained: a throwing listener is logged,
// never aborts construction (no open turn to balance here).
try {
this.ctx.emit('agent/session-start', agent, source)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${id}": agent/session-start listener threw: ${String(error)}`)
}
const stop = agent.start()
// Disposed FIRST (LIFO): request loop stop (sync), then AWAIT the loop's
// actual exit so its closing flush lands while onAppend (yielded above,
@@ -295,8 +324,8 @@ export class AgentLoop extends Service implements AgentFactory {
* `AgentHandle.dispose(): Promise<void>` contract (mirrors the ACP `quiesce()`
* helper).
*/
private startOwned(id: AgentId, options: AgentOptions, session: Session): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, session)
private startOwned(id: AgentId, options: AgentOptions, session: Session, source: SessionStartSource): AgentHandle {
const { agent, disposeAgent } = this.start(id, options, session, source)
let disposing: Promise<void> | undefined
return { agent, dispose: () => (disposing ??= disposeAgent()) }
}

View File

@@ -10,6 +10,7 @@
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
@@ -142,30 +143,37 @@ export interface LoopHandle {
* The agent loop. One invocation drives one agent for its whole lifetime:
*
* ```
* create agent → emit agent/session-start(source) ⟵ once, before turn 1
* forever:
* wait for queued messages (idle)
* TURN (error-contained — a throwing plugin ends the turn, never the loop):
* drain queued → 'turn/start' → session('user/message'…) → emit agent/turn-start
* 'turn/start'; each queued msg: waterfall agent/prompt-submit ⟵ durable turn boundary (no agent/* mirror)
* allow → session('user/message'…) (+ inject additionalContext) | block → drop
* every prompt blocked → 'turn/end'(rejected), 0 steps
* STEP loop:
* drain steering → session('steering/message') ⟵ catches late steering
* assembly = ctx.systemPrompt.assemble() ⟵ waterfall system-prompt/assemble
* await ctx.serial('agent/pre-step') ⟵ surface mutation (compaction) OUTSIDE the step
* session('step/start'); emit agent/step-start ⟵ append before emit (the event-sourcing RFC)
* session('step/start') ⟵ durable step boundary (no agent/* mirror)
* req = {model, system, tools, messages: session.deriveMessages(), signal}
* req = waterfall agent/request ⟵ hooks/model-switch
* stream ctx.llm.stream(req) ⟵ waterfall llm/stream (raw chunks)
* session('assistant/chunk'); emit agent/stream-chunk
* session('assistant/chunk')
* msg = waterfall agent/step-result ⟵ BEFORE the log append, so the
* session('assistant/message' {content, usage?}) session records what actually ran
* each tool-call in msg (sequential, abort-checked):
* session('tool/call'); ctx.tools.execute() ⟵ waterfall tools/execute
* session('tool/call'); ctx.tools.execute() ⟵ tools/pre-execute (allow/deny/ask)
* → dispatch → tools/post-execute
* session('tool/result')
* drain steering → session('steering/message'); emit agent/steering
* emit agent/step-end
* cont = waterfall agent/turn-continuation(default = hadToolCalls || steered)
* if !cont && steering arrived from step-end/continuation listeners: cont = true
* if !cont: break
* session('turn/end'); emit agent/turn-end
* append buffered post-execute additionalContext → session('context/message')(s)
* drain steering → session('steering/message')
* session('step/end') ⟵ durable step boundary (no agent/* mirror)
* cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default
* {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is
* recorded as next-step steering
* if action==stop && steering arrived (step/end/continuation listeners): continue anyway
* if action==stop: break
* session('turn/end') ⟵ durable turn boundary (no agent/* mirror)
* await ctx.parallel('session/flush', session) ⟵ durability checkpoint
* re-enqueue leftover steering as queued ⟵ steering is never stranded
* idle (emit agent/status) unless more queued
@@ -277,37 +285,32 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
let reason: TurnEndReason = { kind: 'completed' }
let step = 0
let turnEnded = false
let stepOpen = false
let errorReported = false
// 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 (the event-sourcing RFC append-before-emit).
// Close the open step exactly once (idempotent via stepOpen). Step boundaries
// are durable session events only — there is no agent/* step emit to mirror
// them (see the agent event-domain rule). A throwing step/end session-event
// listener must not abort finalization and strand the turn open (turn/end
// balance > notifying one bad listener); it is contained and surfaced as a
// turn error below.
const closeStep = (): boolean => {
if (!stepOpen) return false
stepOpen = false
// Session.append pushes step/end BEFORE notifying session/event listeners,
// so a throwing listener leaves step/end in the log (balance holds) but
// would otherwise abort finalization. Contain it and surface it as a turn
// error below — the same outcome as a throwing agent/step-end listener.
// error below.
let failure: unknown
try {
session.append('step/end', { turn, step })
} catch (error: unknown) {
failure = error
}
try {
ctx.emit('agent/step-end', agent, turn, step)
} catch (error: unknown) {
failure ??= error
}
// A throwing step/end session-event listener OR a throwing agent/step-end
// listener surfaces as a turn error via failTurn (idempotent). This prevents
// 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.
// A throwing step/end session-event listener surfaces as a turn error via
// failTurn (idempotent). This prevents 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 true
@@ -324,47 +327,38 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
const failTurn = (err: CodedError): void => {
if (errorReported) return
errorReported = true
// Set the error reason ONLY while the turn is still open — closeTurn appends
// turn/end with it. If the turn has already ended (the only way here: a
// throwing agent/turn-end listener after closeTurn(true) already appended
// turn/end), the reason can no longer affect the durable log, so log the late
// throw directly instead — otherwise the listener exception would vanish.
if (!turnEnded) {
reason = { kind: 'error', step, ...errorData(err) }
} else {
ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`)
}
// The turn is always still open here: the only failure that can reach
// failTurn once turn/end is appended would be a throwing turn-boundary
// listener, and turn boundaries are durable session events with no agent/*
// mirror to throw. A throwing `turn/end` session-event listener is already
// contained inside closeTurn (append pushes before notifying, so the
// boundary is durable). So set the error reason for closeTurn to append.
reason = { kind: 'error', step, ...errorData(err) }
try {
ctx.emit('agent/error', agent, turn, step, err)
} catch {
// contained: the error is already captured (on `reason`, or via the logger
// above); a throwing agent/error listener must not prevent the turn from
// closing.
// contained: the error is already captured on `reason`; a throwing
// agent/error listener must not prevent the turn from closing.
}
}
// Close the turn exactly once (idempotent via turnEnded). `emit` is false on
// the error path (the failure was already surfaced via agent/error) and true
// on the normal/inline-error path. A throwing agent/turn-end listener on the
// normal path escapes to the outer catch, which surfaces it via failTurn —
// turn/end is already appended, so balance holds either way.
const closeTurn = (emit: boolean): void => {
if (turnEnded) return
turnEnded = true
// Close the turn. Called exactly once per turn — the normal loop exit and the
// outer catch are mutually exclusive paths, and this never throws (the append
// is contained below), so there is no re-entry to guard against (unlike
// closeStep, which the cancel branches and the outer catch can both reach).
// Turn boundaries are durable session events only — there is no agent/* turn
// emit to mirror them (see the agent event-domain rule).
const closeTurn = (): void => {
// Session.append pushes turn/end BEFORE notifying session/event listeners,
// so a throwing listener leaves turn/end in the log (the turn is balanced)
// but would otherwise escape — from the outer catch's closeTurn(false) it
// would propagate to the runLoop backstop, and from the normal-path
// closeTurn(true) it would skip the agent/turn-end emit. Contain it: the
// boundary is durable either way, and finalization must not abort on a bad
// listener. (On the normal path the outer catch also re-runs closeTurn,
// which is an idempotent no-op once turnEnded is set.)
// but would otherwise escape — from the outer catch it would propagate to
// the runLoop backstop. Contain it: the boundary is durable either way, and
// finalization must not abort on a bad listener.
try {
session.append('turn/end', { turn, reason })
} catch (error: unknown) {
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on turn/end at turn ${turn}: ${toError(error).message}`)
}
if (emit) ctx.emit('agent/turn-end', agent, turn, reason)
}
try {
@@ -373,20 +367,60 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
// decides "owed" from the log via isTurnOpen, so even a throwing turn/start
// listener — append pushes before notifying — still gets its turn/end).
session.append('turn/start', { turn, trigger })
// Record the queued user messages INSIDE the turn (after turn/start), so
// every event in the log is turn-enclosed. turn/end is now owed, so a throw
// while appending these is caught below and the turn is still closed.
// Each drained queued message runs the `agent/prompt-submit` waterfall before
// it becomes a `user/message` — a hook can rewrite the prompt or block it.
// Recorded INSIDE the turn (after turn/start) so every event is turn-enclosed;
// turn/end is now owed, so a throwing prompt-submit listener (the waterfall
// throws) is caught below and the turn still closes.
let anyAllowed = false
// Seeded with a floor (only observable if the batch were empty, which
// runTurn never allows — it is called with ≥1 queued message); each `block`
// decision carries a required `reason` and overwrites it, so a fully-blocked
// batch always reports the last vetoing reason.
let lastBlockReason = 'prompt blocked by hook'
for (const message of queued) {
session.append('user/message', { content: message.content, source: message.source }, { surfaceOp: 'append' })
const decision = await ctx.waterfall(
'agent/prompt-submit', agent, message.content, message.source,
() => Promise.resolve<PromptDecision>({ kind: 'allow' }),
)
if (decision.kind === 'block') {
lastBlockReason = decision.reason
// Record the veto durably: `PromptDecision.reason` is the durable record
// of why a prompt was blocked, but a fully-blocked batch's `rejected`
// turn/end only preserves the LAST reason, and a MIXED batch (this prompt
// blocked, another allowed) does not end `rejected` at all — so without
// this append a blocked prompt would vanish from the log whenever any
// sibling prompt is allowed. `prompt/blocked` sits in the open turn in
// place of the `user/message` this prompt would have become.
session.append('prompt/blocked', { content: message.content, source: message.source, reason: decision.reason })
continue
}
anyAllowed = true
// `allow.content` REPLACES the prompt bytes (a rewrite); absent keeps them.
const content = decision.content ?? message.content
session.append('user/message', { content, source: message.source }, { surfaceOp: 'append' })
// `allow.additionalContext` is a SEPARATE context/message the next request
// also sees. The turn is open, so inject() appends it into THIS turn.
if (decision.additionalContext) {
agent.inject(decision.additionalContext.content, { source: decision.additionalContext.source })
}
}
ctx.emit('agent/turn-start', agent, turn)
while (true) {
// A fully-blocked batch (every prompt vetoed by prompt-submit) opens a
// zero-step turn that ends `rejected`: break BEFORE the first step so the
// boundary stays balanced (turn/start → turn/end) and the block is a
// durable in-turn fact. `anyAllowed` never changes inside the loop, so this
// only ever fires on the first iteration.
if (!anyAllowed) {
reason = { kind: 'rejected', reason: lastBlockReason }
break
}
step += 1
// Steering from the previous round's step-end/continuation listeners
// (or turn-start listeners on the first step) joins before the request.
drainSteering(ctx, agent, turn)
// Steering from the previous round's continuation listeners joins before
// the request.
drainSteering(agent, turn)
// The step's AbortController exists BEFORE any async pre-step work so a
// dispose() or cancel() — in a synchronous turn-start listener or an
@@ -432,24 +466,25 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
// pre-step plugin ends the turn, not the loop.
await ctx.serial('agent/pre-step', agent, turn, step, fullSystemPrompt, abort.signal)
// Interruption landing during the pre-step seam: do not open an empty
// step. `agent/step-start` listeners get their own check below because
// they necessarily run after step/start is appended/emitted.
// Interruption landing during the pre-step seam: do not open an empty step.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
break
}
session.append('step/start', { turn, step })
// Mark the step open BEFORE the append: Session.append pushes the event
// to the log before notifying session/event listeners, so a THROWING
// step/start listener leaves step/start in the log. Setting stepOpen first
// means the outer catch's closeStep() then appends the balancing step/end
// (turn stays enclosed) instead of stranding an open step under turn/end.
stepOpen = true
ctx.emit('agent/step-start', agent, turn, step)
session.append('step/start', { turn, step })
// Cancel landing in the step-start window: a synchronous
// `agent/step-start` listener can cancel after the step is already open.
// Check AFTER step/start append + emit and before `runStep`: drop the
// step, end the turn accordingly. closeStep balances the already-appended
// step/start.
// Cancel landing in the step-start window: a synchronous `session/event`
// step/start listener can cancel after the step is already open. Check
// AFTER the step/start append and before `runStep`: drop the step, end the
// turn accordingly. closeStep balances the already-appended step/start.
if (handle.isCancelled() || handle.isDisposed()) {
handle.setAbort(undefined)
reason = handle.isDisposed() ? { kind: 'disposed' } : { kind: 'aborted', reason: handle.cancelReason() }
@@ -494,14 +529,14 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
if (stepReason) reason = stepReason
// Steering that arrived during streaming/tool execution.
const steered = drainSteering(ctx, agent, turn)
const steered = drainSteering(agent, turn)
if (closeStep()) break
const defaultDecision = stepOutcome.hadToolCalls || steered
let shouldContinue: boolean
const defaultDecision: ContinuationDecision = { action: stepOutcome.hadToolCalls || steered ? 'continue' : 'stop' }
let decision: ContinuationDecision
try {
shouldContinue = await ctx.waterfall(
decision = await ctx.waterfall(
'agent/turn-continuation', agent, turn, defaultDecision,
() => Promise.resolve(defaultDecision),
)
@@ -511,9 +546,18 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
break
}
// Steering from step-end/continuation listeners (the /goal pattern)
// demands the model see it — it overrides a negative decision; the
// next iteration's drain records it.
// A forced `continue` may carry model-facing context: record it as
// next-STEP steering (the steering channel), so the continued turn's next
// iteration drains it before its request — the typed twin of the /goal
// step/end-steer pattern.
if (decision.action === 'continue' && decision.reason) {
agent.inbox.steer({ content: decision.reason.content, source: decision.reason.source })
}
let shouldContinue = decision.action === 'continue'
// Steering from step/end session-event or continuation listeners (the
// /goal pattern) demands the model see it — it overrides a stop decision;
// the next iteration's drain records it.
if (!shouldContinue && agent.inbox.hasSteering) shouldContinue = true
// A cancel that landed during the continuation window — after the step's
@@ -533,8 +577,8 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
}
}
// Normal / inline-error loop exit: close the turn and notify.
closeTurn(true)
// Normal / inline-error loop exit: close the turn.
closeTurn()
} catch (error: unknown) {
// Decide whether this turn was ever opened from the LOG, not a flag.
// Session.append pushes the event BEFORE notifying session/event listeners,
@@ -543,28 +587,29 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
// Gating on a "turn started" boolean would skip turn/end and leave a
// 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
// chosen below). Absent means the turn/start append threw BEFORE its push (a
// non-serializable trigger — impossible for our fixed trigger); nothing was
// opened, so rethrow to the runLoop backstop.
// and the normal-exit `closeTurn()` did NOT run (we are here because a throw
// preceded it — the two `closeTurn()` sites are on mutually exclusive paths),
// so this catch appends turn/end with the disposed/error reason chosen below.
// `closeStep()` IS idempotent (guarded by `stepOpen`) — it may have run
// already in a step branch, so running it again is a safe no-op. Absent
// turn/start means the append threw BEFORE its push (a non-serializable
// trigger — impossible for our fixed trigger); nothing was opened, so rethrow
// to the runLoop backstop.
const turnStartLogged = session.events.some(e => e.type === 'turn/start' && e.data.turn === turn)
if (!turnStartLogged) throw error
closeStep()
// Choose the close reason. Disposal wins only if no error was already
// reported: a turn disposed mid-step sets reason=disposed in the step-error
// branch (without reporting an error), and if closeTurn(true)'s turn-end
// emit then throws, we land here and must PRESERVE disposed rather than
// overwrite it with the listener's throw. Otherwise a boundary-emit throw
// on a live agent is a real failure → failTurn. (errorReported is mutated
// only inside the failTurn closure, which the analyzer can't follow, hence
// the inline lint-disable.)
// branch (without reporting an error), so preserve disposed rather than
// overwrite it. Otherwise a mid-step throw on a live agent is a real
// failure → failTurn. (errorReported is mutated only inside the failTurn
// closure, which the analyzer can't follow, hence the inline lint-disable.)
if (handle.isDisposed() && !errorReported) { // eslint-disable-line @typescript-eslint/no-unnecessary-condition
reason = { kind: 'disposed' }
} else {
failTurn(toError(error))
}
closeTurn(false)
closeTurn()
}
// Durability checkpoint: persistence plugins drain write-behind buffers.
@@ -590,11 +635,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle,
}
/** Drain the steering queue into the session. Returns whether any arrived. */
function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean {
function drainSteering(agent: ReactLoopAgent, turn: number): boolean {
const messages = agent.inbox.drainSteering()
for (const message of messages) {
agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' })
ctx.emit('agent/steering', agent, turn, message.content, message.source)
}
return messages.length > 0
}
@@ -636,7 +680,6 @@ async function runStep(
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
ctx.emit('agent/stream-chunk', agent, turn, step, chunk)
assembler.push(chunk)
}
@@ -695,6 +738,12 @@ async function runStep(
// ToolRegistry.execute converts tool failures (including aborts) into
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
// Per-step buffer of `additionalContext` attached by tools/post-execute
// listeners. Appended as context/message(s) only AFTER every tool/result for
// the step, so a multi-call step keeps tool-call/result adjacency
// (interleaving context between a call's result and the next call's would
// break the pairing the next model request relies on).
const pendingContext: HookContext[] = []
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
@@ -705,6 +754,12 @@ async function runStep(
} catch {
parsedArguments = call.arguments
}
// TODO(pre-tool-input-rewrite): tools/pre-execute deliberately cannot rewrite
// `arguments` — tool/call (the audit record) and assistant/message (the
// model-history source) are logged BEFORE execute, and live consumers (ACP,
// tool-bash presentation) read the pre-execution args, so an execution-only
// rewrite would desync the UI from what ran. Designing that consistently is
// its own proposed RFC (docs/rfc/proposed/feature/…-pre-tool-input-rewrite.md).
const result = await ctx.tools.execute({
callId: call.id,
name: call.name,
@@ -716,7 +771,7 @@ async function runStep(
turn, step,
// The correlation id MUST be the loop's authoritative call.id (the
// model-transcript id that deriveMessages turns into toolCallId), NOT
// result.callId — a tools/execute waterfall listener returning a
// result.callId — a post-execute waterfall listener returning a
// mismatched id would otherwise orphan the call↔result pairing in the
// next model request. A listener-internal id, if ever needed, belongs in
// a separate diagnostic field, never overloaded onto callId.
@@ -724,7 +779,12 @@ async function runStep(
content: result.content,
isError: result.isError,
...result.error ? { error: result.error } : {},
// The tool's private presentation payload (e.g. a result-time diff),
// persisted so a UI bridge reproduces the card on replay.
...result.meta !== undefined ? { meta: result.meta } : {},
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// Buffer (don't append yet) any post-execute additionalContext for this call.
if (result.additionalContext) pendingContext.push(result.additionalContext)
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
@@ -733,6 +793,13 @@ async function runStep(
/* v8 ignore stop */
}
// Append buffered post-execute context AFTER every tool/result, preserving
// tool-call/result adjacency across the whole batch. inject() appends into the
// open turn (a context/message at its chronological position).
for (const context of pendingContext) {
agent.inject(context.content, { source: context.source })
}
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
}

View File

@@ -117,7 +117,7 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -134,7 +134,7 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -166,22 +166,23 @@ describe('Agent.cancel()', () => {
expect(reasons.length).toBe(2)
})
it('cancel from a synchronous agent/turn-start listener drops the step (step-start window)', async () => {
it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A turn-start listener fires BEFORE any AbortController is installed for the
// step. Cancelling there must still drop the step (the turn-scoped marker,
// not the step AbortController, is what catches this) — no model step runs.
// A turn/start listener fires right after turn/start is appended, BEFORE any
// AbortController is installed for the step. Cancelling there must still drop
// the step (the turn-scoped marker, not the step AbortController, is what
// catches this) — no model step runs.
let streamed = false
ctx.on('agent/stream-chunk', () => { streamed = true })
const dispose = ctx.on('agent/turn-start', (subject) => {
if (subject === agent) agent.cancel('from turn-start')
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'turn/start') agent.cancel('from turn-start')
})
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -194,23 +195,23 @@ describe('Agent.cancel()', () => {
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
})
it('cancel from a synchronous agent/step-start listener drops the step (post-step-start window)', async () => {
it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A step-start listener fires AFTER step/start is appended (and after the
// pre-step seam), so cancelling there lands in the SECOND cancel check (the
// one that must closeStep() to balance the already-open step) — distinct
// from a turn-start cancel, which is caught before the step opens.
// A step/start session-event listener fires AFTER step/start is appended
// (and after the pre-step seam), so cancelling there lands in the SECOND
// cancel check (the one that must closeStep() to balance the already-open
// step) — distinct from a turn-start cancel, caught before the step opens.
let streamed = false
ctx.on('agent/stream-chunk', () => { streamed = true })
const dispose = ctx.on('agent/step-start', (subject) => {
if (subject === agent) agent.cancel('from step-start')
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'step/start') agent.cancel('from step-start')
})
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -224,7 +225,7 @@ describe('Agent.cancel()', () => {
expect(types.filter(t => t === 'step/start').length).toBe(types.filter(t => t === 'step/end').length)
})
it('disposal from a synchronous agent/step-start listener closes the open step as disposed', async () => {
it('disposal from a synchronous step/start session-event listener closes the open step as disposed', async () => {
const adapter = new MockAdapter([textResponse('should not stream')])
const ctx = new Context()
await ctx.plugin(LlmService)
@@ -244,9 +245,9 @@ describe('Agent.cancel()', () => {
let disposalDone: Promise<void> | undefined
let streamed = false
ctx.on('agent/stream-chunk', () => { streamed = true })
ctx.on('agent/step-start', (subject) => {
if (subject === agent) disposalDone = handle.dispose()
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
ctx.on('session/event', (session, event) => {
if (session === agent.session && event.type === 'step/start') disposalDone = handle.dispose()
})
send(agent, 'go')
@@ -271,16 +272,18 @@ describe('Agent.cancel()', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steps = 0
ctx.on('agent/step-start', () => { steps += 1 })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_session, event) => {
if (event.type === 'step/start') steps += 1
if (event.type === 'turn/end') reasons.push(event.data.reason)
})
let continued = false
ctx.on('agent/turn-continuation', async (subject, _turn, _default, next) => {
if (subject === agent && !continued) {
continued = true
agent.cancel('from continuation')
return true // vote to continue — the post-waterfall marker check must override
return { action: 'continue' as const } // vote to continue — the post-waterfall marker check must override
}
return next()
})
@@ -305,7 +308,7 @@ describe('Agent.cancel()', () => {
// runTurn. The second check (after the running flip) must drop the turn —
// runTurn would otherwise throw on the now-empty queue.
let streamed = false
ctx.on('agent/stream-chunk', () => { streamed = true })
ctx.on('session/event', (_s, event) => { if (event.type === 'assistant/chunk') streamed = true })
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') agent.cancel('from running listener')
})

View File

@@ -36,69 +36,6 @@ function send(agent: ReactLoopAgent, text: string) {
}
describe('turn boundary listener throws (handled in-turn, loop survives)', () => {
it('a throwing agent/turn-start listener surfaces via agent/error and the loop survives', async () => {
// The agent/turn-start emit happens AFTER turn/start is appended to the log,
// so a throwing listener is handled inside runTurn (the turn is balanced and
// closed via failTurn → agent/error), NOT rethrown to the runLoop backstop.
// The second turn should proceed normally and consume the first script entry.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-start', () => {
if (!threwOnce) {
threwOnce = true
throw new Error('broken turn-start listener')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['broken turn-start listener'])
// The turn is balanced: its turn/start was logged, so a turn/end was owed
// and appended (decided from the log, not a flag).
expect(agent.session.events.at(-1)?.type).toBe('turn/end')
// loop survives: second turn works fine and makes the model call
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(adapter.requests[0]!.messages.some(m => m.content.some(b => 'text' in b && b.text === 'second'))).toBe(true)
})
it('a throwing agent/turn-end listener surfaces via agent/error and the loop survives', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-end', () => {
if (!threwOnce) {
threwOnce = true
throw new Error('broken turn-end listener')
}
})
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
send(agent, 'first')
await waitForIdle(ctx, agent)
// The turn-end throw happens after the model call is complete, so turn 1's
// request is consumed. turn/end is already in the log (append pushes before
// notifying), so the turn is balanced; the error is surfaced via agent/error.
expect(errors.map(e => e.message)).toEqual(['broken turn-end listener'])
// loop survives: second turn works fine
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
})
it('a pre-push turn/start failure (non-serializable source) is rethrown to the runLoop backstop', async () => {
// A non-serializable message source makes the turn/start append throw BEFORE
// the event is pushed (Session.append validates before push), so turn/start
@@ -192,14 +129,14 @@ describe('tool JSON parse', () => {
})
describe('toError normalization', () => {
it('normalizes non-Error throws from turn-start listeners via toError', async () => {
it('normalizes non-Error throws from a turn/start session-event listener via toError', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-start', () => {
if (!threwOnce) {
ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start' && !threwOnce) {
threwOnce = true
throw 'naked string error' // non-Error throw, normalized via toError
}
@@ -287,7 +224,7 @@ describe('disposed vs aborted branching', () => {
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))

View File

@@ -0,0 +1,529 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools'
import AgentRegistry, {
AgentId,
type ContinuationDecision,
type PromptDecision,
type SessionStartSource,
} from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
/**
* The interception seams introduced by the hooks taxonomy: `agent/prompt-submit`,
* `agent/session-start`, the reshaped `agent/turn-continuation`
* ({@link ContinuationDecision}), and the `tools/pre-execute` / `tools/post-execute`
* split with `additionalContext` buffering. These verify the canonical event
* surface a hook bridge (or a native plugin) programs against, WITHOUT any
* external protocol — a native plugin uses the typed decisions directly.
*/
async function harness(adapter: MockAdapter) {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
dispose()
resolve()
}
})
})
}
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
describe('agent/prompt-submit', () => {
it('allow (default via next) records the user/message unchanged', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const seen: string[] = []
ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => {
seen.push(content.map(b => (b.type === 'text' ? b.text : '')).join(''))
return next()
})
send(agent, 'hello')
await waitForIdle(ctx, agent)
expect(seen).toEqual(['hello'])
const userMsg = events(agent).find(e => e.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'hello' }])
})
it('allow with content REWRITES the prompt before it is recorded', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] }))
send(agent, 'original')
await waitForIdle(ctx, agent)
const userMsg = events(agent).find(e => e.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data.content).toEqual([{ type: 'text', text: 'REWRITTEN' }])
// the rewritten prompt is what reached the model
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('REWRITTEN')
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('original')
})
it('allow with additionalContext injects a separate context/message into the turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
additionalContext: { content: [{ type: 'text', text: 'extra ctx' }], source: { kind: 'plugin', plugin: 'test' } },
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
const log = events(agent)
const userMsg = log.find(e => e.type === 'user/message')
const ctxMsg = log.find(e => e.type === 'context/message')
expect(userMsg).toBeDefined()
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content).toEqual([{ type: 'text', text: 'extra ctx' }])
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
// both the prompt and the injected context reach the model
const sent = JSON.stringify(adapter.requests[0]!.messages)
expect(sent).toContain('extra ctx')
})
it('a prompt-submit rewrite + additionalContext is VISIBLE to the agent/pre-step seam (merged ordering)', async () => {
// The merge of the interception seams with master's compaction seam pins one
// ordering: `agent/prompt-submit` runs (rewriting the prompt and injecting
// context) BEFORE the step loop, and `agent/pre-step` fires INSIDE the step
// before the single deriveMessages(). So a compaction listener on
// `agent/pre-step` must observe the surface AFTER the prompt rewrite/inject —
// otherwise it would measure/compact stale history. This cross-test proves
// the two seams compose in the right order (each is covered in isolation
// elsewhere; this asserts they see each other's effects on the same turn).
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({
kind: 'allow',
content: [{ type: 'text', text: 'REWRITTEN prompt' }],
additionalContext: { content: [{ type: 'text', text: 'injected ctx' }], source: { kind: 'plugin', plugin: 'test' } },
}))
// The pre-step seam (where compaction lives) derives the surface it would act
// on. Capture what it sees on the first step.
let preStepDerived: string | undefined
ctx.on('agent/pre-step', (subject, _turn, step) => {
if (subject === agent && step === 1) preStepDerived = JSON.stringify(subject.session.deriveMessages())
})
send(agent, 'ORIGINAL prompt')
await waitForIdle(ctx, agent)
// The pre-step seam ran and saw BOTH the rewrite (not the original) and the
// injected context — i.e. the prompt-submit effects landed before it.
expect(preStepDerived).toBeDefined()
expect(preStepDerived).toContain('REWRITTEN prompt')
expect(preStepDerived).toContain('injected ctx')
expect(preStepDerived).not.toContain('ORIGINAL prompt')
})
it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (): Promise<PromptDecision> =>
({ kind: 'block', reason: 'blocked by policy' }))
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'do something')
await waitForIdle(ctx, agent)
// the model was never called
expect(adapter.requests).toHaveLength(0)
// the turn opened and closed balanced, with no user/message and no step
const log = events(agent)
expect(log.some(e => e.type === 'turn/start')).toBe(true)
expect(log.some(e => e.type === 'turn/end')).toBe(true)
expect(log.some(e => e.type === 'user/message')).toBe(false)
expect(log.some(e => e.type === 'step/start')).toBe(false)
// the veto is recorded durably as a prompt/blocked in the open turn
const blocked = log.find(e => e.type === 'prompt/blocked')
expect(blocked?.type === 'prompt/blocked' && blocked.data).toMatchObject({
content: [{ type: 'text', text: 'do something' }],
reason: 'blocked by policy',
})
// ended rejected with the block reason
expect(reasons).toEqual([{ kind: 'rejected', reason: 'blocked by policy' }])
const turnEnd = log.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'rejected', reason: 'blocked by policy' })
})
it('a mixed batch records a prompt/blocked for the vetoed prompt while the allowed one runs', async () => {
// Two prompts queued into ONE turn: block "secret", allow "safe". The turn is
// NOT rejected (a prompt was allowed), so without a durable prompt/blocked the
// vetoed prompt and its reason would vanish from the log entirely.
const adapter = new MockAdapter([textResponse('ran once')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
return text === 'secret' ? { kind: 'block', reason: 'policy: no secrets' } : next()
})
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
// both sends land before the loop drains → one batched turn
send(agent, 'secret')
send(agent, 'safe')
await waitForIdle(ctx, agent)
const log = events(agent)
// the allowed prompt became a user/message and drove exactly one model call
const userMsgs = log.filter(e => e.type === 'user/message')
expect(userMsgs).toHaveLength(1)
expect(userMsgs[0]?.type === 'user/message' && userMsgs[0].data.content).toEqual([{ type: 'text', text: 'safe' }])
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
// the blocked prompt is durably recorded, with its content + reason
const blocked = log.filter(e => e.type === 'prompt/blocked')
expect(blocked).toHaveLength(1)
expect(blocked[0]?.type === 'prompt/blocked' && blocked[0].data).toMatchObject({
content: [{ type: 'text', text: 'secret' }],
reason: 'policy: no secrets',
})
// the turn did NOT reject — a sibling was allowed — so the boundary reason
// alone would not have preserved the block
expect(reasons.some(r => r.kind === 'rejected')).toBe(false)
})
it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => {
const adapter = new MockAdapter([textResponse('after')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threw = false
ctx.on('agent/prompt-submit', async () => {
if (!threw) { threw = true; throw new Error('prompt hook broke') }
return { kind: 'allow' as const }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
send(agent, 'first')
await waitForIdle(ctx, agent)
expect(errors.map(e => e.message)).toEqual(['prompt hook broke'])
// turn balanced
const log = events(agent)
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'turn/end')).toHaveLength(1)
// loop survives: a second prompt runs normally
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(adapter.requests.length).toBeGreaterThanOrEqual(1)
})
})
describe('agent/session-start', () => {
it('fires once with source "startup" for a fresh create, before the first turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const sources: SessionStartSource[] = []
ctx.on('agent/session-start', (_agent, source) => void sources.push(source))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// fires synchronously at create, before any turn
expect(sources).toEqual(['startup'])
expect(events(agent).some(e => e.type === 'turn/start')).toBe(false)
send(agent, 'go')
await waitForIdle(ctx, agent)
// still only one session-start
expect(sources).toEqual(['startup'])
})
it('a session-start listener can inject context the first request sees', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('agent/session-start', (agent) => {
agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } })
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
// the injected context reached the model on the first (only) request
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('session preamble')
// and is recorded with the plugin source, never mislabeled as a user prompt
const ctxMsg = events(agent).find(e => e.type === 'context/message')
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'test' })
})
it('a throwing session-start listener does not abort agent construction', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') })
// create must not throw — the listener error is contained/logged
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
expect(agent.id).toBe(AgentId('a1'))
// and the agent still runs
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
})
})
describe('agent/turn-continuation (ContinuationDecision)', () => {
it('a continue decision with a reason records next-step steering in the same turn', async () => {
const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let forced = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise<ContinuationDecision> => {
if (!forced) {
forced = true
return { action: 'continue', reason: { content: [{ type: 'text', text: 'keep going on the goal' }], source: { kind: 'plugin', plugin: 'goal' } } }
}
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const log = events(agent)
// same turn, two steps
expect(log.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(log.filter(e => e.type === 'step/start')).toHaveLength(2)
// the reason was recorded as steering BEFORE step 2, with its plugin source
const steering = log.find(e => e.type === 'steering/message')
expect(steering?.type === 'steering/message' && steering.data.content).toEqual([{ type: 'text', text: 'keep going on the goal' }])
expect(steering?.type === 'steering/message' && steering.data.source).toEqual({ kind: 'plugin', plugin: 'goal' })
// and reached the next request
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('keep going on the goal')
})
it('a stop decision ends the turn even when the step had tool calls', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' })])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => ({ action: 'stop' }))
send(agent, 'go')
await waitForIdle(ctx, agent)
// default would have continued (had tool calls), but the stop decision wins
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'tool/result')).toBe(true)
})
})
describe('tools/post-execute additionalContext buffering across a multi-call step', () => {
it('appends each call\'s additionalContext only AFTER all tool/results, preserving adjacency', async () => {
// One assistant step with TWO tool calls; the second model response stops.
const twoCalls = [
{ type: 'block-start' as const, index: 0, blockType: 'tool-call' as const },
{ type: 'block-end' as const, index: 0, block: { type: 'tool-call' as const, id: CallId('c1'), name: 'echo', arguments: '{"text":"a"}' } },
{ type: 'block-start' as const, index: 1, blockType: 'tool-call' as const },
{ type: 'block-end' as const, index: 1, block: { type: 'tool-call' as const, id: CallId('c2'), name: 'echo', arguments: '{"text":"b"}' } },
{ type: 'usage' as const, usage: { inputTokens: 5, outputTokens: 5 } },
{ type: 'finish' as const, reason: { kind: 'tool-calls' as const } },
]
const adapter = new MockAdapter([twoCalls, textResponse('done')])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Each call attaches additionalContext naming itself.
ctx.on('tools/post-execute', async (exec, _result): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: `ctx-${exec.callId}` }], source: { kind: 'plugin', plugin: 'p' } } }))
send(agent, 'go')
await waitForIdle(ctx, agent)
// Event order in the log: both tool/results, THEN both context/messages —
// never interleaved (which would break tool-call/result adjacency).
const types = events(agent).map(e => e.type)
const firstResult = types.indexOf('tool/result')
const lastResult = types.lastIndexOf('tool/result')
const firstCtx = types.indexOf('context/message')
expect(firstResult).toBeGreaterThanOrEqual(0)
expect(lastResult).toBeGreaterThan(firstResult) // two results
expect(firstCtx).toBeGreaterThan(lastResult) // context only after ALL results
// both contexts present
const ctxTexts = events(agent)
.filter(e => e.type === 'context/message')
.flatMap(e => (e.type === 'context/message' ? e.data.content : []))
.map(b => (b.type === 'text' ? b.text : ''))
expect(ctxTexts).toEqual(['ctx-c1', 'ctx-c2'])
})
})
describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end through the loop)', () => {
it('deny short-circuits dispatch into an isError result the model sees', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('ok')])
const ctx = await harness(adapter)
let ran = false
ctx.tools.register(defineTool({
name: 'danger', description: 'danger', parameters: {},
async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' }
return next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(ran).toBe(false)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result'
&& result.data.content.some(b => b.type === 'text' && b.text.includes('blocked dangerous tool'))).toBe(true)
})
})
describe('worked example: a native hook plugin is just a cordis plugin on the seams', () => {
// The whole point of the interception taxonomy: a "native hook" needs no
// dsh-hook-protocol, no external command, no hook/* log — it is an ordinary
// cordis plugin subscribing to the canonical events and returning typed
// decisions. This proves all four seams compose end-to-end through the REAL
// loop, with NO hook/* SessionEvents involved (those belong to the bridge lib).
const NativeGuard = {
name: 'native-guard',
apply(ctx: Context) {
// 1. SessionStart: seed a standing instruction.
ctx.on('agent/session-start', (agent, source) => {
agent.inject(
[{ type: 'text', text: `policy active (started: ${source})` }],
{ source: { kind: 'plugin', plugin: 'native-guard' } },
)
})
// 2. PromptSubmit: block a forbidden prompt, annotate the rest.
ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise<PromptDecision> => {
const text = content.map(b => (b.type === 'text' ? b.text : '')).join('')
if (text.includes('rm -rf')) return { kind: 'block', reason: 'destructive prompt blocked' }
return next()
})
// 3. PreToolUse: deny a dangerous tool by name.
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'danger') return { kind: 'deny', reason: 'danger tool denied' }
return next()
})
// 4. PostToolUse: attach context after a tool runs.
ctx.on('tools/post-execute', async (_exec, _result, next): Promise<PostToolDecision> => {
const decision = await next()
if (decision.kind === 'accept') {
return { kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'audited' }], source: { kind: 'plugin', plugin: 'native-guard' } } }
}
return decision
})
},
}
it('all four seams fire for a real allowed turn with a tool call', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { text: 'hi' }), textResponse('done')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
ctx.tools.register(defineTool({
name: 'echo', description: 'echo', parameters: { text: { type: 'string' } },
async execute(args) { return [{ type: 'text', text: String(args.text) }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'please echo hi')
await waitForIdle(ctx, agent)
const log = events(agent)
// session-start preamble injected
expect(log.some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('policy active (started: startup)')))).toBe(true)
// prompt allowed → user/message recorded
expect(log.some(e => e.type === 'user/message')).toBe(true)
// tool ran (echo allowed) and post-execute attached "audited" context
expect(log.some(e => e.type === 'tool/result' && !e.data.isError)).toBe(true)
expect(log.some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text === 'audited'))).toBe(true)
// NO hook/* events — a native plugin needs none
expect(log.some(e => e.type.startsWith('hook/'))).toBe(false)
})
it('the same plugin blocks a destructive prompt → rejected turn, model never called', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
await ctx.plugin(NativeGuard)
const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(0)
expect(reasons).toEqual([{ kind: 'rejected', reason: 'destructive prompt blocked' }])
})
it('HMR-safety: disposing the plugin fiber removes all four listeners', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const fiber = await ctx.plugin(NativeGuard)
await fiber.dispose()
// After disposal, a destructive prompt is NOT blocked (the listener is gone).
const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' })
send(agent, 'run rm -rf /')
await waitForIdle(ctx, agent)
// the prompt ran (not rejected) — proving the prompt-submit listener was disposed
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'user/message')).toBe(true)
})
})

View File

@@ -46,15 +46,20 @@ describe('agent loop', () => {
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// All boundaries — turn and step — are durable session events on the
// session/event feed (no agent/* mirror). Record them in fire order to
// assert the full boundary nesting.
const order: string[] = []
for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
ctx.on(name, () => void order.push(name))
}
ctx.on('session/event', (_session, event) => {
if (event.type === 'turn/start' || event.type === 'step/start' || event.type === 'step/end' || event.type === 'turn/end') {
order.push(event.type)
}
})
send(agent, 'hi')
await waitForIdle(ctx, agent)
expect(order).toEqual(['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'])
expect(order).toEqual(['turn/start', 'step/start', 'step/end', 'turn/end'])
const types = agent.session.events.map(e => e.type)
// turn/start opens the turn, THEN the queued user message is recorded inside
@@ -110,6 +115,32 @@ describe('agent loop', () => {
expect(types).toContain('tool/result')
})
it('threads a tool-attached meta (execute object return) onto the tool/result event', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'writer', { path: 'a.txt' }, 'writing'),
textResponse('done'),
])
const ctx = await harness(adapter)
// A tool that returns the { content, meta } object form: the loop must
// persist `meta` on the tool/result event so a UI reproduces the card on replay.
ctx.tools.register(defineTool({
name: 'writer',
description: 'writes a file',
parameters: { path: { type: 'string' } },
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } }
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
const toolResult = agent.session.events.find(e => e.type === 'tool/result')
expect(toolResult?.type === 'tool/result' && toolResult.data.meta)
.toEqual({ diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] })
})
it('passes assembled system prompt and tool schemas into the request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
@@ -132,21 +163,17 @@ describe('agent loop', () => {
expect(request!.tools?.map(t => t.name)).toEqual(['noop'])
})
it('records raw chunks for replay and emits agent/stream-chunk', async () => {
it('records raw chunks for replay as assistant/chunk session events', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const streamed: StreamChunk[] = []
ctx.on('agent/stream-chunk', (_agent, _turn, _step, chunk) => void streamed.push(chunk))
send(agent, 'hi')
await waitForIdle(ctx, agent)
const chunkEvents = agent.session.events.filter(e => e.type === 'assistant/chunk')
// textResponse('abc') = block-start + 3 deltas + block-end + usage + finish = 7
expect(chunkEvents).toHaveLength(7)
expect(streamed).toHaveLength(7)
// replay: chunk events alone re-assemble to the recorded assistant message
const deltaText = chunkEvents
.flatMap(e => e.type === 'assistant/chunk' ? [e.data.chunk] : [])
@@ -269,9 +296,9 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steps = 0
ctx.on('agent/step-end', () => void steps++)
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
if (steps < 3) return true
if (steps < 3) return { action: 'continue' as const }
return next()
})
@@ -294,7 +321,7 @@ describe('agent loop', () => {
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async () => false as const)
ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const)
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -430,7 +457,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
// wait until the stream is hanging, then cancel
@@ -450,7 +477,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -475,16 +502,16 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steps = 0
ctx.on('agent/step-end', () => void steps++)
ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ })
// Force exactly one continuation (step 1 → step 2), then defer to default
// (step 2 is a plain stop with no tool calls → stops).
ctx.on('agent/turn-continuation', async (_agent, _turn, _defaultDecision, next) => {
if (steps < 2) return true
if (steps < 2) return { action: 'continue' as const }
return next()
})
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -506,7 +533,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -539,7 +566,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -581,7 +608,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -600,7 +627,7 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -640,7 +667,7 @@ describe('agent loop', () => {
])
})
it('stops the turn when agent/step-end listener failure has recorded an error', async () => {
it('stops the turn when a step/end session-event listener failure has recorded an error', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('should not run'),
@@ -656,8 +683,11 @@ describe('agent loop', () => {
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threw = false
ctx.on('agent/step-end', () => {
if (!threw) { threw = true; throw new Error('bad step-end listener') }
// A throwing step/end session-event listener is the surviving boundary-listener
// failure path (step boundaries have no agent/* mirror): closeStep contains it
// and surfaces it as a turn error rather than stranding the turn open.
ctx.on('session/event', (_session, event) => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('bad step/end listener') }
})
send(agent, 'go')
@@ -674,13 +704,13 @@ describe('agent loop', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const turns: number[] = []
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
// queue two messages while idle — first starts turn 1 immediately;
// queue the second during turn 1 via a stream-chunk hook
// queue the second during turn 1 when the first assistant chunk streams
let queued = false
ctx.on('agent/stream-chunk', () => {
if (!queued) {
ctx.on('session/event', (_s, event) => {
if (event.type === 'assistant/chunk' && !queued) {
queued = true
send(agent, 'second message')
}
@@ -721,7 +751,7 @@ describe('agent loop', () => {
const errors: Error[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'hi')
await waitForIdle(ctx, agent)

View File

@@ -94,6 +94,36 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.fiber.dispose()
})
it('agent/session-start fires "startup" for createAgent and "resume" for resume()', async () => {
// Lifecycle 1: a fresh createAgent emits session-start with source 'startup'.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const sources1: string[] = []
ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source))
const a1 = ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') }).agent as ReactLoopAgent
expect(sources1).toEqual(['startup'])
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
// Lifecycle 2: resuming the persisted session emits session-start 'resume'.
const adapter2 = new MockAdapter([textResponse('b')])
const ctx2 = new Context()
await ctx2.plugin(LlmService)
await ctx2.plugin(SessionStore)
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const sources2: string[] = []
ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source))
await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') })
expect(sources2).toEqual(['resume'])
await ctx2.fiber.dispose()
})
it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => {
// Lifecycle 1: persist a FORKED session (carries parentSession + seedLength
// in its header) by creating it with a complete-turn seed — the write path

View File

@@ -1,10 +1,10 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
@@ -132,7 +132,7 @@ describe('HIGH: abort during tool execution ends the turn', () => {
}))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -144,36 +144,6 @@ describe('HIGH: abort during tool execution ends the turn', () => {
})
describe('HIGH: steering from late extension points is never stranded', () => {
it('steer() from an agent/step-end listener reaches the next request (/goal pattern)', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('after steering'),
])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute(args) {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/step-end', () => {
if (steeredOnce) return
steeredOnce = true
agent.steer([{ type: 'text', text: 'goal reminder from step-end' }])
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step-end')
})
it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => {
const adapter = new MockAdapter([
textResponse('no tools, would stop here'),
@@ -199,20 +169,69 @@ describe('HIGH: steering from late extension points is never stranded', () => {
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('one more thing')
})
it('steer() from an agent/turn-end listener becomes a queued message for the next turn', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
it('steer() from a step/end session-event listener forces a SAME-TURN next step (/goal pattern)', async () => {
// The /goal pattern steers from a step boundary so the model addresses a
// standing goal before stopping. Step boundaries have no agent/* mirror, so
// the surviving hook point is the durable step/end session event. With a
// no-tools first step the default continuation is stop; the steering queued
// here must force the `!shouldContinue && hasSteering` override so the SAME
// turn runs another step.
//
// The override is what this test guards, so it asserts the same-turn shape —
// NOT merely that the content reaches requests[1]. Without the override the
// turn would stop, and leftover steering is re-enqueued as a next-turn queued
// message, which ALSO lands in requests[1] (just one turn later). So a
// content-only assertion passes with the override disabled and guards
// nothing. The discriminator is the turn/step shape: override ⇒ ONE turn with
// TWO steps and the steering recorded as a `steering/message` BEFORE step 2;
// re-enqueue fallback ⇒ TWO turns.
const adapter = new MockAdapter([
textResponse('no tools, would stop'),
textResponse('after goal reminder'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-end', () => {
if (steeredOnce) return
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/end' || steeredOnce) return
steeredOnce = true
agent.steer([{ type: 'text', text: 'too late for this turn' }])
agent.steer([{ type: 'text', text: 'goal reminder from step/end' }])
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// Same-turn continuation: the steering forced step 2 within turn 1.
const events = [...agent.session.events]
expect(events.filter(e => e.type === 'turn/start')).toHaveLength(1)
expect(events.filter(e => e.type === 'step/start')).toHaveLength(2)
// The steered content is recorded as steering (same turn), BEFORE step 2 —
// not as a fresh turn's user/message. This is the mechanism the override uses.
const steeringIdx = events.findIndex(e => e.type === 'steering/message')
const step2Idx = events.map(e => e.type).lastIndexOf('step/start')
expect(steeringIdx).toBeGreaterThanOrEqual(0)
expect(steeringIdx).toBeLessThan(step2Idx)
// and it reached the next model request.
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('goal reminder from step/end')
})
it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => {
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const turns: number[] = []
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
let steeredOnce = false
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session) return
if (event.type === 'turn/start') turns.push(event.data.turn)
if (event.type === 'turn/end' && !steeredOnce) {
steeredOnce = true
agent.steer([{ type: 'text', text: 'too late for this turn' }])
}
})
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -253,12 +272,12 @@ describe('HIGH: plugin exceptions are contained', () => {
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
ctx.on('agent/turn-continuation', async (): Promise<ContinuationDecision> => {
if (!threwOnce) {
threwOnce = true
throw new Error('broken continuation plugin')
}
return false
return { action: 'stop' }
})
const errors: Error[] = []
@@ -314,7 +333,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const statuses: string[] = []
const reasons: TurnEndReason[] = []
ctx.on('agent/status', (_agent, status) => void statuses.push(status))
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -391,7 +410,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }])
})
it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
it('agent/queued carries the resolved source; steering/message records its source', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
@@ -406,15 +425,16 @@ describe('MEDIUM: misc registry and config fixes', () => {
}))
const queuedSources: { source: MessageSource; steering: boolean }[] = []
const steeringSources: MessageSource[] = []
ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info))
ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source))
send(agent, 'go') // no explicit source → default {kind:'user'} must be visible
await waitForIdle(ctx, agent)
expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false })
expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true })
// The drain appends the durable steering/message with the caller's source
// intact — the log, not a transient emit, is where consumers read it.
const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : [])
expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }])
})
})
@@ -443,7 +463,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
ctx2.effect(() => forked.start())
const turns: number[] = []
ctx2.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
ctx2.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) })
forked.send([{ type: 'text', text: 'continue' }])
await new Promise<void>((resolve) => {
ctx2.on('agent/status', (subject, status) => {
@@ -487,7 +507,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -512,7 +532,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -530,7 +550,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await waitForIdle(ctx, agent)
@@ -539,24 +559,26 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
})
})
describe('P1-6: step/start is appended before agent/step-start is emitted', () => {
it('a step-start listener sees the step/start event already in session.events', async () => {
describe('P1-6: a step/start session-event listener sees the event already in the log', () => {
it('the step/start event is in session.events when its session/event listener fires', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('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, the event-sourcing RFC).
// Session.append pushes the event BEFORE notifying session/event listeners,
// so a step/start listener always finds the matching event already in the
// log. (Step boundaries have no agent/* mirror — the session log is the live
// feed.)
const observed: { turn: number; step: number; lastEventType: string | undefined; sawStepStart: boolean }[] = []
ctx.on('agent/step-start', (subject, turn, step) => {
if (subject !== agent) return
const events = [...subject.session.events]
ctx.on('session/event', (subject, event) => {
if (subject !== agent.session || event.type !== 'step/start') return
const events = [...subject.events]
const last = events.at(-1)
observed.push({
turn,
step,
turn: event.data.turn,
step: event.data.step,
lastEventType: last?.type,
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === turn && e.data.step === step),
sawStepStart: events.some(e => e.type === 'step/start' && e.data.turn === event.data.turn && e.data.step === event.data.step),
})
})
@@ -599,35 +621,23 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
}
}
it('a throwing agent/turn-start listener still closes the turn with exactly one error and one turn/end, no step', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnstart'), { model: 'mock' })
let threw = false
ctx.on('agent/turn-start', () => { if (!threw) { threw = true; throw new Error('boom turn-start') } })
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// turn opened and closed; no step ran; exactly one error turn-end + emitted.
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 0, stepEnd: 0, errors: 1 })
expect(errors.map(e => e.message)).toEqual(['boom turn-start'])
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toEqual({ kind: 'error', step: 0, message: 'boom turn-start' })
// model was never called (we threw before the step's request).
expect(adapter.requests).toHaveLength(0)
})
it('a throwing agent/step-start listener closes the open step then the turn (step/end before turn/end)', async () => {
it('a throwing step/start session-event listener closes the open step then the turn (step/end before turn/end)', async () => {
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
// Step boundaries have no agent/* mirror; a throwing step/start session-event
// listener is the surviving step-boundary-listener failure. The loop marks
// the step open BEFORE appending step/start (Session.append pushes before
// notifying, so a post-push listener throw still leaves stepOpen=true), so
// the outer catch's closeStep() appends the balancing step/end — the turn
// stays enclosed. The invariants oracle (balancedHarness) rejects any
// imbalance, so a green run proves turn/start → step/start → step/end →
// turn/end nesting holds.
let threw = false
ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } })
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/start' && !threw) { threw = true; throw new Error('boom step-start') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
@@ -638,7 +648,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
const c = boundaryCounts(agent)
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
expect(errors.map(x => x.message)).toEqual(['boom step-start'])
// step/end must precede turn/end (the invariants oracle would reject
// step/end precedes turn/end (the invariants oracle would reject
// turn/end-while-step-open, but assert the order explicitly too).
const stepEndIdx = e.findIndex(x => x.type === 'step/end')
const turnEndIdx = e.findIndex(x => x.type === 'turn/end')
@@ -690,7 +700,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -707,46 +717,46 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
})
it('preserves reason disposed when the turn-end emit throws during disposal (outer-catch disposed branch)', async () => {
// Dispose mid-step → the step-error branch sets reason=disposed (no error
// reported). closeTurn(true) then emits agent/turn-end, whose listener
// throws → control reaches the outer catch with isDisposed() && !errorReported,
// which must PRESERVE disposed rather than overwrite it with the listener's
// throw. This is the only path that exercises that catch sub-branch.
const adapter = new MockAdapter(['hang'])
it('preserves reason disposed when a pre-step listener disposes then throws (outer-catch disposed branch)', async () => {
// Reach the OUTER catch while disposed: an `agent/pre-step` listener requests
// disposal AND throws. The throw escapes the pre-step `await` (line ~419) to
// the loop's outer catch — BEFORE the post-pre-step disposal check at ~422
// gets to run — so the catch sees `isDisposed() && !errorReported` and must
// PRESERVE reason=disposed rather than overwrite it with the listener's throw
// (disposal is not a failure). This is the surviving path to that sub-branch
// now that there is no turn-boundary emit to throw from.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await balancedHarness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' })
agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
// The FIRST agent/turn-end emit throws (the disposal-driven turn end).
let threw = false
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end during disposal') } })
// Collect agent/error emissions to prove none is surfaced through that
// channel either (the listener throw must be fully contained).
ctx.on('agent/pre-step', () => {
if (threw) return
threw = true
// Request disposal, then throw in the same synchronous tick: status flips
// to 'disposed' (the disposer aborts the step controller) and the throw
// drives control into the outer catch with isDisposed() already true.
void fiber.dispose()
throw new Error('boom pre-step during disposal')
})
const errorEmits: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
await fiber.dispose() // dispose during the hanging step
await agent.done
// The throwing turn-end listener actually fired — proving the outer-catch
// path was exercised, not skipped.
expect(threw).toBe(true)
const e = [...agent.session.events]
// Exactly one turn/start and one turn/end (balanced); the turn/end carries
// the disposed reason, NOT an error reason from the throwing listener.
// Balanced: one turn/start, one turn/end carrying disposed (NOT error).
expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1)
const turnEnd = e.findLast(x => x.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
// The throwing turn-end listener is contained: the turn/end carries the
// disposed reason (not an error) and no agent/error is emitted (disposal is
// not a failure; the throw is swallowed).
expect(e.some(x => x.type === 'turn/end' && x.data.reason.kind === 'error')).toBe(false)
// No step opened (the throw was before step/start) and disposal is not a
// failure, so no agent/error for the contained throw.
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(errorEmits).toHaveLength(0)
})
@@ -792,54 +802,20 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(adapter.requests).toHaveLength(1)
})
it('a throwing turn-end listener on a SUCCESSFUL turn leaves no event after turn/end (loadable log)', async () => {
// 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 (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)
const agent = ctx.agentLoop.create(AgentId('a-tend'), { model: 'mock' })
let threw = false
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
send(agent, 'go')
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
expect(c.turnEnd).toBe(1)
expect(c.errors).toBe(0) // NO session error event (it would be post-turn/end)
expect(agent.session.events.at(-1)?.type).toBe('turn/end') // last event is the boundary
expect(errors.map(e => e.message)).toEqual(['boom turn-end']) // surfaced via agent/error
// The late throw is also logged directly: failTurn's turn-already-ended
// branch warns so a throwing turn-end listener after turn/end never vanishes.
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/turn-end listener threw after turn 1 closed'))
// The whole log is loadable (nothing dropped): a fresh replay sees the turn.
const replay = new Session(SessionId('replay'), [...agent.session.events])
expect(replay.deriveMessages().map(m => m.role)).toEqual(['user', 'assistant'])
// loop survives.
send(agent, 'again')
await waitForIdle(ctx, agent)
expect(boundaryCounts(agent).turnEnd).toBe(2)
})
it('a throwing agent/step-end listener during a successful step ends the turn as error, not completed', async () => {
// closeStep() must surface a throwing step-end listener via failTurn so the
it('a throwing step/end session-event listener during a successful step ends the turn as error, not completed', async () => {
// closeStep() must surface a throwing step/end listener via failTurn so the
// turn ends with reason error, not a silent "completed" with the throw
// swallowed. Regression test for the closeStep() catch that previously
// swallowed the throw in the normal (no-tool, no-steering) path.
// swallowed the throw in the normal (no-tool, no-steering) path. (Step
// boundaries have no agent/* mirror; the session-event listener is the path.)
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' })
let threw = false
ctx.on('agent/step-end', () => { if (!threw) { threw = true; throw new Error('boom step-end') } })
ctx.on('session/event', (_s, event) => {
if (event.type === 'step/end' && !threw) { threw = true; throw new Error('boom step-end') }
})
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
@@ -869,52 +845,19 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(c2.stepStart).toBe(c2.stepEnd)
})
it('a step error followed by a throwing turn-end listener logs the error exactly once (no double-report)', async () => {
// The step fails (finish-error) → failTurn records ONE error and sets the
// error reason. closeTurn(true) then appends turn/end and emits
// agent/turn-end, whose listener throws → the outer catch calls failTurn
// again, but its errorReported guard makes it a no-op. Trap #1: exactly one
// error, the turn stays balanced.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-double'), { model: 'mock' })
let threw = false
ctx.on('agent/turn-end', () => { if (!threw) { threw = true; throw new Error('boom turn-end') } })
const errors: Error[] = []
ctx.on('agent/error', (_a, _t, _s, error) => void errors.push(error))
send(agent, 'go')
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// exactly one error turn-end + one agent/error emit, despite two failTurn calls.
expect(c.errors).toBe(1)
expect(errors.map(e => e.message)).toEqual(['provider down'])
expect(c.turnStart).toBe(1)
expect(c.turnEnd).toBe(1) // single turn/end, balanced
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider down' })
// loop survives the compound failure.
send(agent, 'again')
await waitForIdle(ctx, agent)
expect(boundaryCounts(agent).turnEnd).toBe(2)
})
it('a throwing session/event listener on step/end during finalization still appends turn/end', async () => {
// A throwing agent/step-start listener drives the outer catch, which calls
// closeStep() during finalization. closeStep appends step/end; a
// A finish-error stream opens a step then fails it, driving finalization
// through closeStep() with the step open. closeStep appends step/end; a
// session/event listener throwing on THAT must not abort the catch before
// closeTurn(false) — step/end is already logged (balance holds) and the
// throw is contained + surfaced via failTurn, so turn/end is still appended.
const adapter = new MockAdapter([textResponse('never reached')])
// closeTurn — step/end is already logged (balance holds) and the throw is
// contained + surfaced via failTurn, so turn/end is still appended. (The
// failed step itself also routes through failTurn; the step/end-listener
// throw is the second, contained, failure.)
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' })
// Open a step, then make the agent/step-start emit throw (boundary throw →
// outer catch → closeStep during finalization).
ctx.on('agent/step-start', () => { throw new Error('boom step-start') })
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'step/end') { threw = true; throw new Error('boom step/end listener') }
@@ -941,11 +884,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
it('a throwing session/event listener on turn/end is contained (turn still balanced, loop survives)', async () => {
// closeTurn appends turn/end; Session.append pushes it BEFORE notifying
// session/event listeners, so a throwing listener leaves turn/end in the log
// (the turn is balanced) but must not escape — from the normal-path
// closeTurn(true) it would otherwise propagate; the append is contained so
// the turn/end emit + loop continue. (A throwing agent/turn-end LISTENER is
// a separate, already-tested path; here the session/event append notify is
// what throws.)
// (the turn is balanced) but must not escape — from the normal-path closeTurn
// it would otherwise propagate; the append is contained so the loop continues.
// Turn boundaries are durable session events only (no agent/* mirror), so this
// session/event append-notify throw is the sole turn-end-listener failure path.
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
@@ -972,7 +914,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
})
describe('P1-7: tool/result is logged under the originating call.id, not result.callId', () => {
it('a tools/execute listener returning a mismatched callId cannot orphan the call↔result pairing', async () => {
it('the loop records tool/result under the model call.id even when a post-execute listener replaces content', async () => {
// Model emits a tool-call with id "c1", then a final text turn.
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { x: 1 }),
@@ -986,12 +928,13 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
async execute() { return [{ type: 'text', text: 'ok' }] },
}))
// A waterfall listener short-circuits with a result carrying the WRONG
// callId (a listener-internal/proxy id). The loop must still record the
// tool/result under the model's authoritative call.id.
ctx.on('tools/execute', (exec) => {
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false })
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
}, { prepend: true })
const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
@@ -1084,7 +1027,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
// Give the loop time to enter the step and reach assemble().
@@ -1110,10 +1053,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
// No step was opened, no LLM call was made.
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// agent/turn-end may not fire when disposal happens during assembly: the
// fiber's disposer (stop→status=disposed) runs before closeTurn(true)'s
// emit, and the LIFO chain disposes effects in reverse registration order.
// The turn/end durable record is the one that matters.
// The durable turn/end record is the authoritative turn-boundary signal
// (turn boundaries have no agent/* mirror), so this asserts on the log.
})
it('cancel during system-prompt assembly drops the about-to-start step as aborted', { timeout: 30000 }, async () => {
@@ -1142,7 +1083,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
@@ -1197,7 +1138,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 50))
@@ -1218,9 +1159,8 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'disposed' })
expect(e.some(x => x.type === 'step/start')).toBe(false)
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
// agent/turn-end may not fire when disposal happens during pre-step: the
// fiber's disposer runs before closeTurn(true)'s emit. The durable turn/end
// is the authoritative record.
// The durable turn/end record is the authoritative turn-boundary signal
// (turn boundaries have no agent/* mirror).
})
it('cancel during agent/pre-step seam ends the turn aborted', { timeout: 15000 }, async () => {
@@ -1250,7 +1190,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -1315,7 +1255,7 @@ describe('disposal/cancel honored during pre-step assembly (P1-1)', () => {
expect(e.some(x => x.type === 'assistant/chunk')).toBe(false)
expect(e.some(x => x.type === 'assistant/message')).toBe(false)
expect(adapter.requests).toHaveLength(0)
// The durable turn/end reason is the authoritative record; agent/turn-end
// may not fire when disposal interleaves with closeTurn(true)'s emit.
// The durable turn/end reason is the authoritative turn-boundary record
// (turn boundaries have no agent/* mirror).
})
})

View File

@@ -31,25 +31,31 @@ The full `agent/*` event taxonomy is declared via declaration merging in `dsh-ag
- `agent/created`, `agent/disposed` — registration/deregistration
- `agent/status` — idle / running / disposed transition
- `agent/queued` — message entered inbox (source-resolved, steering flag)
- `agent/session-start` — the session lifecycle began (once, before turn 1), carrying a `SessionStartSource` (`startup` for a fresh or forked create, `resume` for a reloaded persisted session; `clear`/`compact` reserved). A pure notification — it cannot block startup; a listener seeds context via `agent.inject()` (a `context/message` the first request sees).
#### Turn/step boundaries (emit)
#### Boundaries are durable session events, not `agent/*` emits
- `agent/turn-start`, `agent/turn-end` (carries `TurnEndReason`)
- `agent/step-start`, `agent/step-end`
Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that needs them reads the durable `turn/start`/`turn/end`/`step/start`/`step/end` events off the `session/event` feed (the session log is the live boundary feed, carrying the `Session` — the turn/step numbers and reasons ride on the event data). See [the event-domain-semantics RFC](../../../docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md) and [the remove-boundary-mirror-events RFC](../../../docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md).
#### Interception seams
`agent/pre-step` is a **serial** surface-mutation checkpoint; the rest are **waterfalls** that return a small, seam-specific typed **Decision** union (the unified idiom across the taxonomy — a CC/Codex bridge maps its `permissionDecision`/`decision`/`continue` fields onto these, a native plugin returns them directly):
- `agent/session-start` (emit) — fired once before the first turn; a listener seeds context via `agent.inject()` (it cannot veto startup).
- `agent/prompt-submit` — decide what happens to one drained queued message before it becomes a `user/message`: `PromptDecision` = `allow` (optionally rewriting the prompt `content` or attaching `additionalContext`) or `block` (drop it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`). Maps onto Claude Code's `UserPromptSubmit`.
- `agent/pre-step` (serial) — mutate the session surface before the step opens and history is derived (compaction). Fires after `turn/start` and before `step/start`, so a listener's appended events land outside the step.
- `agent/request` (waterfall) — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
- `agent/step-result` (waterfall) — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` (waterfall) — override the continue/stop decision (force-continue /loop, force-stop budget guard)
- `agent/request` — mutate `GenerateOptions` before the model call (hooks, model switching, tool filtering)
- `agent/step-result` — post-process the assembled assistant message before tool dispatch (validates what the log records)
- `agent/turn-continuation` — override the continue/stop decision via `ContinuationDecision` = `{action:'stop'}` or `{action:'continue', reason?}` (a `continue` `reason` is recorded as next-step steering in the same turn — the typed `/goal` pattern). Force-continue `/loop`, force-stop budget guard.
#### Streaming + tool (emit)
Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam.
#### Error notifications (emit)
- `agent/stream-chunk` — raw chunk from the model (token-level UI/log feed)
- `agent/steering` — steering content injected mid-turn
- `agent/error` — step/turn error
The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use).
### Agent interface (`types.ts`)
The handle every plugin programs against:

View File

@@ -126,6 +126,8 @@ export class AgentRegistry extends Service {
* Register the agent-creation factory (the loop calls this on construction,
* effect-scoped). Throws if a factory is already registered. Returns the
* disposer; on dispose the factory slot is cleared.
* @param factory - the loop-owned factory {@link create}/{@link resume} delegate to.
* @returns the disposer that clears the factory slot.
*/
setFactory(factory: AgentFactory): () => void {
const dispose = this.ctx.effect(() => {
@@ -142,6 +144,8 @@ export class AgentRegistry extends Service {
* agent): this constructs the agent and its session. Throws if no factory is
* registered. Returns an {@link AgentHandle} — the owner disposes it to tear
* down exactly this agent.
* @param options - agent id, session id/seed/metadata, and agent options.
* @returns the handle whose dispose tears down exactly this agent.
*/
create(options: CreateAgentOptions): AgentHandle {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -152,6 +156,8 @@ export class AgentRegistry extends Service {
* Load a persisted session and resume an agent on it through the registered
* factory. Rejects if no factory is registered; the factory rejects if
* session persistence is not configured. Returns an {@link AgentHandle}.
* @param options - the persisted session id plus agent id and options.
* @returns the handle for the resumed agent.
*/
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
@@ -162,6 +168,8 @@ export class AgentRegistry extends Service {
* Register a live agent. Throws if an agent with the same id is already
* registered. Emits `agent/created` on registration and `agent/disposed`
* when the calling fiber is disposed. Returns the disposer.
* @param agent - the already-constructed agent to record in the store.
* @returns the disposer that removes the agent and emits `agent/disposed`.
*/
register(agent: Agent): () => void {
const dispose = this.ctx.effect(function* (this: AgentRegistry) {
@@ -200,10 +208,19 @@ export class AgentRegistry extends Service {
return () => void dispose()
}
/**
* Look up a live agent.
* @param id - the agent id to look up.
* @returns the agent, or undefined when no live agent has that id.
*/
get(id: AgentId): Agent | undefined {
return this.store.get(id)
}
/**
* All live agents, in registration order.
* @returns a fresh array; mutating it does not affect the registry.
*/
list(): Agent[] {
return [...this.store.values()]
}

View File

@@ -6,11 +6,45 @@
* Merge-extensible: `AgentOptions` supports declaration merging for
* plugin-specific creation options.
*
* ## Event-domain semantics (the boundary rule)
*
* The harness has three event domains, each with one job:
*
* - **`session/*`** (`@deepseek-ai/dsh-session`) — the DURABLE, replayable FACT
* log. Owns `SessionEventMap`; every entry is JSON-only (no live objects).
* One `session/event` emit per append, plus the `session/flush` parallel
* durability checkpoint. Answers "what happened, durably/replayably." A
* consumer that wants the live transcript subscribes here.
* - **`agent/*`** (this module) — the LIVE runtime surface. Always carries the
* live `Agent`. Two shapes: INTERCEPTION seams (the `agent/prompt-submit`/
* `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and
* the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits
* (`agent/status`, `agent/error`, `agent/created`/
* `agent/disposed`, `agent/queued`, `agent/session-start`)
* that notify with the `Agent` in hand. Turn/step boundaries are NOT here —
* they are durable `session/event` records. Answers "right now, with the agent
* object — intercept or observe."
* - **`tools/*`** (`@deepseek-ai/dsh-tools`) — the tool registry + execution.
*
* **The rule:** a durable, replayable fact is a SessionEvent; a live
* interception or a transient/live-object signal is an `agent`/`tools` Cordis
* event. A turn/step boundary is a durable fact: it lives in the session log
* and is read off the `session/event` feed — it is NOT mirrored as an `agent/*`
* emit. A consumer that needs the `Agent` handle (or its short id) at a boundary
* keeps a session-id→agent map from `agent/created`/`agent/disposed`.
* See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md`
* and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`.
*
* The interception waterfalls here (`agent/prompt-submit`, `agent/request`,
* `agent/step-result`, `agent/turn-continuation`) each return a typed Decision —
* the convention pinned by
* `docs/rfc/implemented/feature/2026-06-30-interception-seams.md`.
*
* @module @deepseek-ai/dsh-agent/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, GenerateOptions, Message, MessageSource } from '@deepseek-ai/dsh-llm'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
@@ -19,7 +53,7 @@ export type AgentId = Branded<'AgentId'>
export function AgentId(id: string): AgentId {
return id as AgentId
}
import type { Session, TurnEndReason } from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
/**
* Options an agent is created with.
@@ -38,6 +72,68 @@ export interface SendOptions {
export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
* Model-facing context an interception listener wants the agent to SEE on the
* next request — the canonical shape behind every "inject extra context"
* decision ({@link PromptDecision}, {@link PostToolDecision},
* {@link ContinuationDecision}). It is `agent.inject()`ed as a
* `context/message`, so it carries a REQUIRED {@link MessageSource}: `inject()`
* defaults a missing source to `{kind:'user'}`, which would MISLABEL plugin
* context as a user prompt and corrupt derived history. A bridge sets
* `{kind:'plugin', plugin:'…'}`; a native plugin names itself. Required, not
* optional — the label is load-bearing, never defaulted here.
*/
export interface HookContext {
content: ContentBlock[]
source: MessageSource
}
/**
* The decision an {@link Agent} `agent/prompt-submit` waterfall listener returns
* for ONE drained queued message, before it becomes a `user/message`. Maps onto
* the Claude Code `UserPromptSubmit` hook's allow/block + `additionalContext`.
*
* - `allow` proceeds with the prompt; optional `content` REPLACES the prompt
* bytes (a rewrite), and optional `additionalContext` is `inject()`ed as a
* separate `context/message` the next request also sees.
* - `block` drops the prompt (it never becomes a `user/message`); `reason` is
* the durable record of why. The loop appends a `prompt/blocked` session event
* (carrying the original content, source, and `reason`) in place of the
* dropped `user/message`, so the veto survives replay even in a MIXED batch
* where a sibling prompt is allowed. A batch whose EVERY prompt is blocked
* additionally opens a zero-step turn that ends with {@link TurnEndReason}
* `rejected` (so the boundary stays balanced and a UI can render "blocked by
* hook").
*/
export type PromptDecision =
| { kind: 'allow'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; reason: string }
/**
* The decision an {@link Agent} `agent/turn-continuation` waterfall listener
* returns. The loop computes the default (`continue` when the step had tool
* calls or steering was injected, else `stop`); listeners override it to
* force-continue (`/goal`, `/loop`) or force-stop (budget guards).
*
* A `continue` may carry a `reason`: model-facing context recorded as next-STEP
* steering within the SAME turn (the loop enqueues it through the steering
* channel, so the continued turn's next step sees it). This is the typed twin of
* the existing "steer from a step/end listener" `/goal` pattern.
*/
export type ContinuationDecision =
| { action: 'stop' }
| { action: 'continue'; reason?: HookContext }
/**
* Why an agent's session lifecycle began, carried by `agent/session-start`. A
* bridge keys its SessionStart hook's matcher on this (Claude Code's
* `startup`/`resume`/`clear`/`compact` source set). `startup` = a fresh create
* (including a seeded/forked create — a seed is NOT a resume); `resume` = a
* persisted session reloaded via `ctx.agents.resume()`. `clear`/`compact` are
* driven by those subsystems (compact = `TODO(compaction)`).
*/
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
/**
* The agent handle — the surface every plugin (UI, hooks, orchestrators)
* programs against. The concrete implementation lives in
@@ -132,12 +228,14 @@ declare module 'cordis' {
/**
* An agent was registered in the {@link AgentRegistry} and is ready to
* receive messages.
* @param agent - the newly registered agent, already resolvable in the registry.
* @mode emit
*/
'agent/created'(agent: Agent): void
/**
* An agent was disposed and removed from the registry; its fiber and any
* in-flight turn have been torn down.
* @param agent - the agent that was torn down; its handle is now inert.
* @mode emit
*/
'agent/disposed'(agent: Agent): void
@@ -145,39 +243,41 @@ declare module 'cordis' {
* Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive
* lifecycle off this transition, never off a status you just requested —
* `send()` does not flip status to `running` before it returns.
* @param agent - the agent whose status flipped.
* @param status - the status just entered (the transition's destination).
* @mode emit
*/
'agent/status'(agent: Agent, status: AgentStatus): void
/**
* A message entered the agent's inbox (queued or steering). `source` is
* the resolved source (defaults applied), not the caller's raw options.
* @param agent - the agent whose inbox received the message.
* @param content - the enqueued content blocks, verbatim.
* @param info - the resolved source plus whether it entered as steering.
* @mode emit
*/
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
// ---- turn/step boundaries (emit) ----
// ---- session lifecycle (emit) ----
/**
* A turn began. `turn` is the 1-based turn number within the session.
* The agent's session lifecycle began, fired once before its first turn.
* `source` says why ({@link SessionStartSource}: fresh startup, a resumed
* persisted session, …). A pure NOTIFICATION (emit, not waterfall): it
* carries no veto — a session-start listener that wants to seed context does
* so via `agent.inject()` (a `context/message` the first request sees), not
* by returning a decision. Cannot block the session from starting; that gap
* is deliberate (a bridge logs/injects, it does not gate startup).
* @param agent - the agent whose session lifecycle began.
* @param source - why the session started (fresh startup, resume, …).
* @mode emit
*/
'agent/turn-start'(agent: Agent, turn: number): void
/**
* A turn ended. `reason` distinguishes a clean stop from a truncated or
* aborted one (`completed` | `aborted` | `error` | `disposed` | `max-tokens`).
* @mode emit
*/
'agent/turn-end'(agent: Agent, turn: number, reason: TurnEndReason): void
/**
* A step (one model call plus its tool dispatch) began. `step` is 1-based
* within the turn; a turn runs one or more steps.
* @mode emit
*/
'agent/step-start'(agent: Agent, turn: number, step: number): void
/**
* A step ended.
* @mode emit
*/
'agent/step-end'(agent: Agent, turn: number, step: number): void
'agent/session-start'(agent: Agent, source: SessionStartSource): void
// Turn and step boundaries are NOT mirrored as agent/* emits: a consumer
// that needs them reads the durable `turn/start`/`turn/end`/`step/start`/
// `step/end` session events off the `session/event` feed (the session log is
// the live transcript feed). See the module doc's three-domain rule and the
// "remove agent boundary mirror events" RFC.
// ---- step/request extension seams (serial + waterfall) ----
/**
@@ -204,6 +304,11 @@ declare module 'cordis' {
* listener needs to measure pressure (the system prompt counts toward the
* budget). `signal` cancels any in-flight work a listener starts (e.g. a
* summarization model call).
* @param agent - the agent about to open the step.
* @param turn - the already-open turn this step belongs to.
* @param step - the number of the step about to start.
* @param fullSystemPrompt - the assembled prompt, for measuring token pressure.
* @param signal - aborts in-flight listener work when the turn is torn down.
* @mode serial
*/
// TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction
@@ -212,43 +317,64 @@ declare module 'cordis' {
// prompt provider, or move token-pressure measurement behind a
// compaction-specific seam instead of the shared pre-step checkpoint.
'agent/pre-step'(agent: Agent, turn: number, step: number, fullSystemPrompt: string, signal: AbortSignal): Promise<void> | void
/**
* Waterfall: decide what happens to ONE drained queued message before it
* becomes a `user/message` — allow (optionally rewriting the prompt bytes or
* attaching `additionalContext`) or block it. Fires inside the already-open
* turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook.
* Call `next()` to delegate to the default (allow unchanged), or return a
* {@link PromptDecision} without calling `next()` to short-circuit.
* @param agent - the agent draining its inbox.
* @param content - the drained message's blocks, as queued.
* @param source - the message's resolved source.
* @mode waterfall
*/
'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision>
/**
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
* model call (hooks, model switching, tool filtering, …). Call `next()` to
* delegate, or return without it to short-circuit. For surface mutation that
* must precede history derivation (compaction), use {@link agent/pre-step}
* instead — by the time this fires, `options.messages` is already derived.
* @param agent - the agent making the model call.
* @param turn - the open turn number.
* @param step - the step whose request this is.
* @param options - the assembled request; listeners return a transformed copy.
* @mode waterfall
*/
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
/**
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, …).
* @param agent - the agent that received the step's response.
* @param turn - the open turn number.
* @param step - the step that produced the message.
* @param message - the assistant message as assembled from the stream.
* @mode waterfall
*/
'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message>
/**
* Waterfall: override the turn-continuation decision. The default
* (computed by the loop) is `hadToolCalls || steeringInjected`. Listeners
* can force-continue (/goal, /loop) or force-stop (budget guards).
* Waterfall: override the turn-continuation decision via a typed
* {@link ContinuationDecision}. The loop's `defaultDecision` is `continue`
* when the step had tool calls or steering was injected, else `stop`.
* Listeners force-continue (`/goal`, `/loop` — optionally attaching a
* `reason` recorded as next-step steering) or force-stop (budget guards).
* Call `next()` to delegate to the default, or return a decision to override.
* @param agent - the agent deciding whether to run another step.
* @param turn - the turn being continued or stopped.
* @param defaultDecision - what the loop would do absent an override.
* @mode waterfall
*/
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise<boolean>): Promise<boolean>
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision>
// ---- streaming + tool notifications (emit) ----
/**
* A raw {@link StreamChunk} arrived from the model (token-level UI/log feed).
* @mode emit
*/
'agent/stream-chunk'(agent: Agent, turn: number, step: number, chunk: StreamChunk): void
/**
* Steering content was injected into a running turn.
* @mode emit
*/
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
// ---- error notifications (emit) ----
/**
* A step or turn errored. The loop reports a failure here (plus the logger)
* even when the error has no in-turn position for a session `error` event.
* @param agent - the agent whose turn errored.
* @param turn - the turn in which the failure surfaced.
* @param step - the step at which the failure surfaced.
* @param error - the failure, verbatim.
* @mode emit
*/
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void

View File

@@ -4,17 +4,20 @@
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI.
* What a freshness diff CANNOT prove is that the generator REJECTS malformed
* source the way it promises to — a missing `@mode` tag, or a tag that
* contradicts the signature shape. These tests drive `collectEvents()` against
* synthetic fixture packages to prove each guard fires (and that a well-formed
* event passes), mirroring the drift-guard negative tests for verify-type-equiv.
* source the way it promises to — a missing `@mode` tag, a tag that
* contradicts the signature shape, or a JSDoc-completeness violation (missing
* prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an
* unannotated return type). These tests drive `collectEvents()` /
* `collectServices()` against synthetic fixture packages to prove each guard
* fires (and that well-formed declarations pass), mirroring the drift-guard
* negative tests for verify-type-equiv.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts'
import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts'
/** Write a fixture package exposing one `interface Events` block and return the
* scan root to hand `collectEvents`. */
@@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string {
return root
}
/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` →
* `FixService`) plus the class source, and return the scan root to hand
* `collectServices`. */
function serviceFixtureRoot(classSource: string): string {
const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-'))
const dir = join(root, 'packages', 'group', 'fix', 'src')
mkdirSync(dir, { recursive: true })
writeFileSync(
join(dir, 'index.ts'),
`declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`,
)
return root
}
const roots: string[] = []
const make = (block: string): string => {
const r = fixtureRoot(block)
roots.push(r)
return r
}
const makeService = (classSource: string): string => {
const r = serviceFixtureRoot(classSource)
roots.push(r)
return r
}
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
@@ -43,7 +65,7 @@ afterEach(() => {
describe('gen-cordis-catalog collectEvents', () => {
it('extracts a well-formed event with its @mode and JSDoc', () => {
const events = collectEvents(make(
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
@@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => {
it('classifies a trailing-next signature as a waterfall', () => {
const events = collectEvents(make(
' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>',
))
expect(events[0]?.mode).toBe('waterfall')
})
@@ -65,19 +87,154 @@ describe('gen-cordis-catalog collectEvents', () => {
it('hard-errors when an event is missing its @mode tag', () => {
expect(() => collectEvents(make(
' /** No mode here. */\n \'fix/untagged\'(id: string): void',
' /** No mode here. */\n \'fix/untagged\'(): void',
))).toThrow(/missing an @mode tag/)
})
it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => {
expect(() => collectEvents(make(
' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>',
))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/)
})
it('hard-errors when @mode waterfall has no trailing next to delegate to', () => {
expect(() => collectEvents(make(
' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
' /**\n * Not actually a waterfall.\n * @param id - which thing.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void',
))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/)
})
it('hard-errors on an undocumented payload parameter', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/is missing @param id/)
})
it('hard-errors on a stale @param naming no real parameter', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @param id - which thing.\n * @param ghost - not a parameter.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/@param ghost does not match any parameter/)
})
it('hard-errors on an @param with an empty description', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @param id\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/@param id has an empty description/)
})
it('hard-errors on an event whose JSDoc has no description prose', () => {
expect(() => collectEvents(make(
' /**\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void',
))).toThrow(/no description prose/)
})
it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => {
const events = collectEvents(make(
' /**\n * Scoped interception.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>',
))
expect(events).toHaveLength(1)
})
it('hard-errors on a binding-pattern parameter @param cannot name', () => {
expect(() => collectEvents(make(
' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/destructured\'({ id }: { id: string }): void',
))).toThrow(/is a binding pattern/)
})
it('aggregates every violation into one error instead of failing fast', () => {
expect(() => collectEvents(make(
' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void',
))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
})
})
describe('gen-cordis-catalog collectServices', () => {
const WELL_FORMED = `/** Fixture service. */
export class FixService {
/**
* Do the thing.
* @param id - which thing to do.
* @returns the outcome of doing it.
*/
run(id: string): string { return id }
/** Fire and forget (void needs no @returns). */
poke(): void {}
/** Flush (Promise<void> needs no @returns either). */
flush(): Promise<void> { return Promise.resolve() }
}`
it('extracts a well-formed service with its methods and class JSDoc', () => {
const services = collectServices(makeService(WELL_FORMED))
expect(services).toHaveLength(1)
expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' })
expect(services[0]?.methods).toHaveLength(3)
})
it('hard-errors on a public method with no JSDoc at all', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}',
))).toThrow(/ctx\.fix\.run .* has no JSDoc/)
})
it('hard-errors on an undocumented method parameter', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
))).toThrow(/ctx\.fix\.run .* is missing @param id/)
})
it('hard-errors on a missing @returns for a non-void return type', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string): string { return id }\n}',
))).toThrow(/is missing @returns \(return type: string\)/)
})
it('hard-errors on an unannotated (inferred) return type', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}',
))).toThrow(/no return type annotation/)
})
it('hard-errors on a service class with no JSDoc', () => {
expect(() => collectServices(makeService(
'export class FixService {\n /** Fire and forget. */\n poke(): void {}\n}',
))).toThrow(/class FixService has no JSDoc/)
})
it('hard-errors on a stale method @param', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param ghost - not a parameter.\n */\n poke(): void {}\n}',
))).toThrow(/@param ghost does not match any parameter/)
})
it('hard-errors on a method whose JSDoc is tags with no description prose', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * @param id - which thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}',
))).toThrow(/no description prose above its block tags/)
})
it('hard-errors on a method @param with an empty description', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param id\n */\n poke(id: string): void {}\n}',
))).toThrow(/@param id has an empty description/)
})
it('hard-errors on an @returns with an empty description', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n * @returns\n */\n run(id: string): string { return id }\n}',
))).toThrow(/@returns has an empty description/)
})
it('hard-errors on a binding-pattern method parameter @param cannot name', () => {
expect(() => collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n */\n run({ id }: { id: string }): void {}\n}',
))).toThrow(/is a binding pattern/)
})
it('ignores private/protected/static members (not the ctx.<key> surface)', () => {
const services = collectServices(makeService(
'/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}',
))
expect(services[0]?.methods).toHaveLength(0)
})
})

View File

@@ -49,9 +49,9 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
### Session event vocabulary (`types.ts`)
The append-only log: `turn/start`, `turn/end`, `step/start`, `step/end`, `user/message`, `assistant/message`, `assistant/chunk`, `tool/call`, `tool/result`, `steering/message`, `context/message`, `todo/write`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md). Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
Merge-extensible via `SessionEventMap` — the compaction seam adds `compact/start`, `compact/summary`, and `compact/end`.
Merge-extensible via `SessionEventMap`a plugin declaration-merges its own types (the compaction seam's `compact/*`, the hook bridges' `hook/*`); merged members appear in the same catalog.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings).

View File

@@ -16,6 +16,7 @@ import { SurfaceManager, isSurfaceEligibleType } from './surface.ts'
export * from './types.ts'
export { isJsonValue } from './json.ts'
export type { JsonValue } from './json.ts'
export { interruptedTurnClosers } from './repair.ts'
export type { SurfaceNode } from './surface.ts'
export { isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
@@ -29,12 +30,15 @@ declare module 'cordis' {
interface Events {
/**
* A session was created in the store.
* @param session - the session just entered and announced.
* @mode emit
*/
'session/created'(session: Session): void
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @mode emit
*/
'session/event'(session: Session, event: SessionEvent): void
@@ -44,6 +48,7 @@ declare module 'cordis' {
* plugins (JSONL, SQLite) drain their write-behind buffers here and on
* fiber dispose. Awaited (parallel), not a waterfall: every listener runs
* and the loop waits for all of them, but none can veto.
* @param session - the session whose buffered events must reach durable storage.
* @mode parallel
*/
'session/flush'(session: Session): Promise<void> | void
@@ -341,6 +346,9 @@ export class SessionStore extends Service {
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
* `startOwned`).
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @returns the live session, already entered and announced.
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path (storage backends key directories off it).
*/
@@ -366,6 +374,9 @@ export class SessionStore extends Service {
* chain rather than as racing sibling effects — which would detach `onAppend`
* before the loop's closing `session/flush`, dropping the closing events.
*
* @param id - the session id; omitted, the store mints `session-<n>`.
* @param options - seed events and/or creation metadata for the header.
* @returns the constructed session, NOT yet in the store.
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path.
*/
@@ -403,6 +414,8 @@ export class SessionStore extends Service {
* the two back-to-back so they never trip this, but the public seam cannot
* assume that.
*
* @param session - a {@link prepare}d session not yet in the store.
* @returns the detach disposer (`onAppend = undefined` + store removal).
* @throws if a session with this id is already in the store.
*/
enter(session: Session): () => void {
@@ -417,15 +430,25 @@ export class SessionStore extends Service {
/** Emit `session/created` for an {@link enter}ed session. Separate from
* {@link enter} so the caller can yield the detach disposer first (rollback
* safety — see {@link enter}). */
* safety — see {@link enter}).
* @param session - the entered session to announce to listeners. */
announce(session: Session): void {
this.ctx.emit('session/created', session)
}
/**
* Look up a live session.
* @param id - the session id to look up.
* @returns the session, or undefined when no live session has that id.
*/
get(id: SessionId): Session | undefined {
return this.store.get(id)
}
/**
* All live sessions, in creation order.
* @returns a fresh array; mutating it does not affect the store.
*/
list(): Session[] {
return [...this.store.values()]
}

View File

@@ -13,6 +13,16 @@
* @module @deepseek-ai/dsh-session/json
*/
/**
* A value that round-trips losslessly through JSON: `null`, a boolean, a finite
* number, a string, an array of such values, or a plain object whose values are
* such values. The static type companion to {@link isJsonValue} (which validates
* the same shape at runtime). Use it to type a payload that must survive
* session-log persistence and replay byte-identically — e.g. a tool's private
* presentation `meta`.
*/
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
/**
* Whether `value` is losslessly JSON-serializable: only `null`, finite numbers,
* booleans, strings, plain arrays, and plain objects of such values. Rejects

View File

@@ -91,7 +91,6 @@ export interface CreateSessionOptions {
*/
export interface TurnTriggerMap {
message: { kind: 'message'; source: MessageSource }
continuation: { kind: 'continuation' }
/**
* An out-of-band context injection (`agent.inject()`) made while the agent
* was idle. The loop wraps the injected `context/message` in a one-shot turn
@@ -134,6 +133,16 @@ export interface TurnEndReasonMap {
error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' }
'max-tokens': { kind: 'max-tokens' }
/**
* The turn's entire prompt batch was BLOCKED before any step ran — every
* drained queued message was vetoed by an `agent/prompt-submit` listener (a
* hook). The turn still opened (so the boundary stays balanced and the block
* is a durable in-turn fact), but ran zero steps. `reason` carries the block
* message from the vetoing decision. Distinct from `aborted` (a user-driven
* cancel) and `error` (a failure): the prompt was rejected by policy, not
* interrupted or broken. A UI renders it as "prompt blocked by hook".
*/
rejected: { kind: 'rejected'; reason: string }
/**
* The turn never ended on its own: the process crashed mid-turn and a
* persistence backend later closed the orphaned (open) turn on reload so the
@@ -188,12 +197,36 @@ export interface TodoItem {
* the invariants plugin checks, is a breaking change to the on-disk format.
*/
export interface SessionEventMap {
/**
* Opens turn `turn`. `trigger` records what started it — a drained message
* batch or an idle-time injection. The turn is the durability/replay
* boundary: every event sits between a `turn/start` and its matching
* `turn/end` (the turn-enclosure invariant).
*/
'turn/start': { turn: number; trigger: TurnTrigger }
/**
* Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop
* fires the awaited `session/flush` checkpoint at every turn end, so the turn
* boundary is also the durable-commit boundary.
*/
'turn/end': { turn: number; reason: TurnEndReason }
/** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */
'step/start': { turn: number; step: number }
/** Closes step `step` of turn `turn`. */
'step/end': { turn: number; step: number }
/** A user-visible prompt (queued message drained at turn start). */
'user/message': { content: ContentBlock[]; source: MessageSource }
/**
* A queued prompt an `agent/prompt-submit` listener VETOED — the durable
* record of a blocked prompt and why. Appended in place of the `user/message`
* the prompt would have become, so the block survives replay even in a MIXED
* batch where another queued prompt is allowed (there the turn does not end
* `rejected`, so the boundary reason alone would not preserve it). `content`
* is the original prompt the listener rejected; `reason` is the veto text
* ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a
* blocked prompt produces no LLM message and never reaches `deriveMessages()`.
*/
'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string }
/**
* In-session context injection (file-change notices, subdir AGENTS.md,
* skill content, cron notifications, …). Rendered into the derived history
@@ -209,8 +242,22 @@ export interface SessionEventMap {
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
/**
* The model requested one tool invocation: `name` with the raw `arguments`
* JSON string exactly as the model produced it (unparsed). `callId` pairs the
* call with its `tool/result`.
*/
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string } }
/**
* A completed tool call's model-facing result, plus an optional tool-private
* `meta` presentation payload. `meta` is opaque to the core (`unknown` — the
* producing tool owns its shape and reads it back in `presentResult`) but MUST
* be JSON-serializable: `Session.append` runtime-validates all event data with
* `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the
* durable log reproduces the identical card on replay. Absent unless the tool
* attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here).
*/
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown }
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
/**

View File

@@ -0,0 +1,225 @@
/**
* Negative-path tests for the persistence log catalog generator
* (`scripts/gen-persistence-catalog.ts`).
*
* The generated catalog is frozen by a regenerate-and-diff freshness gate, so
* the freshness half is exercised by `pnpm run verify-persistence-catalog` in
* CI. What a freshness diff CANNOT prove is that the generator REJECTS
* malformed source the way it promises to — a member without description
* prose, a forbidden `@mode` tag, a non-literal member name, a duplicate event
* declaration, a missing or ambiguous `SurfaceEventType` union, a stale union
* member. These tests drive the exported collectors against synthetic fixture
* packages to prove each guard fires (and that well-formed declarations pass),
* mirroring the gen-cordis-catalog negative tests.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import {
annotateSurface,
collectLogEvents,
collectSurfaceEventTypes,
render,
} from '../../../../scripts/gen-persistence-catalog.ts'
/** Create a fixture scan root; `files` maps `packages/…`-relative paths to source. */
function fixtureRoot(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'persistence-catalog-'))
for (const [rel, source] of Object.entries(files)) {
const abs = join(root, rel)
mkdirSync(join(abs, '..'), { recursive: true })
writeFileSync(abs, source)
}
return root
}
const roots: string[] = []
const make = (files: Record<string, string>): string => {
const r = fixtureRoot(files)
roots.push(r)
return r
}
/** A merge-form declaration file wrapping `members` in the session module. */
const merge = (members: string): string =>
`declare module '@deepseek-ai/dsh-session' {\n interface SessionEventMap {\n${members}\n }\n}\n`
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
/** The manifest that marks a fixture package as the owning session package. */
const OWNER_MANIFEST = '{ "name": "@deepseek-ai/dsh-session" }\n'
describe('gen-persistence-catalog collectLogEvents', () => {
it('extracts a documented member of the owning top-level interface', () => {
const events = collectLogEvents(make({
'packages/core/fix/package.json': OWNER_MANIFEST,
'packages/core/fix/src/types.ts':
'export interface SessionEventMap {\n /** A thing was recorded. */\n \'fix/happened\': { turn: number }\n}\n',
}))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({
name: 'fix/happened',
scope: 'fix',
doc: 'A thing was recorded.',
payload: '{ turn: number }',
source: 'packages/core/fix/src/types.ts:3',
})
})
it('hard-errors on a top-level interface outside the owning package', () => {
expect(() => collectLogEvents(make({
'packages/group/alien/package.json': '{ "name": "@deepseek-ai/dsh-alien" }\n',
'packages/group/alien/src/types.ts':
'export interface SessionEventMap {\n /** Not the real vocabulary. */\n \'alien/event\': { turn: number }\n}\n',
}))).toThrow(/top-level interface SessionEventMap .* is outside @deepseek-ai\/dsh-session \(package @deepseek-ai\/dsh-alien\)/)
})
it('hard-errors on a non-exported top-level interface even in the owning package', () => {
expect(() => collectLogEvents(make({
'packages/core/fix/package.json': OWNER_MANIFEST,
'packages/core/fix/src/helper.ts':
'interface SessionEventMap {\n /** A local helper, not the vocabulary. */\n \'fix/local\': { turn: number }\n}\nexport const use: SessionEventMap | null = null\n',
}))).toThrow(/is not exported; the owning vocabulary is the single exported declaration/)
})
it('hard-errors when the owning interface is exported from two files', () => {
expect(() => collectLogEvents(make({
'packages/core/fix/package.json': OWNER_MANIFEST,
'packages/core/fix/src/a.ts': 'export interface SessionEventMap {\n /** First home. */\n \'fix/a\': { turn: number }\n}\n',
'packages/core/fix/src/b.ts': 'export interface SessionEventMap {\n /** Second home. */\n \'fix/b\': { turn: number }\n}\n',
}))).toThrow(/is already declared at packages\/core\/fix\/src\/a\.ts:1; the owning vocabulary has exactly one home/)
})
it('hard-errors on an extends clause (inherited keys would escape the catalog)', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts':
'interface Extra { \'fix/hidden\': { turn: number } }\ndeclare module \'@deepseek-ai/dsh-session\' {\n interface SessionEventMap extends Extra {\n /** Declared directly. */\n \'fix/direct\': { turn: number }\n }\n}\n',
}))).toThrow(/uses extends; inherited keys would join keyof SessionEventMap without a catalog row/)
})
it('extracts a member declaration-merged via the session module', () => {
const events = collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /** Merged provenance. */\n \'fix/merged\': { id: string }'),
}))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ name: 'fix/merged', doc: 'Merged provenance.' })
})
it('collapses a newline-separated multi-line payload to a valid one-line fragment', () => {
const events = collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(
' /** Wide payload. */\n \'fix/wide\': {\n alpha: string[]\n range: { start: number; end: number }\n count: number\n }',
),
}))
expect(events[0]?.payload).toBe('{ alpha: string[]; range: { start: number; end: number }; count: number }')
})
it('hard-errors on a member with no description prose', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' \'fix/undocumented\': { turn: number }'),
}))).toThrow(/no description prose/)
})
it('hard-errors on an @mode tag (a log event has no dispatch mode)', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/tagged\': { turn: number }'),
}))).toThrow(/carries an @mode tag/)
})
it('hard-errors on an extra-indented @mode tag (does not leak into prose)', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /**\n * Documented, but mistagged.\n * @mode emit\n */\n \'fix/indented\': { turn: number }'),
}))).toThrow(/carries an @mode tag/)
})
it('hard-errors on a method-form member (it still joins keyof SessionEventMap)', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /** Documented, wrong shape. */\n \'fix/method\'(turn: number): void'),
}))).toThrow(/not a property signature with an explicit payload type/)
})
it('hard-errors on a property member with no payload type annotation', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /** Documented, no payload. */\n \'fix/bare\''),
}))).toThrow(/not a property signature with an explicit payload type/)
})
it('hard-errors on a non-literal member name', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' /** Not a literal. */\n unquoted: { turn: number }'),
}))).toThrow(/non-literal name/)
})
it('hard-errors when the same event is declared twice', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/a.ts': merge(' /** First. */\n \'fix/dup\': { turn: number }'),
'packages/group/fix/src/b.ts': merge(' /** Second. */\n \'fix/dup\': { turn: number }'),
}))).toThrow(/already declared at packages\/group\/fix\/src\/a\.ts/)
})
it('aggregates every violation into one error instead of failing fast', () => {
expect(() => collectLogEvents(make({
'packages/group/fix/src/types.ts': merge(' \'fix/one\': { turn: number }\n \'fix/two\': { turn: number }'),
}))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/)
})
})
describe('gen-persistence-catalog collectSurfaceEventTypes', () => {
it('parses the literal union', () => {
const types = collectSurfaceEventTypes(make({
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | \'fix/b\'\n',
}))
expect(types).toEqual(['fix/a', 'fix/b'])
})
it('hard-errors when no union is declared', () => {
expect(() => collectSurfaceEventTypes(make({
'packages/core/fix/src/types.ts': 'export const unrelated = 1\n',
}))).toThrow(/no SurfaceEventType union found/)
})
it('hard-errors when the union is declared more than once', () => {
expect(() => collectSurfaceEventTypes(make({
'packages/core/fix/src/a.ts': 'export type SurfaceEventType = \'fix/a\'\n',
'packages/core/fix/src/b.ts': 'export type SurfaceEventType = \'fix/b\'\n',
}))).toThrow(/declared more than once/)
})
it('hard-errors on a non-string-literal union member', () => {
expect(() => collectSurfaceEventTypes(make({
'packages/core/fix/src/types.ts': 'export type SurfaceEventType = \'fix/a\' | number\n',
}))).toThrow(/non-string-literal member/)
})
})
describe('gen-persistence-catalog annotateSurface + render', () => {
const entry = (name: string) => ({
name,
scope: name.split('/')[0] ?? name,
payload: '{ turn: number }',
doc: `Records ${name}.`,
source: 'packages/core/fix/src/types.ts:3',
})
it('badges union members surface and everything else log-only', () => {
const annotated = annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message'])
expect(annotated.map(e => [e.name, e.surface])).toEqual([['fix/message', true], ['fix/marker', false]])
})
it('hard-errors on a union member naming no declared event', () => {
expect(() => annotateSurface([entry('fix/marker')], ['fix/ghost']))
.toThrow(/'fix\/ghost' name no declared log event/)
})
it('renders badges, payload fences, and the generated-file header', () => {
const out = render(annotateSurface([entry('fix/message'), entry('fix/marker')], ['fix/message']))
expect(out).toContain('Generated by scripts/gen-persistence-catalog.ts')
expect(out).toContain('#### `fix/message` — surface')
expect(out).toContain('#### `fix/marker` — log-only')
expect(out).toContain('```ts persistence-catalog\n\'fix/marker\': { turn: number }\n```')
})
})

View File

@@ -19,6 +19,8 @@ declare module 'cordis' {
* Waterfall around prompt assembly — mutate or extend the
* {@link PromptAssembly} (sections + tool schemas) before it is rendered.
* Bound to the {@link SystemPrompt} service; call `next()` to delegate.
* @param assembly - the assembly built from the registered sections and
* tool providers; listeners may mutate it or return a replacement.
* @mode waterfall
*/
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
@@ -80,6 +82,8 @@ export class SystemPrompt extends Service {
* Contribute a text section to the system prompt. Order is determined by
* `section.order` (ascending). The section is removed when the calling
* fiber is disposed. Emits `system-prompt/change` on register/unregister.
* @param section - the section to contribute (name, order, text or provider).
* @returns the disposer that removes the section.
*/
section(section: PromptSection): () => void {
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
@@ -105,6 +109,8 @@ export class SystemPrompt extends Service {
* Contribute a tool-schema provider that is evaluated at each assembly
* call (so it can reflect the live registry state). The provider is
* removed when the calling fiber is disposed. Emits `system-prompt/change`.
* @param provider - evaluated at every {@link assemble} for fresh schemas.
* @returns the disposer that removes the provider.
*/
tools(provider: () => ToolSchema[]): () => void {
const dispose = this.ctx.effect(function* (this: SystemPrompt) {
@@ -132,6 +138,7 @@ export class SystemPrompt extends Service {
* listeners the opportunity to mutate or replace the assembly before it
* reaches the model. Await the result before reading the assembly values —
* waterfall listeners may be async.
* @returns the assembly after the waterfall has run.
*/
assemble(): Promise<PromptAssembly> {
const assembly: PromptAssembly = {

View File

@@ -1,6 +1,6 @@
# dsh-tools
Tool registry and execution waterfall. Tool plugins register their schemas and executors; the agent loop executes calls through the `tools/execute` waterfall.
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch → `tools/post-execute` (inspect/replace the result, attach context).
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -9,7 +9,7 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog/tools.md](../../../docs/tool-catalog/tools.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/execute` waterfall.
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute` → dispatch → `tools/post-execute` pipeline.
### Injected services
@@ -19,20 +19,23 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
| Event | Mode | Purpose |
|---|---|---|
| `tools/execute` | waterfall | Wrap/veto tool execution (sandbox, permission, hooks, plan mode) |
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
| `tools/change` | emit | A tool was registered or unregistered |
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), 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").
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. 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). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision` `{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolCallView` / `ToolResultView` — provider-neutral `card`-tagged render intents a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- The `tools/execute` waterfall is the single seam for sandbox, permission, hooks, and plan-mode plugins to wrap or veto a call. Listeners receive `(exec, next)`: call `next()` to proceed, or return a result without calling `next()` to short-circuit (veto).
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
@@ -70,12 +73,18 @@ See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, an
### 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:
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 that return a **`card`-tagged render intent** (a discriminated union — a tool declares its card kind once and a UI bridge switches on `card`):
- `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), an optional `locations` (`{ path, line? }[]` — the files this call reads/modifies, so a capable UI can follow along / jump to them; the ACP bridge forwards them as `tool_call.locations`), 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`.
- `presentCall(args): ToolCallView | undefined` — the PENDING state, one of:
- `{ card: 'generic', title, kind?, rawInput?, content?, locations? }` — the default card: a human-readable `title`, 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 background task id, NOT the whole args object), optional `content` (extra UI content blocks), and optional `locations` (`{ path, line? }[]` — files this call reads/modifies, so a capable UI can follow along; the ACP bridge forwards them as `tool_call.locations`).
- `{ card: 'terminal', title, description?, cwd? }` — a shell command: a capable UI renders a terminal card (the `title` is the command, `description` renders above it, `cwd` heads it); an incapable UI falls back to a generic execute card.
- `{ card: 'diff', title, diffs, locations? }` — a file create/modify: a capable UI renders an inline diff card from `diffs` (`{ path, oldText, newText }[]`; `oldText: null` for a new file). Used by `write`/`edit`.
- `presentResult(args, result): ToolResultView | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError, meta? }` result, one of:
- `{ card: 'generic', title?, content? }` — an optional replacement `title` and reformatted `content`.
- `{ card: 'terminal', title?, output?, exitCode?, signal? }` — a terminal run's captured `output` and exit status. A capable UI shows an exit-status pill; an incapable UI gets a fenced ` ```console ` fallback the BRIDGE derives from `output` (the tool does not encode the fences).
- `{ card: 'diff', title?, diffs }` — a completed file mutation as an inline diff. `diffs` is `FileDiff[]` — typically the applied hunks with surrounding context computed from the before/after content, or a whole-file diff (`oldText: null`) when there is no before-image (a file create). Used by `write`/`edit`; a `tool_call_update.content` replaces the call's content, so a mutation tool returns this even when it duplicates the call-time snippet (else the result text would clobber the pending diff).
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.
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. `result.meta` is the tool's own optional presentation payload (opaque `unknown`, JSON-serializable), attached by `execute` (see below) and persisted on the `tool/result` event, so a `presentResult` reading it stays replay-deterministic (the same `meta` is read back from the log). 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 views are provider-neutral — the ACP bridge (`dsh-acp`) maps each `card` to ACP `tool_call`/`tool_call_update` wire fields (a `diff` card to a `{ type: 'diff' }` content block, a `terminal` card to the `_meta` terminal convention), and relativizes a file card's title against the session cwd. See the render-intent-union RFC (`docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md`) and the applied-hunk-diffs RFC (`docs/rfc/implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md`); `dsh-tool-bash` (terminal) and `dsh-tool-fs` (diff/generic) are the reference implementations.
```ts
import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -90,13 +99,13 @@ const bash = defineTool({
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).
// A terminal card: the command is the title, the description renders above it.
presentCall: args => ({ card: 'terminal', title: args.command, description: args.description }),
// A terminal result: the raw output + exit; the bridge derives the fenced fallback.
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```' }] }
return { card: 'terminal', output: block.text }
},
})
```

View File

@@ -1,8 +1,9 @@
/**
* Tool registry and execution waterfall. Plugins register tools; the registry
* Tool registry and execution pipeline. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through the `tools/execute` waterfall for sandbox, permission, and hook
* plugins to wrap or veto.
* through `tools/pre-execute` (the allow/deny gate) → core dispatch →
* `tools/post-execute` (inspect/replace the result, attach context) for
* sandbox, permission, and hook plugins to gate or transform a call.
*
* @module @deepseek-ai/dsh-tools
*/
@@ -10,8 +11,9 @@
import { Context, Service } from 'cordis'
import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { Agent, HookContext } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type { ToolCallView, ToolResultView } from './presentation.ts'
export {
defineTool,
@@ -26,6 +28,23 @@ export {
type JsonSchemaObject,
} from './schema.ts'
// The render-intent vocabulary a tool declares via `presentCall`/`presentResult`
// lives in its own UI-facing module; re-export it so `@deepseek-ai/dsh-tools`
// stays the single public surface for consumers (producers + the ACP bridge).
export type {
ToolCallKind,
FileLocation,
FileDiff,
ToolCallView,
GenericCallView,
TerminalCallView,
DiffCallView,
ToolResultView,
GenericResultView,
TerminalResultView,
DiffResultView,
} from './presentation.ts'
declare module 'cordis' {
interface Context {
tools: ToolRegistry
@@ -33,14 +52,34 @@ declare module 'cordis' {
interface Events {
/**
* Waterfall around every tool execution — the single seam where sandbox,
* permission, hook, and plan-mode plugins wrap or veto a call. Listeners
* receive `(exec, next)`: call `next()` to proceed (possibly around your
* own logic), or return a {@link ToolExecutionResult} without calling
* `next()` to short-circuit (veto).
* Waterfall BEFORE a tool runs — the gate where sandbox, permission, and
* hook plugins allow or deny a call (Claude Code's `PreToolUse`). Listeners
* receive `(exec, next)`: call `next()` to delegate to the default (allow),
* or return a {@link PreToolDecision} without calling `next()` to
* short-circuit. A `deny` skips dispatch and yields an `isError` result; the
* tool body never runs. Input rewrite is deliberately NOT offered here (see
* {@link PreToolDecision}); `ask` degrades to deny until the permission
* system lands (`FIXME(permissions)`).
* @param exec - the pending call (name, parsed arguments, caller agent).
* @mode waterfall
*/
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
* `additionalContext` for the next request) or block it with corrective
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
* `(exec, result, next)`: call `next()` to delegate to the default (accept
* unchanged), or return a {@link PostToolDecision} to override. The core tool
* dispatch sits between the two waterfalls as plain code, all inside
* `execute`'s outer try/catch (and the tool body keeps its own inner
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
* result).
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
*/
'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision>
/**
* A tool was registered or unregistered (the available tool set changed).
* @mode emit
@@ -55,157 +94,37 @@ declare module 'cordis' {
// 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.
* What a tool's `execute` returns. The bare {@link ContentBlock}`[]` form is the
* common case (model-facing content only); the object form additionally attaches
* a tool-private `meta` presentation payload that the registry threads onto the
* `tool/result` session event and hands back to the tool's `presentResult`.
* `meta` is opaque to the core (`unknown` — the tool owns and narrows its shape),
* and MUST be JSON-serializable: it persists on the durable log (the session
* enforces this at `append`), so replay reproduces the card.
*/
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[]
/**
* Files this call reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. Provider-neutral
* `{ path, line? }` pairs; a UI bridge maps them to its own affordance (the ACP
* bridge forwards them as `tool_call.locations`). `path` is what the tool
* operated on (the model-facing path); `line` is an optional 1-based line to
* focus (e.g. a read's offset). Omit for a call that touches no file (e.g.
* `bash`).
*/
locations?: { path: string; line?: number }[]
/**
* 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
}
export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta?: unknown }
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* 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`.
* 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). Returns a {@link ToolCallView} (a `card`-tagged render intent),
* or `undefined` (or omit the method) 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
presentCall?(args: unknown): ToolCallView | 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.
* `result` (`execute`'s content + whether it errored). Returns a
* {@link ToolResultView}, or `undefined` (or omit the method) 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
presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined
}
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
@@ -214,9 +133,16 @@ export interface ToolResult {
content: ContentBlock[]
/** Whether the call failed. */
isError: boolean
/**
* The tool-private presentation payload the tool attached from `execute` (via
* the object return form), threaded verbatim from the `tool/result` event.
* Opaque (`unknown`); the tool narrows it back to its own shape. Absent when
* the tool attached none.
*/
meta?: unknown
}
/** One pending tool call, as it flows through the execution waterfall. */
/** One pending tool call, as it flows through the execution pipeline (`tools/pre-execute` → dispatch → `tools/post-execute`). */
export interface ToolExecution {
callId: CallId
name: string
@@ -257,8 +183,62 @@ export interface ToolExecutionResult {
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
/**
/**
* Extra model-facing context a `tools/post-execute` listener attached for the
* NEXT request (Claude Code's PostToolUse `additionalContext`). It is NOT part
* of this call's `content` — `content`/`feedback` shape the tool RESULT, but
* `additionalContext` is a SEPARATE `context/message`. A step can carry
* multiple tool calls, so the loop BUFFERS every call's `additionalContext`
* and appends them only AFTER all `tool/result`s for the step, keeping
* tool-call/result adjacency intact. Carried on the result purely to ferry it
* from `execute()` up to the loop's per-step buffer.
*/
additionalContext?: HookContext
/**
* The tool-private presentation payload from a successful `execute` (the object
* return form). Threaded onto the `tool/result` session event and back into
* {@link ToolResult} for `presentResult`. Opaque (`unknown`); absent when the
* tool attached none or the call failed.
*/
meta?: unknown
}
/**
* The decision a `tools/pre-execute` listener returns for one pending call.
* Maps onto Claude Code's `PreToolUse` `permissionDecision`.
*
* - `allow` proceeds to dispatch. (Input rewrite — changing `exec.arguments` —
* is deliberately NOT offered: `tool/call` and `assistant/message` are logged
* BEFORE execution and live consumers, e.g. the ACP bridge and `dsh-tool-bash`
* presentation, read the pre-execution arguments, so an execution-only rewrite
* would desync the UI from what RAN. That consistency redesign is its own
* `proposed` RFC; `TODO(pre-tool-input-rewrite)` anchors it at the call site.)
* - `deny` skips dispatch; the loop records an `isError` result carrying `reason`.
* - `ask` is the permission-prompt intent; until the permission system exists it
* degrades to `deny` (`FIXME(permissions)`).
*/
export type PreToolDecision =
| { kind: 'allow' }
| { kind: 'deny'; reason: string }
| { kind: 'ask'; reason?: string }
/**
* The decision a `tools/post-execute` listener returns for one finished call.
* Maps onto Claude Code's `PostToolUse` decision.
*
* - `accept` keeps the call successful; optional `content` REPLACES the
* model-facing result (clean: `tool/result` is logged AFTER `execute()`
* returns, so a replaced result is the single source of truth for both derived
* history and UI). Optional `additionalContext` rides to the next request.
* - `block` turns the call into an `isError` result whose content is the
* corrective `feedback` (the model is told the call was rejected and why),
* optionally also attaching `additionalContext`.
*/
export type PostToolDecision =
| { kind: 'accept'; content?: ContentBlock[]; additionalContext?: HookContext }
| { kind: 'block'; feedback: ContentBlock[]; additionalContext?: HookContext }
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
* instances use `.message`; non-Error objects with a string `message`
@@ -281,8 +261,9 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/execute` waterfall. The registry
* contributes its schemas into the system-prompt assembly.
* loop executes calls through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
@@ -299,6 +280,9 @@ export class ToolRegistry extends Service {
* registered. The tool's schema (minus the `execute` function) is
* automatically contributed to the system-prompt assembly. Disposed
* with the calling fiber. Emits `tools/change` on register/unregister.
* @param definition - the tool's schema plus its execute (and optional
* presentation) functions.
* @returns the disposer that unregisters the tool.
*/
register(definition: ToolDefinition): () => void {
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
@@ -322,55 +306,144 @@ export class ToolRegistry extends Service {
return () => void dispose()
}
/**
* Look up a registered tool.
* @param name - the tool name as registered.
* @returns the definition, or undefined when no tool has that name.
*/
get(name: string): ToolDefinition | undefined {
return this.store.get(name)
}
/**
* 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.
* (`name`, `description`, `parameters`), 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.
* @returns one deep-cloned schema per registered tool, in registration order.
*/
schemas(): ToolSchema[] {
return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({
return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({
name,
description,
parameters: structuredClone(parameters),
...strict !== undefined ? { strict } : {},
}))
}
/**
* Execute one tool call through the `tools/execute` waterfall. If the tool is
* not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. If the tool or a waterfall listener throws, the error is
* caught and returned as an `isError` result so the loop records a failed tool
* call instead of failing the whole turn; a thrown {@link HarnessError}
* Execute one tool call through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
* and the inspect/transform seam; core dispatch sits between them as plain
* code. The whole thing is wrapped in one outer try/catch so a throwing
* listener (in either waterfall) becomes an `isError` result instead of
* failing the turn; the tool body ALSO keeps its own inner try/catch, so a
* thrown tool becomes an `isError` result that `post-execute` listeners can
* still inspect. If the tool is not registered, the result is an `isError`
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
* surfaces its `{ name, code }` on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after both waterfalls; failures resolve as
* `isError` results, never rejections.
*/
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)
// --- Gate: tools/pre-execute. A deny (or an ask, which degrades to deny
// until the permission system lands) skips dispatch entirely. ---
const decision = await this.ctx.waterfall(
this, 'tools/pre-execute', exec,
() => Promise.resolve<PreToolDecision>({ kind: 'allow' }),
)
if (decision.kind !== 'allow') {
// deny → isError. ask has no permission UI yet, so degrade to deny
// (FIXME(permissions)): a forthcoming permission system turns `ask` into
// a real prompt; today it is the conservative "not allowed".
const reason = decision.kind === 'deny'
? decision.reason
: decision.reason ?? `tool "${exec.name}" requires approval (not yet supported)`
const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${reason}` }],
isError: true,
}
})
return await this.postExecute(exec, denied)
}
// --- Core dispatch (plain code between the waterfalls). The tool body's
// own try/catch turns a throw into an isError result so post-execute can
// inspect it; an unknown tool routes through the same catch. ---
let result: ToolExecutionResult
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
result = toolErrorResult(exec.callId, error)
}
return await this.postExecute(exec, result)
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener (or the waterfall
// machinery) becomes an isError result, never a turn failure.
return toolErrorResult(exec.callId, error)
}
}
/**
* Run the `tools/post-execute` waterfall over a dispatched `result` and apply
* its {@link PostToolDecision}: `accept` keeps the call successful (replacing
* `content` when given), `block` turns it into an `isError` whose content is
* the corrective `feedback`. Either decision may attach `additionalContext`,
* which is ferried on the returned result for the loop's per-step buffer.
* Runs inside `execute`'s outer try/catch (a throwing listener → isError).
*/
private async postExecute(exec: ToolExecution, result: ToolExecutionResult): Promise<ToolExecutionResult> {
// Snapshot the protected outcome BEFORE the waterfall. A listener receives
// the same `result` reference, so a post-waterfall read of `result.callId`/
// `.isError`/`.error` could carry a listener's mutation — violating the
// authoritative-call-id requirement and the "preserve the dispatched
// isError/error" contract. The decision is the ONLY sanctioned channel for a
// listener to change the outcome (block, or accept-with-replacement); the
// call id is always the authoritative `exec.callId`. `content` is copied into
// a fresh array so a listener's in-place `push`/`splice` on `result.content`
// cannot leak into the returned content either (the elements are the same
// references — the snapshot guards the array structure, not deep immutability).
const dispatched = {
callId: exec.callId,
content: [...result.content],
isError: result.isError,
...result.error ? { error: result.error } : {},
...result.meta !== undefined ? { meta: result.meta } : {},
}
const decision = await this.ctx.waterfall(
this, 'tools/post-execute', exec, result,
() => Promise.resolve<PostToolDecision>({ kind: 'accept' }),
)
const additionalContext = decision.additionalContext
if (decision.kind === 'block') {
return {
callId: dispatched.callId,
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
}
}
// accept: replace content if supplied, preserve the dispatched isError/error.
return {
...dispatched,
...decision.content ? { content: decision.content } : {},
...additionalContext ? { additionalContext } : {},
}
}
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {

View File

@@ -0,0 +1,206 @@
/**
* Tool render-intent vocabulary: the provider-neutral types a tool declares via
* `ToolDefinition.presentCall`/`ToolDefinition.presentResult` to say
* how ONE of its calls renders in a UI (an editor's tool-call card, a CLI log
* line). A UI bridge switches on the `card` tag to map each intent to its own
* wire shape, so a UI never special-cases tool names.
*
* This is the UI-facing surface of `dsh-tools`, kept separate from the registry
* and execution core in `index.ts`: this module owns ONLY presentation
* vocabulary and references none of the execution types, so the dependency runs
* one way (`index.ts` imports these views for the `ToolDefinition` method
* signatures). The opaque `meta` presentation channel is execution plumbing and
* lives with the registry in `index.ts`, not here.
*
* See the render-intent-union RFC
* (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
*
* @module @deepseek-ai/dsh-tools/src/presentation
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
/**
* 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'
/**
* A file location a tool reads or modifies, so a capable UI can "follow along" —
* highlight or jump to the file (and line) as the tool runs. Provider-neutral;
* a UI bridge maps it to its own affordance (the ACP bridge forwards it as
* `tool_call.locations`). `path` is what the tool operated on (the model-facing
* path); `line` is an optional 1-based line to focus (e.g. a read's offset).
*/
export interface FileLocation {
path: string
line?: number
}
/**
* A single-file change a tool is about to make, for a UI that renders inline
* diffs (an editor's diff card). Provider-neutral; the ACP bridge forwards it as
* a `{ type: 'diff' }` tool-call content block. `oldText` is `null` for a
* new-file create (nothing to diff against); an overwrite also uses `null`,
* because a call-time presenter has no access to the file's prior content.
*/
export interface FileDiff {
path: string
/** Prior content, or `null` for a new file / an overwrite (no prior content available at call time). */
oldText: string | null
/** Content after the change. */
newText: string
}
/**
* 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. A `card`-tagged
* discriminated union: a tool declares its render INTENT once and a UI bridge
* switches on `card` to map it to the bridge's own wire shape. Provider-neutral —
* the tool owns its presentation, so a UI never special-cases tool names.
*
* Returned by `ToolDefinition.presentCall`. See the render-intent-union
* RFC (docs/rfc/implemented/architecture/2026-07-02-tool-render-intent-union.md).
*/
export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView
/**
* The default card: a titled tool-call row with an optional category icon, a
* salient raw input, extra content blocks, and follow-along file locations. Any
* tool whose call is not a terminal or a diff uses this.
*/
export interface GenericCallView {
card: 'generic'
/**
* Human-readable, always-visible label describing what THIS call does. Keep it
* short — a UI shows it as a card header / log line.
*/
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. a background
* task id). Omit to show nothing; a string renders 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 blocks to show on the pending call alongside the title.
* Omit to show none. A UI maps these to its own content blocks.
*/
content?: ContentBlock[]
/** Files this call reads/modifies, for editor follow-along. Omit for a call that touches no file. */
locations?: FileLocation[]
}
/**
* A call that IS a shell command running in a working directory: a capable UI
* renders it as a terminal card (cwd-headed, with the command as the title and
* live/afterward output from the {@link TerminalResultView}); an incapable UI
* falls back to a generic card whose body is the fenced command output. Set by a
* tool whose call is a foreground command (e.g. `bash`).
*/
export interface TerminalCallView {
card: 'terminal'
/** The command, shown as the terminal card's title / header line. */
title: string
/**
* A human-readable one-line summary of what the command does, rendered ABOVE
* the terminal card (the card itself has no description slot). Omit for none.
*/
description?: string
/**
* Working directory the command runs 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 presenter can't see the session cwd).
* Omit entirely to let the bridge use the session workspace.
*/
cwd?: string
}
/**
* A call that creates or modifies files, rendered as an inline diff card by a
* capable UI. Set by a tool whose call writes/edits a file (e.g. `write`,
* `edit`). The diffs are derived from the call ARGUMENTS (a create's `oldText` is
* `null`); the tool emits a separate {@link DiffResultView} after `execute` — the
* applied change (an edit/overwrite hunk with context, or a whole-file diff for a
* create).
*/
export interface DiffCallView {
card: 'diff'
/** Card header (e.g. `Write foo.txt`). */
title: string
/** One entry per file the call changes. */
diffs: FileDiff[]
/** Files this call modifies, for editor follow-along (usually the diffs' paths). */
locations?: FileLocation[]
}
/**
* How a tool wants the COMPLETED call shown — the *result* state, after `execute`
* returns. A `card`-tagged union mirroring {@link ToolCallView}: a UI switches on
* `card`. Lets the tool reformat its result for a UI distinctly from the
* model-facing text it returned from `execute`. Returned by
* `ToolDefinition.presentResult`; omitting the method keeps the pending
* title and renders the raw result content.
*/
export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView
/**
* The default completed card: an optional replacement title and reformatted
* content. Omit a field to keep the pending title / render the raw result content.
*/
export interface GenericResultView {
card: 'generic'
/** Replacement title for the completed call. 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.
*/
content?: ContentBlock[]
}
/**
* The completed state of a {@link TerminalCallView}: the captured output and exit
* status. A capable UI renders `output` in the terminal card and shows an
* exit-status pill; an incapable UI gets a fenced ```console fallback the BRIDGE
* derives from `output` (the tool does not double-encode it).
*/
export interface TerminalResultView {
card: 'terminal'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** Captured command output (stdout+stderr as the tool chooses to combine them). */
output?: string
/**
* Process exit code, when the run ended by exiting (not a signal). Lets a
* capable UI show an exit-status pill. Omit when killed by a signal or unknown.
*/
exitCode?: number
/** Signal name that killed the process (e.g. `SIGTERM`). Mutually exclusive with `exitCode`. */
signal?: string
}
/**
* A completed file mutation rendered as an inline diff card, the *result-time*
* analogue of {@link DiffCallView}. Set by a tool whose `execute` applied a file
* change (e.g. `write`, `edit`): `diffs` are the change to show — typically the
* APPLIED hunks computed from the before/after content (one entry per hunk, each
* with surrounding context lines), so the editor shows the real change in place;
* a tool with no before-image (e.g. a file create) may instead give a whole-file
* diff (`oldText: null`). A `tool_call_update`'s content REPLACES the call's
* content in an editor, so a mutation tool returns this even when it duplicates
* the call-time snippet — otherwise the model-facing result text would replace
* (clobber) the pending diff card.
*/
export interface DiffResultView {
card: 'diff'
/** Replacement title for the completed call. Omit to keep the pending-state title. */
title?: string
/** The change to show, in file order — applied contextual hunks, or a whole-file diff when there is no before-image. */
diffs: FileDiff[]
}

View File

@@ -19,9 +19,9 @@
* @module dsh-tools/schema
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts'
import type { ToolDefinition, ToolExecuteReturn, ToolExecution, ToolResult } from './index.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
// ---------------------------------------------------------------------------
// SchemaSpec — the author-facing per-property type
@@ -182,7 +182,7 @@ export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
/**
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
* (`code: 'INVALID_ARGS'`); the registry's execute waterfall catches it and
* (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and
* returns an `isError` ToolExecutionResult carrying the structured error, so
* the model can self-correct and downstream plugins can route on the code.
*/
@@ -291,27 +291,27 @@ export interface DefineToolOptions<S extends SchemaSpec> {
parameters: S
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed.
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
* content only) or a `{ content, meta }` object to also attach a tool-private
* presentation payload (see {@link ToolExecuteReturn}).
*/
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* 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}.
* {@link ToolCallView}.
*/
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
presentCall?(args: InferArgs<S>): ToolCallView | 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}.
* free for the same replay reason. See {@link ToolResultView}.
*/
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
/** Whether the tool requires structured output (default false). */
strict?: boolean
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
}
/**
@@ -353,8 +353,7 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...options.strict !== undefined ? { strict: options.strict } : {},
async execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the
@@ -369,13 +368,13 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
// 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 => {
tool.presentCall = (args: unknown): ToolCallView | 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 => {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result)
}

View File

@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {
@@ -93,25 +93,17 @@ describe('gen-tool-catalog render', () => {
{
pkg: '@deepseek-ai/dsh-tool-demo',
source: 'packages/demo/tool-demo/src/index.ts',
requires: ['ctx.tools'],
writes: ['tool/result'],
schemas: [{ name: 'demo', description: 'A demo tool.', parameters: { type: 'object', properties: {} } }],
},
]
const md = render(catalog)
expect(md).toContain('| `@deepseek-ai/dsh-tool-demo` | `demo` | `ctx.tools` | `tool/result` |')
expect(md).toContain('## `@deepseek-ai/dsh-tool-demo`')
expect(md).toContain('### `demo`')
expect(md).toContain('A demo tool.')
expect(md).toContain('```json')
expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]')
})
it('renders the strict flag when a schema sets it', () => {
const catalog: ToolCatalog = [
{
pkg: '@deepseek-ai/dsh-tool-demo',
source: 'packages/demo/tool-demo/src/index.ts',
schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }],
},
]
expect(render(catalog)).toContain('Strict: `true`')
})
})

View File

@@ -4,7 +4,7 @@ import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type ToolExecutionResult,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -52,8 +52,8 @@ describe('ToolRegistry', () => {
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 }),
presentCall: args => ({ card: 'generic', title: args.x }),
presentResult: (args, result) => ({ card: 'generic', 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'])
@@ -62,18 +62,6 @@ describe('ToolRegistry', () => {
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)
@@ -81,6 +69,38 @@ describe('ToolRegistry', () => {
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'meta-tool',
async execute() {
return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] } }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
})
})
it('omits meta when the object return form supplies none', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'no-meta-tool',
async execute() {
return { content: [{ type: 'text', text: 'ok' }] }
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
it('returns isError results for unknown tools and throwing tools', async () => {
const ctx = await setup()
ctx.tools.register({
@@ -112,53 +132,150 @@ describe('ToolRegistry', () => {
expect(err.message).toBe('unknown tool "ghost"')
})
it('lets tools/execute waterfall listeners veto a call (permission pattern)', async () => {
it('lets a tools/pre-execute listener deny a call (permission pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
if (exec.name === 'echo') {
return {
callId: exec.callId,
content: [{ type: 'text', text: 'denied by policy' }],
isError: true,
}
}
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
if (exec.name === 'echo') return { kind: 'deny', reason: 'denied by policy' }
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'denied by policy' })
expect(result.content[0]).toMatchObject({ text: 'Error: denied by policy' })
})
it('composes multiple tools/execute listeners (sandbox-wrap pattern)', async () => {
it('an ask decision degrades to deny until the permission system lands', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> =>
({ kind: 'ask', reason: 'needs approval' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: needs approval' })
})
it('an ask decision with no reason degrades to deny with a default message', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'ask' }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: tool "echo" requires approval (not yet supported)' })
})
it('a tools/post-execute listener can replace the result content (accept) ', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', content: [{ type: 'text', text: 'rewritten' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(false)
expect(result.content[0]).toMatchObject({ text: 'rewritten' })
})
it('a tools/post-execute block turns the call into an isError with corrective feedback', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'block', feedback: [{ type: 'text', text: 'output rejected: try again' }] }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'output rejected: try again' })
})
it('a block decision can ALSO attach additionalContext', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({
kind: 'block',
feedback: [{ type: 'text', text: 'rejected' }],
additionalContext: { content: [{ type: 'text', text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } },
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'rejected' })
expect(result.additionalContext).toMatchObject({ content: [{ text: 'why it was rejected' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute additionalContext rides on the result for the loop to buffer', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, _result, _next): Promise<PostToolDecision> =>
({ kind: 'accept', additionalContext: { content: [{ type: 'text', text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.additionalContext).toMatchObject({ content: [{ text: 'fyi' }], source: { kind: 'plugin', plugin: 'test' } })
})
it('a post-execute listener mutating the result object cannot corrupt callId/isError/error', async () => {
// The decision is the ONLY sanctioned channel to change the outcome. A
// listener that reaches in and mutates the passed result reference (flipping
// isError, rewriting callId, attaching a bogus error) must NOT affect what
// execute() returns — the registry snapshots the authoritative fields before
// the waterfall and rebuilds from the snapshot + decision.
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/post-execute', async (_exec, result, next) => {
const mutable = result as { callId: string; isError: boolean; error?: unknown; content: unknown[] }
mutable.callId = 'hijacked'
mutable.isError = true
mutable.error = { name: 'Evil', code: 'EVIL' }
mutable.content.push({ type: 'text', text: 'INJECTED' }) // in-place array mutation
return next() // delegate to the default accept — no decision-level override
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.callId).toBe(CallId('c1')) // authoritative exec.callId, not 'hijacked'
expect(result.isError).toBe(false) // the real (successful) dispatch outcome
expect(result.error).toBeUndefined() // no listener-injected error
expect(result.content).toHaveLength(1) // the in-place push did not leak in
expect(result.content[0]).toMatchObject({ text: 'hi' })
expect(result.content.some(b => (b as { text?: string }).text === 'INJECTED')).toBe(false)
})
it('composes pre + post waterfalls around dispatch (sandbox-wrap pattern)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const order: string[] = []
ctx.on('tools/execute', async (_exec, next) => {
order.push('first:before')
const result = await next()
order.push('first:after')
return result
ctx.on('tools/pre-execute', async (_exec, next) => {
order.push('pre:before')
const decision = await next()
order.push('pre:after')
return decision
})
ctx.on('tools/execute', async (_exec, next) => {
order.push('second:before')
const result = await next()
order.push('second:after')
return result
ctx.on('tools/post-execute', async (_exec, _result, next) => {
order.push('post:before')
const decision = await next()
order.push('post:after')
return decision
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'x' } })
expect(result.isError).toBe(false)
expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
// pre runs fully (gate) before dispatch, then post runs over the result.
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
})
it('returns an isError result when a tools/execute listener throws', async () => {
it('returns an isError result when a tools/pre-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
ctx.on('tools/pre-execute', async () => {
throw new Error('permission hook broke')
})
@@ -171,10 +288,26 @@ describe('ToolRegistry', () => {
})
})
it('preserves structured error info when a tools/execute listener throws HarnessError', async () => {
it('returns an isError result when a tools/post-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
ctx.on('tools/post-execute', async () => {
throw new Error('post hook broke')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: post hook broke' }],
isError: true,
})
})
it('preserves structured error info when a tools/pre-execute listener throws HarnessError', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/pre-execute', async () => {
throw new HarnessError('denied', 'DENIED')
})
@@ -466,44 +599,6 @@ describe('schema DSL edge cases', () => {
})
})
it('defineTool passes through strict flag when set to true', () => {
const tool = defineTool({
name: 'strict-tool',
description: 'A strict tool',
parameters: { input: { type: 'string' } },
strict: true,
async execute(args) {
return [{ type: 'text' as const, text: args.input ?? '' }]
},
})
expect(tool.strict).toBe(true)
})
it('defineTool omits strict when not provided', () => {
const tool = defineTool({
name: 'non-strict-tool',
description: 'A non-strict tool',
parameters: { input: { type: 'string' } },
async execute(args) {
return [{ type: 'text' as const, text: args.input ?? '' }]
},
})
expect('strict' in tool).toBe(false)
})
it('defineTool strict=false is included', () => {
const tool = defineTool({
name: 'explicitly-non-strict',
description: 'Explicitly non-strict',
parameters: { input: { type: 'string' } },
strict: false,
async execute(args) {
return [{ type: 'text' as const, text: args.input ?? '' }]
},
})
expect(tool.strict).toBe(false)
})
it('handles enum and default together in one property', () => {
const spec = {
level: { type: 'string', enum: ['low', 'high'], default: 'low' },
@@ -906,15 +1001,15 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
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 }
return { card: 'generic', title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
},
presentResult(args, result) {
return { title: `Opened ${args.path}`, content: result.content }
return { card: 'generic', title: `Opened ${args.path}`, content: result.content }
},
})
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ card: 'generic', 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' }] })
.toEqual({ card: 'generic', title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
@@ -934,8 +1029,8 @@ describe('defineTool presentation (presentCall / presentResult)', () => {
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 }),
presentCall: args => ({ card: 'generic', title: args.path }),
presentResult: (args, result) => ({ card: 'generic', 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

View File

@@ -19,4 +19,4 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
## Role
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI implementations such as `@deepseek-ai/dsh-ui-stdio` provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.

View File

@@ -74,7 +74,12 @@ export class UserInteractionService extends Service {
super(ctx, 'userInteraction')
}
/** Register the UI provider. Only one provider may be active in a context. */
/**
* Register the UI provider. Only one provider may be active in a context.
*
* @param provider UI-side implementation that collects answers.
* @returns Disposer that unregisters this provider.
*/
registerProvider(provider: UserInteractionProvider): () => void {
const dispose = this.ctx.effect(function* (this: UserInteractionService) {
if (this.provider !== undefined) {
@@ -88,7 +93,12 @@ export class UserInteractionService extends Service {
return () => void dispose()
}
/** Ask the active UI provider and wait for the user's answer. */
/**
* Ask the active UI provider and wait for the user's answer.
*
* @param request Question, options, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs-local
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the six `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
The **local-filesystem implementation** of the `ctx.fs` provider seam ([`@deepseek-ai/dsh-fs`](../fs)). Backs the seven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`.
```ts ignore-check
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
@@ -15,6 +15,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`resolve(path, opts?)`** — a relative `path` resolves against `opts.cwd` when the caller supplies one (the model-facing tools pass the calling agent's session cwd — see [the per-session cwd RFC](../../../docs/rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)), else `config.cwd` (default `process.cwd()`); an absolute `path` ignores both. The `targetKey` is the file's `realpath`, so two input paths reaching the same file through symlinks share one identity, and writes/edits land on the link target (preserving the link). A not-yet-existing path uses the realpathed parent directory plus basename when the parent exists; only an unresolvable parent falls back to the absolute path. `displayPath` is the absolute (un-resolved) path.
- **`stat`** — returns `FsInfo` (`version` = `mtimeMs:size`, `type` of `file`/`directory`/`other`, byte `size`) or `undefined` when the target is absent.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).

View File

@@ -20,15 +20,12 @@
import { randomUUID } from 'node:crypto'
import { createReadStream } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, rename, rm, stat } from 'node:fs/promises'
import type { Stats } from 'node:fs'
import { chmod, mkdir, open, readFile, realpath, readdir, rename, rm, stat } from 'node:fs/promises'
import type { Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
/** Files at or above this size stream their text; smaller files read whole. */
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
const BINARY_SAMPLE_BYTES = 8192
function isENOENT(error: unknown): boolean {
@@ -55,6 +52,10 @@ function errorMessage(error: unknown): string {
}
/* v8 ignore stop */
function isPermissionError(error: unknown): boolean {
return error instanceof Error && 'code' in error && (error.code === 'EACCES' || error.code === 'EPERM')
}
function throwIfAborted(signal: AbortSignal | undefined, verb: string): void {
if (signal?.aborted) throw new FsError(`${verb} aborted`, 'FS_ABORTED')
}
@@ -81,13 +82,11 @@ function versionOf(info: Stats): FsVersion {
}
/**
* Test seam: lets specs force the streaming read path (via a small
* `streamMinSize`) and pin the temp-file name (to prove exclusive-open
* behavior) without a 10 MB fixture or a name race.
* Test seam: lets specs pin the atomic-write temp names (to prove
* exclusive-open behavior without a name race) and observe the staged temp
* file before it is renamed over the target.
*/
export interface FsIoInternals {
/** Override {@link STREAM_MIN_SIZE} for read routing. */
streamMinSize?: number
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
@@ -112,6 +111,15 @@ export interface PathInfo {
size: number
}
/** One local directory child with a resolved target and cheap metadata. */
export interface LocalDirEntry {
name: string
type: 'file' | 'directory' | 'other'
target: LocalTarget
version?: FsVersion
size?: number
}
/**
* Resolve a path to its absolute display path and realpath identity. Relative
* paths are based on `cwd`. When the file itself does not yet exist, the
@@ -172,6 +180,68 @@ export async function probe(absolutePath: string): Promise<PathInfo | null> {
}
}
// --- Directory listing ---
function listingIoError(displayPath: string, error: unknown): FsError {
/* v8 ignore next -- defensive pass-through for races where a child resolver has already produced a structured FsError. */
if (error instanceof FsError) return error
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
async function resolveListedChildTarget(parent: LocalTarget, name: string): Promise<LocalTarget> {
const identity = await resolveLocalTarget(parent.targetKey, name)
return { displayPath: join(parent.displayPath, name), targetKey: identity.targetKey }
}
/**
* List direct children of a directory in stable name order. Each child includes
* a resolved target plus stat metadata when still available; file contents are
* never read.
*/
export async function listDirectory(target: LocalTarget, signal?: AbortSignal): Promise<LocalDirEntry[]> {
throwIfAborted(signal, 'list')
let info: PathInfo | null
try {
info = await probe(target.targetKey)
} catch (error: unknown) {
throw listingIoError(target.displayPath, error)
}
if (!info) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
let entries: Dirent[]
try {
entries = await readdir(target.targetKey, { withFileTypes: true, encoding: 'utf8' })
} catch (error: unknown) {
/* v8 ignore next -- requires permission/kernel failure from readdir after a successful directory stat. */
throw listingIoError(target.displayPath, error)
}
throwIfAborted(signal, 'list')
const result: LocalDirEntry[] = []
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
throwIfAborted(signal, 'list')
try {
const childTarget = await resolveListedChildTarget(target, entry.name)
const childInfo = await probe(childTarget.targetKey)
result.push({
name: entry.name,
type: childInfo?.type ?? 'other',
target: childTarget,
...(childInfo ? { version: childInfo.version } : {}),
...(childInfo?.type === 'file' ? { size: childInfo.size } : {}),
})
} catch (error: unknown) {
throw listingIoError(join(target.displayPath, entry.name), error)
}
throwIfAborted(signal, 'list')
}
return result
}
// --- Reading ---
function notTextError(verb: 'read' | 'edit', displayPath: string): FsError {
@@ -382,6 +452,26 @@ export async function readForEdit(
return { content: normalizeLineEndings(raw), lineEndings: detectLineEndings(raw) }
}
/**
* Best-effort read of a file's current text for a before/after diff basis, used
* by an overwrite. Returns the LF-normalized decoded content, or `null` when the
* file is binary or not valid UTF-8 — a write must succeed regardless of the
* prior bytes, so an undiffable prior file simply yields no contextual-hunk basis
* (the caller treats `null` the same as an absent file: the result renders a
* whole-file diff rather than an applied hunk).
*/
export async function readTextForDiff(absolutePath: string, signal?: AbortSignal): Promise<string | null> {
const buffer = await readFileAbortable(absolutePath, 'read', signal)
if (buffer.includes(0)) return null
try {
return normalizeLineEndings(new TextDecoder('utf-8', { fatal: true }).decode(buffer))
} catch (error: unknown) {
/* v8 ignore next 2 -- TextDecoder({fatal}) only throws TypeError on invalid bytes; any other throw is an unreachable runtime fault. */
if (!(error instanceof TypeError)) throw error
return null
}
}
/**
* Apply a literal replacement to LF-normalized content. Throws
* `FS_EDIT_NOT_FOUND` on empty `oldString` or zero matches and
@@ -410,4 +500,4 @@ export function applyLiteralEdit(
return { content: content.split(oldNorm).join(newNorm), replacements }
}
export { restoreLineEndings }
export { normalizeLineEndings, restoreLineEndings }

View File

@@ -1,6 +1,6 @@
/**
* Local-filesystem implementation of the `ctx.fs` provider seam.
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the six
* {@link LocalFileSystem} subclasses {@link FileSystem} and backs the seven
* text-storage primitives with the host filesystem via
* {@link module:@deepseek-ai/dsh-fs-local/fsio}. Path resolution uses
* `realpath`, so the stable `targetKey` is the real file identity (two input
@@ -17,6 +17,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -26,8 +27,11 @@ import type {
} from '@deepseek-ai/dsh-fs'
import {
applyLiteralEdit,
listDirectory,
normalizeLineEndings,
probe,
readForEdit,
readTextForDiff,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
@@ -37,17 +41,18 @@ import {
import type { FsIoInternals } from './fsio.ts'
export {
STREAM_MIN_SIZE,
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readTextForDiff,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalTarget, PathInfo } from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
/** Configuration for the local filesystem backend. */
export interface Config {
@@ -99,7 +104,7 @@ export class LocalFileSystem extends FileSystem {
override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> {
const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path)
return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath }
return { targetKey: local.targetKey, displayPath: local.displayPath }
}
override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
@@ -117,6 +122,17 @@ export class LocalFileSystem extends FileSystem {
return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal))
}
override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)
return entries.map(entry => ({
name: entry.name,
type: entry.type,
target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath },
...(entry.version !== undefined ? { version: entry.version } : {}),
...(entry.size !== undefined ? { size: entry.size } : {}),
}))
}
override async writeText(
target: FsTarget,
content: string,
@@ -143,11 +159,25 @@ export class LocalFileSystem extends FileSystem {
// provider) — no version guard, no read-first requirement. Still atomic
// (the per-target lock is unconditional), so the write is never torn.
// Capture the prior text (the before/after diff basis) BEFORE the write.
// `null` for a create (no existing file) OR an existing-but-undiffable
// file (binary/invalid-UTF-8) — a null `before` gives no contextual-hunk
// basis, so a consumer falls back to a whole-file diff (the tool still
// renders a result-time diff card, not the raw result text).
// TODO(overwrite-diff-bound): this reads the whole prior file into memory
// for a UI-only diff; bound the pre-read and fall back to no contextual
// basis above a size threshold (see the applied-hunk-diffs RFC non-goals).
const before = existing ? await readTextForDiff(target.targetKey, signal) : null
await writeFileAtomic(target.targetKey, content, existing?.mode, signal, this.internals)
const after = await probe(target.targetKey)
return {
operation: existing ? 'update' : 'create',
version: this.versionAfterWrite(after, target),
before,
// LF-normalized to share the diff basis with `before` (also LF): a CRLF
// overwrite must not read as every line changed. Line-ending restoration
// is a storage detail the applied-hunk diff ignores.
after: normalizeLineEndings(content),
}
})
}
@@ -180,9 +210,11 @@ export class LocalFileSystem extends FileSystem {
const after = await probe(target.targetKey)
return {
replacements: edited.replacements,
replaceAll: edit.replaceAll,
version: this.versionAfterWrite(after, target),
// The LF-normalized before/after text (the applied-hunk diff basis);
// line-ending restoration is a storage detail the diff ignores.
before: original.content,
after: edited.content,
}
})
}

View File

@@ -7,7 +7,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile, unlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
@@ -118,6 +118,50 @@ describe('readText / streamText', () => {
})
})
describe('listDir', () => {
it('lists files and directories in stable name order with resolved child targets', async () => {
await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true })
await writeFile(join(dir, 'skills', 'zeta.md'), 'zeta')
await writeFile(join(dir, 'skills', 'alpha.md'), 'alpha')
await symlink(join(dir, 'skills', 'missing-target'), join(dir, 'skills', 'broken-link'))
const entries = await fs.listDir(await fs.resolve('skills'))
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
['alpha.md', 'file'],
['broken-link', 'other'],
['dir-skill', 'directory'],
['zeta.md', 'file'],
])
expect(entries.map(entry => entry.target.displayPath)).toEqual([
join(dir, 'skills', 'alpha.md'),
join(dir, 'skills', 'broken-link'),
join(dir, 'skills', 'dir-skill'),
join(dir, 'skills', 'zeta.md'),
])
const materializedEntries = entries.filter(entry => entry.version !== undefined)
expect(materializedEntries.map(entry => entry.target.targetKey))
.toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath))))
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
})
it('reports a missing directory as FS_NOT_FOUND', async () => {
await expect(fs.listDir(await fs.resolve('missing'))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('reports a file target as FS_NOT_DIRECTORY', async () => {
await writeFile(join(dir, 'a.txt'), 'text')
await expect(fs.listDir(await fs.resolve('a.txt'))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
})
it('honors a pre-aborted signal', async () => {
await mkdir(join(dir, 'skills'), { recursive: true })
await expect(fs.listDir(await fs.resolve('skills'), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
})
describe('writeText', () => {
it('createIfAbsent creates a new file', async () => {
const target = await fs.resolve('new.txt')
@@ -188,6 +232,53 @@ describe('writeText', () => {
await expect(fs.writeText(target, 'x')).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' })
})
it('a create reports before:null and after = the written content (no prior file)', async () => {
const target = await fs.resolve('new.txt')
const outcome = await fs.writeText(target, 'fresh')
expect(outcome.before).toBeNull()
expect(outcome.after).toBe('fresh')
})
it('an overwrite reports before = the OLD content and after = the new content', async () => {
await writeFile(join(dir, 'a.txt'), 'old body')
const target = await fs.resolve('a.txt')
const outcome = await fs.writeText(target, 'new body')
expect(outcome.before).toBe('old body')
expect(outcome.after).toBe('new body')
})
it('an overwrite returns LF-normalized before AND after (a CRLF rewrite is not every-line-changed)', async () => {
// The applied-hunk diff bases on `before`/`after`; if `after` kept CRLF while
// `before` is LF-normalized, a CRLF rewrite would read as every line changed.
// Both sides are LF so only the genuinely-changed line diffs.
await writeFile(join(dir, 'a.txt'), 'a\r\nb\r\nc\r\n')
const target = await fs.resolve('a.txt')
const outcome = await fs.writeText(target, 'a\r\nB\r\nc\r\n')
expect(outcome.before).toBe('a\nb\nc\n')
expect(outcome.after).toBe('a\nB\nc\n')
})
it('an overwrite of a BINARY prior file reports before:null (undiffable), still succeeds', async () => {
await writeFile(join(dir, 'a.bin'), Buffer.from([0x00, 0x01, 0x02]))
const target = await fs.resolve('a.bin')
const outcome = await fs.writeText(target, 'now text')
expect(outcome.operation).toBe('update')
expect(outcome.before).toBeNull()
expect(outcome.after).toBe('now text')
})
it('an overwrite of an INVALID-UTF-8 (non-NUL) prior file reports before:null, still succeeds', async () => {
// 0xff is never valid UTF-8 but is not a NUL, so it exercises the decoder's
// fatal-throw path (not the NUL-scan short-circuit): an undiffable prior file
// still yields a successful write with no before-content basis.
await writeFile(join(dir, 'a.bin'), Buffer.from([0x68, 0xff, 0x69]))
const target = await fs.resolve('a.bin')
const outcome = await fs.writeText(target, 'now valid')
expect(outcome.operation).toBe('update')
expect(outcome.before).toBeNull()
expect(outcome.after).toBe('now valid')
})
it('releases per-target mutation locks after success and failure', async () => {
const target = await fs.resolve('a.txt')
await fs.writeText(target, 'created', { kind: 'createIfAbsent' })
@@ -238,10 +329,21 @@ describe('editText', () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) })
expect(outcome.replacements).toBe(1)
expect(outcome.after).toBe('hello there')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
it('reports before/after content (the applied-hunk basis), LF-normalized', async () => {
await writeFile(join(dir, 'a.txt'), 'a\r\nOLD\r\nb\r\n')
const target = await fs.resolve('a.txt')
const outcome = await fs.editText(target, { oldString: 'OLD', newString: 'NEW', replaceAll: false })
expect(outcome.before).toBe('a\nOLD\nb\n')
expect(outcome.after).toBe('a\nNEW\nb\n')
// The written file keeps the original CRLF endings (before/after are the
// LF-normalized diff basis, not the on-disk bytes).
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('a\r\nNEW\r\nb\r\n')
})
it('checks the stale version BEFORE literal matching', async () => {
await writeFile(join(dir, 'a.txt'), 'hello world')
const target = await fs.resolve('a.txt')
@@ -257,7 +359,7 @@ describe('editText', () => {
const target = await fs.resolve('a.txt')
// No version guard: any current content is edited, regardless of version.
const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false })
expect(outcome.replacements).toBe(1)
expect(outcome.after).toBe('hello there')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there')
})
@@ -303,7 +405,7 @@ describe('editText', () => {
await writeFile(join(dir, 'a.txt'), 'a a a')
const target = await fs.resolve('a.txt')
const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) })
expect(outcome.replacements).toBe(3)
expect(outcome.after).toBe('b b b')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b')
})
@@ -349,7 +451,7 @@ describe('editText', () => {
// The version the first edit returned is a valid guard for a second edit —
// no intervening re-stat needed.
const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version })
expect(second.replacements).toBe(1)
expect(second.after).toBe('ONE TWO')
expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO')
})

View File

@@ -6,12 +6,13 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdtemp, readFile, rm, stat, symlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
import {
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readWholeText,
@@ -145,6 +146,110 @@ describe('probe', () => {
})
})
describe('listDirectory', () => {
it('lists direct children in stable order without reading content', async () => {
const root = join(dir, 'skills')
await mkdir(join(root, 'dir-skill'), { recursive: true })
await writeFile(join(root, 'zeta.md'), 'zeta')
await writeFile(join(root, 'alpha.md'), 'alpha')
await symlink(join(root, 'missing-target'), join(root, 'broken-link'))
const entries = await listDirectory(localTarget(root))
expect(entries.map(entry => [entry.name, entry.type])).toEqual([
['alpha.md', 'file'],
['broken-link', 'other'],
['dir-skill', 'directory'],
['zeta.md', 'file'],
])
expect(entries.find(entry => entry.name === 'alpha.md')?.size).toBe(5)
expect(typeof entries.find(entry => entry.name === 'alpha.md')?.version).toBe('string')
expect(entries.find(entry => entry.name === 'broken-link')?.version).toBeUndefined()
expect(entries.find(entry => entry.name === 'dir-skill')?.size).toBeUndefined()
})
it('derives child target keys from the listed parent identity', async () => {
const realOne = join(dir, 'real-one')
const realTwo = join(dir, 'real-two')
const link = join(dir, 'link')
await mkdir(realOne)
await mkdir(realTwo)
await writeFile(join(realOne, 'same.txt'), 'one')
await writeFile(join(realTwo, 'same.txt'), 'different two')
await symlink(realOne, link)
const target = await resolveLocalTarget(dir, 'link')
await unlink(link)
await symlink(realTwo, link)
const entries = await listDirectory(target)
expect(entries).toHaveLength(1)
expect(entries[0]).toMatchObject({
name: 'same.txt',
target: {
displayPath: join(link, 'same.txt'),
targetKey: await realpath(join(realOne, 'same.txt')),
},
size: 3,
})
})
it('rejects missing, non-directory, and aborted listing requests', async () => {
await expect(listDirectory(localTarget(join(dir, 'missing')))).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
const file = join(dir, 'a.txt')
await writeFile(file, 'hi')
await expect(listDirectory(localTarget(file))).rejects.toMatchObject({ code: 'FS_NOT_DIRECTORY' })
await expect(listDirectory(localTarget(dir), AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
})
it('translates directory permission failures into FS_PERMISSION_DENIED', async () => {
const root = join(dir, 'restricted')
await mkdir(root)
await chmod(root, 0o000)
try {
const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
// Root-like environments may still be able to list mode-000 directories.
if (error === undefined) return
expect(error).toBeInstanceOf(FsError)
expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
} finally {
await chmod(root, 0o700)
}
})
it('translates preflight metadata IO failures into FS_IO_ERROR', async () => {
const loop = join(dir, 'loop')
await symlink(loop, loop)
await expect(listDirectory(localTarget(loop))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
})
it('translates child resolution failures into structured listing errors', async () => {
const root = join(dir, 'listed')
await mkdir(root)
const loop = join(root, 'loop')
await symlink(loop, loop)
await expect(listDirectory(localTarget(root))).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
})
it('translates child permission failures into FS_PERMISSION_DENIED', async () => {
const root = join(dir, 'listed')
const protectedRoot = join(dir, 'protected')
const secret = join(protectedRoot, 'secret')
await mkdir(root)
await mkdir(secret, { recursive: true })
await symlink(secret, join(root, 'secret-link'))
await chmod(protectedRoot, 0o000)
try {
const error = await listDirectory(localTarget(root)).then(() => undefined, (caught: unknown) => caught)
// Root-like environments may still resolve through mode-000 directories.
if (error === undefined) return
expect(error).toBeInstanceOf(FsError)
expect(error).toMatchObject({ code: 'FS_PERMISSION_DENIED' })
} finally {
await chmod(protectedRoot, 0o700)
}
})
})
describe('readWholeText', () => {
it('reads a small file', async () => {
const file = join(dir, 'a.txt')

View File

@@ -18,7 +18,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy'
function target(path: string): FsTarget {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
return { targetKey: FsTargetKey(path), displayPath: path }
}
const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } })

View File

@@ -1,6 +1,6 @@
# @deepseek-ai/dsh-fs
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the text-storage primitives a backend provides — resolve a path, stat metadata, read/stream text, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
The **filesystem provider seam**: an abstract `FileSystem` service (`ctx.fs`) defining the storage primitives a backend provides — resolve a path, stat metadata, read/stream text, list directories, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for.
This package is the provider-seam layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam RFC](../../../docs/rfc/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam RFC](../../../docs/rfc/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate RFC](../../../docs/rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md)):
@@ -15,14 +15,15 @@ A future sandboxed, virtual, or remote backend implements this interface and the
## Service API (`ctx.fs`)
A backend subclasses `FileSystem` and implements six primitives.
A backend subclasses `FileSystem` and implements seven primitives.
| Member | Semantics |
|---|---|
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. |
| `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. |
| `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). |
| `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). |
| `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. |
| `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. |
| `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. |
@@ -30,7 +31,7 @@ The mutation runs inside the backend's per-target lock either way, so an uncondi
## The `fs/*` policy events
This package declares three events (see the generated [catalog](../../../docs/cordis-catalog/events-and-services.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
This package declares three events (see the generated [events catalog](../../../docs/cordis-catalog/events.md)) so the emitter (`@deepseek-ai/dsh-tool-fs`) and the policy listener (`@deepseek-ai/dsh-fs-policy`) share a vocabulary without the emitter depending on the policy plugin. `fs/write-intent` and `fs/edit-intent` are single-slot decision waterfalls (the listener fully decides, never calling `next()`); `fs/observed` is a fire-and-forget recording event. They carry only `dsh-fs` vocabulary plus an opaque `object` actor — no model-facing concepts and no agent/session owner structure.
## A provider seam, not the policy layer
@@ -40,4 +41,4 @@ This package declares three events (see the generated [catalog](../../../docs/co
## Vocabulary
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.
`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids RFC](../../../docs/rfc/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy RFC](../../../docs/rfc/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts.

View File

@@ -59,6 +59,7 @@
import { Context, Service } from 'cordis'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -76,6 +77,7 @@ export {
export type {
FsEditOutcome,
FsEditRequest,
FsDirEntry,
FsErrorCode,
FsInfo,
FsTarget,
@@ -100,6 +102,8 @@ declare module 'cordis' {
* chain. The slot is first-wins: the first non-`next()` decider (registration
* order, or `prepend`) occupies it; a second decider is a misconfiguration,
* not layering. `actor` is the opaque tool-execution context, never read here.
* @param target - the resolved target about to be written.
* @param actor - the opaque tool-execution context the decider keys off.
* @mode waterfall
*/
'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined>
@@ -112,6 +116,8 @@ declare module 'cordis' {
* `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset
* or has not observed the target. Does NOT call `next()`: one decision,
* first-wins (see {@link Events.'fs/write-intent'}).
* @param target - the resolved target about to be edited.
* @param actor - the opaque tool-execution context the decider keys off.
* @mode waterfall
*/
'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined>
@@ -124,6 +130,9 @@ declare module 'cordis' {
* await listener promises — async or fallible audit/telemetry does not
* belong here. No listener ⇒ nothing recorded. `actor` is the opaque
* tool-execution context.
* @param target - the target that was read/written/edited.
* @param version - the version the actor now holds as its observation.
* @param actor - the observing tool-execution context; undefined records nothing useful.
* @mode emit
*/
'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void
@@ -131,7 +140,7 @@ declare module 'cordis' {
}
/**
* Abstract filesystem provider service. Subclass, implement the six text-storage
* Abstract filesystem provider service. Subclass, implement the seven storage
* primitives, and load the subclass as a plugin — it registers as `ctx.fs` (one
* implementation per context; loading a second throws, cordis' standard
* duplicate-service behavior).
@@ -145,6 +154,11 @@ declare module 'cordis' {
* - {@link readText}/{@link streamText} read the whole regular text file (the
* stream for large files); both own regular-file checks, UTF-8 decoding,
* binary/NUL rejection, and `FS_NOT_TEXT`.
* - {@link listDir} returns direct children of a directory in stable name order
* with resolved child targets and cheap metadata only. It never reads file
* contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw
* `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and
* other backend I/O failures throw `FS_IO_ERROR`.
* - {@link writeText} is atomic temp-file + rename. `expected` is OPTIONAL:
* omit it for an unconditional create-or-overwrite (the bare-provider default),
* or supply a {@link FsWriteIntent} to guard the write.
@@ -173,13 +187,26 @@ export abstract class FileSystem extends Service {
* caller's per-session workspace (`exec.agent.session.header.cwd`) without the
* provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash`
* defaults a bash `workdir` to the session cwd.
* @param path - the path to resolve; relative paths resolve against `opts.cwd`.
* @param opts - `cwd` overrides the backend's default base for relative paths.
* @returns the stable target; the same file yields the same `targetKey`.
*/
abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget>
/** Return target metadata, or `undefined` when the target does not exist. */
/**
* Return target metadata, or `undefined` when the target does not exist.
* @param target - the resolved target to stat.
* @param signal - aborts the metadata round-trip.
* @returns metadata only, never content; undefined for an absent target.
*/
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
/** Read the whole regular text file as a single decoded string. */
/**
* Read the whole regular text file as a single decoded string.
* @param target - the resolved target to read.
* @param signal - aborts the read.
* @returns the full decoded UTF-8 content.
*/
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
/**
@@ -187,14 +214,31 @@ export abstract class FileSystem extends Service {
* semantics as {@link readText}, for large files). The backend owns
* cross-chunk UTF-8 decoding and binary rejection so the policy layer never
* touches raw bytes.
* @param target - the resolved target to read.
* @param signal - aborts the stream, including between chunks.
* @returns the chunk iterable, decoded and validated like {@link readText}.
*/
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
/**
* List direct children of a directory in stable name order. Returns resolved
* child targets plus cheap metadata only; never reads file contents.
* @param target - the resolved directory target.
* @param signal - aborts the listing.
* @returns one entry per direct child, in stable name order.
*/
abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]>
/**
* Create or fully replace a UTF-8 text file atomically. `expected` is the
* create-vs-replace decision and stale guard when supplied; OMITTING it is an
* unconditional create-or-overwrite (the bare provider — no version guard, no
* read-first requirement). Atomic either way.
* @param target - the resolved target to write.
* @param content - the full new file content.
* @param expected - the write intent guarding the write; omit for unconditional.
* @param signal - aborts before the atomic rename takes effect.
* @returns the outcome, including the version the write produced.
*/
abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
@@ -204,6 +248,11 @@ export abstract class FileSystem extends Service {
* matching; OMITTING it edits the current content unconditionally (no version
* guard). Either way applies the replacement and writes atomically — one
* mutation critical section — and a missing target reports `FS_STALE_VERSION`.
* @param target - the resolved target to edit.
* @param edit - the literal search/replace request.
* @param expected - the version guard; omit for an unconditional edit.
* @param signal - aborts before the atomic rename takes effect.
* @returns the outcome, including the version the edit produced.
*/
abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
}

View File

@@ -52,8 +52,6 @@ export function FsVersion(v: string): FsVersion {
* this; every other operation takes it.
*/
export interface FsTarget {
/** The original model/plugin-supplied path, for diagnostics only. */
inputPath: string
/** Opaque key for stale guards and target lookup. */
targetKey: FsTargetKey
/**
@@ -78,6 +76,23 @@ export interface FsInfo {
size?: number
}
/**
* One direct child returned by {@link FileSystem.listDir}. Listing returns
* metadata and resolved targets only; it must not read file contents.
*/
export interface FsDirEntry {
/** Basename of the child inside the listed directory. */
name: string
/** Whether the child is a regular file, a directory, or something else. */
type: 'file' | 'directory' | 'other'
/** Resolved child target for follow-up operations. */
target: FsTarget
/** Opaque freshness token when the backend can report metadata cheaply. */
version?: FsVersion
/** Byte size of a regular file, when the backend can report it. */
size?: number
}
/**
* The explicit intent of a guarded {@link FileSystem.writeText} call.
* `createIfAbsent` creates a missing target and rejects an existing one with
@@ -101,6 +116,16 @@ export interface FsWriteOutcome {
operation: 'create' | 'update'
/** Opaque version of the file after the write. */
version: FsVersion
/**
* The file's content BEFORE the write, or `null` when the file did not exist
* (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text
* (the diff basis), never a diff — a consumer computes the result-time
* contextual diff from `before`/`after` when `before` is present, else falls
* back to a whole-file diff.
*/
before: string | null
/** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */
after: string
}
/** A literal-replacement edit request. */
@@ -115,12 +140,16 @@ export interface FsEditRequest {
/** Outcome of a literal edit. */
export interface FsEditOutcome {
/** Number of literal replacements applied. */
replacements: number
/** Whether every match was replaced. */
replaceAll: boolean
/** Opaque version of the file after the edit. */
version: FsVersion
/**
* The file's content BEFORE the edit. Raw storage text (LF-normalized by the
* backend), never a diff — a consumer computes the result-time contextual diff
* (the applied hunk with context) from `before`/`after`.
*/
before: string
/** The file's content AFTER the edit. */
after: string
}
/**
@@ -130,8 +159,11 @@ export interface FsEditOutcome {
*/
export type FsErrorCode =
| 'FS_NOT_FOUND'
| 'FS_NOT_DIRECTORY'
| 'FS_NOT_TEXT'
| 'FS_NOT_REGULAR_FILE'
| 'FS_PERMISSION_DENIED'
| 'FS_IO_ERROR'
| 'FS_STALE_VERSION'
| 'FS_NOT_OBSERVED'
| 'FS_AMBIGUOUS_EDIT'

View File

@@ -9,6 +9,7 @@ import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -17,12 +18,12 @@ import type {
FsWriteOutcome,
} from '@deepseek-ai/dsh-fs'
/** A minimal in-memory fake implementing the six provider primitives. */
/** A minimal in-memory fake implementing the seven provider primitives. */
class FakeFileSystem extends FileSystem {
files = new Map<string, string>()
override async resolve(path: string): Promise<FsTarget> {
return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path }
return { targetKey: FsTargetKey(path), displayPath: path }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
const content = this.files.get(target.targetKey)
@@ -38,15 +39,28 @@ class FakeFileSystem extends FileSystem {
const content = await this.readText(target)
return (async function* () { yield content })()
}
override async listDir(target: FsTarget): Promise<FsDirEntry[]> {
if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY')
return [
{
name: 'alpha.md',
type: 'file',
target: { targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' },
size: 2,
version: FsVersion('v1'),
},
]
}
override async writeText(target: FsTarget, content: string, _expected?: FsWriteIntent): Promise<FsWriteOutcome> {
const existed = this.files.has(target.targetKey)
const before = this.files.get(target.targetKey) ?? null
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
}
override async editText(target: FsTarget, edit: FsEditRequest): Promise<FsEditOutcome> {
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
const after = content.split(edit.oldString).join(edit.newString)
this.files.set(target.targetKey, after)
return { version: FsVersion('v3'), before: content, after }
}
}
@@ -86,6 +100,20 @@ describe('FileSystem provider seam', () => {
expect(streamed).toBe(await fs.readText(target))
})
it('listDir returns child entry targets without reading file content', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)
const fs = ctx.fs as FakeFileSystem
const entries = await fs.listDir(await fs.resolve('skills'))
expect(entries).toEqual([{
name: 'alpha.md',
type: 'file',
target: { targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' },
size: 2,
version: 'v1',
}])
})
it('stat returns undefined for an absent target', async () => {
const ctx = new Context()
await ctx.plugin(FakeFileSystem)

View File

@@ -11,11 +11,22 @@ await ctx.plugin(ToolFs) // this package — re
`@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit.
## Config
All keys are optional; the defaults are the shipped read caps.
| Key | Default | Meaning |
|---|---|---|
| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). |
| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). |
| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. |
| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. |
## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md))
| Tool | Arguments | Behavior |
|---|---|---|
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. |
| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). |
| `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. |
| `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. |

View File

@@ -21,9 +21,14 @@
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"diff": "^9.0.0",
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"

View File

@@ -0,0 +1,92 @@
/**
* Result-time contextual-diff computation for the `write`/`edit` tools. Turns a
* before/after pair of file texts into one {@link FileDiff} per applied hunk —
* each hunk's `oldText`/`newText` reconstructed from the unified-diff lines with
* ±{@link DIFF_CONTEXT} surrounding context lines, matching how claude-agent-acp
* renders an editor inline diff.
*
* This is display-only presentation vocabulary (a UI concern), so it lives in
* the model-facing tool, NOT the `dsh-fs` storage seam — the backend returns
* only the raw before/after text (storage facts) and the tool computes the diff.
*
* @module @deepseek-ai/dsh-tool-fs/src/diff
*/
import { structuredPatch } from 'diff'
import type { FileDiff } from '@deepseek-ai/dsh-tools'
/** Context lines shown on each side of an applied hunk (matches claude-agent-acp). */
export const DIFF_CONTEXT = 3
/**
* The `write`/`edit` tools' private `tool/result` `meta` payload: the applied
* contextual-diff hunks. Attached opaquely (as `unknown`) on the tool result and
* persisted with the session log — it must be JSON-serializable (the session
* validates this at `append`), so `presentResult` reproduces the diff card on
* replay. The producing tool owns this shape; the bridge only sees the opaque
* `meta` and the tool narrows it back via {@link diffsFromMeta}.
*/
export type FsDiffMeta = { diffs: FileDiff[] }
/**
* Compute one {@link FileDiff} per hunk between `before` and `after`, each
* carrying the applied change plus {@link DIFF_CONTEXT} context lines. Returns an
* empty array when the texts are identical (no hunks). For a scattered
* `replace_all` edit the patch yields multiple hunks, so multiple `FileDiff`s
* come back — matching the editor rendering one diff block per site.
*
* Each hunk's `oldText` is its `-` (removed) and context lines joined by `\n`;
* `newText` is its `+` (added) and context lines. A hunk with no old lines
* (a pure insertion) reports `oldText: null` (nothing to diff against), mirroring
* the call-time card's new-file convention. The unified-diff "\ No newline at end
* of file" markers are dropped — they annotate the patch, not file content.
*/
export function computeHunkDiffs(path: string, before: string, after: string): FileDiff[] {
const patch = structuredPatch('', '', before, after, undefined, undefined, { context: DIFF_CONTEXT })
const diffs: FileDiff[] = []
for (const hunk of patch.hunks) {
const oldLines: string[] = []
const newLines: string[] = []
for (const line of hunk.lines) {
// The unified-diff marker for a missing trailing newline annotates the
// patch, not the content — skip it so it never leaks into a diff block.
if (line.startsWith('\\')) continue
const text = line.slice(1)
if (line.startsWith('-')) {
oldLines.push(text)
} else if (line.startsWith('+')) {
newLines.push(text)
} else {
// A context (unchanged) line appears on both sides.
oldLines.push(text)
newLines.push(text)
}
}
diffs.push({ path, oldText: oldLines.length > 0 ? oldLines.join('\n') : null, newText: newLines.join('\n') })
}
return diffs
}
/** Whether `value` is a valid {@link FileDiff} (defensive narrowing from opaque `meta`). */
function isFileDiff(value: unknown): value is FileDiff {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const { path, oldText, newText } = value as Record<string, unknown>
return typeof path === 'string'
&& (oldText === null || typeof oldText === 'string')
&& typeof newText === 'string'
}
/**
* Narrow an opaque `tool/result` `meta` back to this tool's {@link FileDiff}
* hunks, or `undefined` when it is absent/malformed. `presentResult` runs on
* arbitrary logged `meta` (possibly from an older shape or a hand-edited log), so
* it validates defensively rather than trusting the payload — a bad `meta` yields
* `undefined`, and the caller decides the fallback (edit → the generic result
* rendering; write → an args-derived whole-file diff), never a thrown presenter.
*/
export function diffsFromMeta(meta: unknown): FileDiff[] | undefined {
if (typeof meta !== 'object' || meta === null || Array.isArray(meta)) return undefined
const diffs = (meta as Record<string, unknown>).diffs
if (!Array.isArray(diffs) || diffs.length === 0 || !diffs.every(isFileDiff)) return undefined
return diffs
}

View File

@@ -14,10 +14,11 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsEditOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionCwd } from './session-cwd.ts'
/** Validated `edit` arguments after defaulting. */
@@ -41,9 +42,9 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new
}
}
/** Format an edit outcome as a Claude-style model-facing success message. */
export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string {
return outcome.replaceAll
/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */
export function formatEditOutput(displayPath: string, replaceAll: boolean): string {
return replaceAll
? `The file ${displayPath} has been updated. All occurrences were successfully replaced.`
: `The file ${displayPath} has been updated successfully.`
}
@@ -65,7 +66,7 @@ export function applyEditTool(ctx: Context): void {
new_string: { type: 'string', required: true, description: 'Literal replacement text. Use an empty string to delete the match.' },
replace_all: { type: 'boolean', description: 'Replace all matches. Defaults to false; when false, old_string must appear exactly once.' },
},
async execute(args, exec): Promise<ContentBlock[]> {
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
const input = parseEditArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
@@ -81,20 +82,39 @@ export function applyEditTool(ctx: Context): void {
)
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
return [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }]
},
// Pure display: `edit` kind, a location for editor follow-along, and a short
// old→new summary as rawInput (truncated so a large replacement stays a
// readable card). The replacement COUNT is not available here — presentResult
// only sees `{ content, isError }`, not the outcome — so the title is static.
presentCall(args) {
const clip = (s: string): string => (s.length > 40 ? `${s.slice(0, 40)}` : s)
// The result-time applied-hunk diff (before→after with context lines). An
// edit always changes content (parseEditArgs requires old_string to differ
// and editText matches at least once), so there is always at least one hunk.
// The bridge renders these as an inline diff that supersedes the call-time
// snippet; the display path is the model-facing `file_path` (the bridge
// relativizes it).
const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after)
return {
content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }],
meta: { diffs },
}
},
// Pure display: a diff card of the literal replacement (old_string →
// new_string), derived from the call args. `oldText: old_string || null`
// matches claude-agent-acp's Edit arm; new_string is a required arg here, so
// it maps straight to newText. A follow-along location points at the file.
presentCall(args): DiffCallView {
return {
card: 'diff',
title: `Edit ${args.file_path}`,
kind: 'edit',
rawInput: `${JSON.stringify(clip(args.old_string))}${JSON.stringify(clip(args.new_string))}`,
diffs: [{ path: args.file_path, oldText: args.old_string || null, newText: args.new_string }],
locations: [{ path: args.file_path }],
}
},
// Result-time display: the applied contextual-diff hunks carried on `meta`.
// On success with diffs, a `diff` result card supersedes the call-time
// snippet; on error (nothing applied) or malformed meta, fall through to the
// generic "updated successfully" rendering.
presentResult(args, result: ToolResult): DiffResultView | undefined {
if (result.isError) return undefined
const diffs = diffsFromMeta(result.meta)
if (diffs === undefined) return undefined
return { card: 'diff', title: `Edit ${args.file_path}`, diffs }
},
}))
}

View File

@@ -23,15 +23,20 @@
*/
import type { Context } from 'cordis'
import { applyReadTool } from './read.ts'
import z from 'schemastery'
import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts'
import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
export type { ReadToolCaps } from './read.ts'
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts'
export type { FsDiffMeta } from './diff.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'
@@ -39,9 +44,49 @@ export const name = 'tool-fs'
/** Services required by the filesystem tool suite. */
export const inject = ['tools', 'fs', 'systemPrompt']
/** Plugin config (all optional — `Config` supplies the defaults). */
export interface Config {
/** Default and maximum number of lines returned by one `read` call. */
readLimit?: number
/** Maximum characters returned for a single line before truncation. */
readMaxLineLength?: number
/** Maximum bytes returned for the selected lines of one `read` call. */
readMaxBytes?: number
/** Files at or above this size stream instead of loading whole into memory. */
readStreamMinSize?: number
}
export const Config: z<Config> = z.object({
readLimit: z.number().default(READ_LIMIT),
readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH),
readMaxBytes: z.number().default(READ_MAX_BYTES),
readStreamMinSize: z.number().default(STREAM_MIN_SIZE),
})
/** The shape after schemastery applied the defaults. */
type ResolvedConfig = Required<Config>
/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`tool-fs: ${name} must be a positive integer`)
}
}
/** Register the full `read`/`write`/`edit` filesystem tool suite. */
export function apply(ctx: Context): void {
applyReadTool(ctx)
export function apply(ctx: Context, config: Config): void {
// schemastery (Config) has already filled every defaulted field.
const resolved = config as ResolvedConfig
assertPositiveInteger('readLimit', resolved.readLimit)
assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength)
assertPositiveInteger('readMaxBytes', resolved.readMaxBytes)
assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize)
applyReadTool(ctx, {
limit: resolved.readLimit,
maxLineLength: resolved.readMaxLineLength,
maxBytes: resolved.readMaxBytes,
streamMinSize: resolved.readStreamMinSize,
})
applyWriteTool(ctx)
applyEditTool(ctx)
}

View File

@@ -17,23 +17,23 @@
*/
import { FsError } from '@deepseek-ai/dsh-fs'
import type { FsVersion } from '@deepseek-ai/dsh-fs'
/** Maximum characters returned for a single line. */
/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */
export const READ_MAX_LINE_LENGTH = 2000
/** Maximum bytes returned for selected file lines. */
/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */
export const READ_MAX_BYTES = 50 * 1024
const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)`
const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1
/** Resolved read window. The consumer applies its defaults/caps before calling. */
export interface ReadWindow {
/** 1-based first line to return. */
offset: number
/** Maximum number of lines to return. */
limit: number
/** Maximum characters returned for a single line; overflow is truncated with a suffix. */
maxLineLength: number
/** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */
maxBytes: number
}
/** One line returned from a text file. */
@@ -58,16 +58,12 @@ export interface WindowResult {
export interface FileReadOutcome {
/** 1-based first line requested. */
offset: number
/** Maximum number of lines requested. */
limit: number
/** Returned lines, already numbered. */
lines: FileTextLine[]
/** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */
totalLines: number
/** Whether selected output hit the byte cap before EOF or the requested limit. */
truncatedByBytes?: true
/** Opaque version of the file at read time. */
version: FsVersion
}
interface WindowAccumulator {
@@ -82,8 +78,8 @@ function newAccumulator(): WindowAccumulator {
return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false }
}
function truncateLine(line: string): string {
return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line
function truncateLine(line: string, maxLineLength: number): string {
return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line
}
function lineByteSize(line: string, currentLineCount: number): number {
@@ -94,9 +90,9 @@ function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindo
acc.totalLines += 1
if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return
const text = truncateLine(rawLine)
const text = truncateLine(rawLine, request.maxLineLength)
const bytes = lineByteSize(text, acc.lines.length)
if (acc.outputBytes + bytes > READ_MAX_BYTES) {
if (acc.outputBytes + bytes > request.maxBytes) {
acc.truncatedByBytes = true
acc.done = true
return
@@ -121,7 +117,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string
* Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an
* `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code
* path serves both. Scans for newlines with a capped line buffer (a newline-free
* giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}),
* giant line is truncated, never buffered past `request.maxLineLength`),
* enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF.
*/
export async function buildWindow(
@@ -130,12 +126,14 @@ export async function buildWindow(
displayPath: string,
): Promise<WindowResult> {
const acc = newAccumulator()
// One char past the truncation point is enough to prove a line overflows.
const lineBufferCap = request.maxLineLength + 1
let lineBuffer = ''
function appendToLineBuffer(segment: string): void {
if (lineBuffer.length >= LINE_BUFFER_CAP) return
if (lineBuffer.length >= lineBufferCap) return
lineBuffer += segment
if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP)
if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap)
}
function flushLine(): void {

View File

@@ -14,6 +14,7 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { FsError } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
@@ -22,12 +23,27 @@ import { buildWindow, formatReadOutput } from './read-render.ts'
import type { FileReadOutcome } from './read-render.ts'
import { sessionCwd } from './session-cwd.ts'
/** Default and maximum number of lines returned by one `read` call. */
/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */
export const READ_LIMIT = 2000
/** Files at or above this size stream; smaller files read whole into memory. */
/**
* Default streaming threshold (the `readStreamMinSize` config): files at or
* above this size stream; smaller files read whole into memory.
*/
export const STREAM_MIN_SIZE = 10 * 1024 * 1024
/** Resolved read-tool caps — plugin config after defaulting (see `Config` in index.ts). */
export interface ReadToolCaps {
/** Default and maximum number of lines returned by one call. */
limit: number
/** Maximum characters returned for a single line. */
maxLineLength: number
/** Maximum bytes returned for selected file lines. */
maxBytes: number
/** Files at or above this size stream; smaller files read whole into memory. */
streamMinSize: number
}
/** Validated `read` arguments after defaulting. */
interface ReadInput {
filePath: string
@@ -42,17 +58,17 @@ function parsePositiveInteger(value: number, name: string): number {
return value
}
/** Validate value constraints the schema DSL can't express. */
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput {
/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */
export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput {
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset')
const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit')
if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`)
const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit')
if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`)
return { filePath: args.file_path, offset, limit }
}
/** Register the `read` tool and its system-prompt guidance. */
export function applyReadTool(ctx: Context): void {
export function applyReadTool(ctx: Context, caps: ReadToolCaps): void {
ctx.systemPrompt.section({
name: 'tool:read',
order: 100,
@@ -65,10 +81,10 @@ export function applyReadTool(ctx: Context): void {
parameters: {
file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' },
offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` },
limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` },
},
async execute(args, exec): Promise<ContentBlock[]> {
const input = parseReadArgs(args)
const input = parseReadArgs(args, caps.limit)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
@@ -82,17 +98,19 @@ export function applyReadTool(ctx: Context): void {
// Stream when the file is large OR size is unknown, so a size-less backend
// never buffers an arbitrarily large file.
const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE
const chunks = info.size === undefined || info.size >= caps.streamMinSize
? await ctx.fs.streamText(target, exec.signal)
: [await ctx.fs.readText(target, exec.signal)]
const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath)
const window = await buildWindow(
chunks,
{ offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes },
target.displayPath,
)
const outcome: FileReadOutcome = {
offset: input.offset,
limit: input.limit,
lines: window.lines,
totalLines: window.totalLines,
version: info.version,
...window.truncatedByBytes ? { truncatedByBytes: true } : {},
}
// Record the observed version (a no-op when no policy plugin listens). The
@@ -101,19 +119,22 @@ export function applyReadTool(ctx: Context): void {
ctx.emit('fs/observed', target, info.version, exec)
return [{ type: 'text', text: formatReadOutput(target.displayPath, outcome) }]
},
// Pure display: a UI card titled by the file, `read` kind (icon), and a
// location so an editor can follow along to the file (and the read's offset
// line). `rawInput` surfaces offset/limit when the model narrowed the read.
presentCall(args) {
const detail = [
...args.offset !== undefined ? [`offset ${args.offset}`] : [],
...args.limit !== undefined ? [`limit ${args.limit}`] : [],
].join(', ')
// Pure display: a generic card titled by the file with the read window
// appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along
// location whose line is the read's offset (defaulting to 1). The window is
// derived from the RAW args (offset/limit as the model passed them), NOT the
// tool's defaulted 1/configured limit, so an unbounded read shows a bare
// title (and the presenter stays a pure function of args, config-free).
presentCall(args): GenericCallView {
const { offset, limit } = args
const window = limit !== undefined && limit > 0
? ` (${offset ?? 1} - ${(offset ?? 1) + limit - 1})`
: offset !== undefined ? ` (from line ${offset})` : ''
return {
title: `Read ${args.file_path}`,
card: 'generic',
title: `Read ${args.file_path}${window}`,
kind: 'read',
locations: [{ path: args.file_path, ...args.offset !== undefined ? { line: args.offset } : {} }],
...detail.length > 0 ? { rawInput: detail } : {},
locations: [{ path: args.file_path, line: offset ?? 1 }],
}
},
}))

View File

@@ -13,10 +13,12 @@
import type { Context } from 'cordis'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-fs'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts'
import { sessionCwd } from './session-cwd.ts'
/** Validate value constraints the schema DSL can't express. */
@@ -50,7 +52,7 @@ export function applyWriteTool(ctx: Context): void {
file_path: { type: 'string', required: true, description: 'Path to write, resolved by the filesystem backend.' },
content: { type: 'string', required: true, description: 'Full UTF-8 text content to write.' },
},
async execute(args, exec): Promise<ContentBlock[]> {
async execute(args, exec): Promise<{ content: ContentBlock[]; meta?: FsDiffMeta }> {
const input = parseWriteArgs(args)
const cwd = sessionCwd(exec)
const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined)
@@ -60,14 +62,41 @@ export function applyWriteTool(ctx: Context): void {
const outcome = await ctx.fs.writeText(target, input.content, intent, exec.signal)
// Record the observed version (a no-op when no policy plugin listens).
ctx.emit('fs/observed', target, outcome.version, exec)
return [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }]
// Attach a contextual hunk as `meta` ONLY for an overwrite (a before-version
// exists). A create has no "before" — `outcome.before` is null — so it
// carries no `meta`; `presentResult` then renders a whole-file diff from the
// args, so the completed card is still a diff (never the result text).
const diffs = outcome.before !== null ? computeHunkDiffs(input.filePath, outcome.before, outcome.after) : []
return {
content: [{ type: 'text', text: formatWriteOutput(target.displayPath, outcome) }],
...diffs.length > 0 ? { meta: { diffs } } : {},
}
},
// Pure display: `edit` kind (an editor treats create/replace as an edit) and
// a location so the UI can follow along to the written file. The create-vs-
// overwrite fact lives in the model-facing result text; `presentResult` only
// sees `{ content, isError }` (not the outcome), so the title stays static.
presentCall(args) {
return { title: `Write ${args.file_path}`, kind: 'edit', locations: [{ path: args.file_path }] }
// Pure display: a diff card (an editor renders write as a new-file / full-
// replace diff). `oldText: null` — a call-time presenter has no access to the
// file's prior content, so even an overwrite renders new-file style, matching
// claude-agent-acp. A follow-along location points at the written file.
presentCall(args): DiffCallView {
return {
card: 'diff',
title: `Write ${args.file_path}`,
diffs: [{ path: args.file_path, oldText: null, newText: args.content }],
locations: [{ path: args.file_path }],
}
},
// Result-time display: a `diff` card so the completed `tool_call_update`
// re-installs the diff rather than the model-facing result text (an ACP
// `tool_call_update.content` REPLACES the call's content, so a text result
// would clobber the pending diff card). An OVERWRITE uses the applied
// contextual hunks on `meta`; a CREATE has no `meta` (no prior content), so
// its whole-file new-file diff is derived from `args.content` (replay-safe,
// matching the call-time card). An error falls through to generic rendering
// so its message shows.
presentResult(args, result: ToolResult): DiffResultView | undefined {
if (result.isError) return undefined
const diffs = diffsFromMeta(result.meta)
?? [{ path: args.file_path, oldText: null, newText: args.content }]
return { card: 'diff', title: `Write ${args.file_path}`, diffs }
},
}))
}

View File

@@ -0,0 +1,113 @@
/**
* Unit tests for the result-time contextual-diff computation (`src/diff.ts`):
* the pure before/after → {@link FileDiff}[] hunk builder and the defensive
* `meta` narrowing. These pin the exact hunk reconstruction (context lines,
* multi-hunk replaceAll, pure insertion/deletion, no-op) the ACP bridge renders.
*/
import { describe, expect, it } from 'vitest'
import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs'
import type { JsonValue } from '@deepseek-ai/dsh-session'
const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n'
describe('computeHunkDiffs', () => {
it('a single-line change yields one hunk with ±context lines on both sides', () => {
const before = lines(8)
const after = before.replace('line4', 'CHANGED')
const diffs = computeHunkDiffs('f.txt', before, after)
expect(diffs).toEqual([{
path: 'f.txt',
oldText: 'line1\nline2\nline3\nline4\nline5\nline6\nline7',
newText: 'line1\nline2\nline3\nCHANGED\nline5\nline6\nline7',
}])
})
it('a scattered replace_all yields one FileDiff PER hunk (matching per-site editor blocks)', () => {
const before = lines(20)
const after = before.replace('line3', 'A').replace('line16', 'B')
const diffs = computeHunkDiffs('f.txt', before, after)
expect(diffs).toHaveLength(2)
expect(diffs[0]?.path).toBe('f.txt')
expect(diffs[0]?.oldText).toContain('line3')
expect(diffs[0]?.newText).toContain('A')
expect(diffs[1]?.oldText).toContain('line16')
expect(diffs[1]?.newText).toContain('B')
// The two hunks are distinct sites, not one merged block.
expect(diffs[0]?.newText).not.toContain('B')
expect(diffs[1]?.newText).not.toContain('A')
})
it('identical before/after (a no-op) yields no hunks', () => {
expect(computeHunkDiffs('f.txt', 'same\n', 'same\n')).toEqual([])
})
it('a pure insertion into empty content reports oldText null (nothing to diff against)', () => {
const diffs = computeHunkDiffs('f.txt', '', 'brand new\n')
expect(diffs).toEqual([{ path: 'f.txt', oldText: null, newText: 'brand new' }])
})
it('a pure deletion of the whole file reports newText empty', () => {
const diffs = computeHunkDiffs('f.txt', 'gone\n', '')
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'gone', newText: '' }])
})
it('drops the "\\ No newline at end of file" marker from a no-trailing-newline change', () => {
const diffs = computeHunkDiffs('f.txt', 'x', 'y')
// The marker line (starting with "\\") must never leak into a diff block.
expect(diffs).toEqual([{ path: 'f.txt', oldText: 'x', newText: 'y' }])
expect(diffs[0]?.oldText).not.toContain('\\')
expect(diffs[0]?.newText).not.toContain('\\')
})
it('uses DIFF_CONTEXT (3) surrounding lines', () => {
expect(DIFF_CONTEXT).toBe(3)
const before = lines(20)
const after = before.replace('line10', 'CHANGED')
const [diff] = computeHunkDiffs('f.txt', before, after)
// 3 context above (7,8,9) + the change + 3 below (11,12,13) = 7 lines a side.
expect(diff?.oldText?.split('\n')).toHaveLength(7)
expect(diff?.newText.split('\n')).toHaveLength(7)
expect(diff?.oldText?.split('\n')[0]).toBe('line7')
})
})
describe('diffsFromMeta (defensive narrowing)', () => {
// The narrowing accepts an opaque JsonValue; a malformed payload is not a
// statically-valid JsonValue, so route every case through one cast helper that
// mirrors how a hand-edited/older session log delivers arbitrary shapes.
const m = (value: unknown): JsonValue | undefined => value as JsonValue | undefined
const good = { diffs: [{ path: 'f.txt', oldText: 'a', newText: 'b' }] }
it('narrows a well-formed { diffs } payload', () => {
expect(diffsFromMeta(m(good))).toEqual(good.diffs)
})
it('accepts a diff whose oldText is null (a create-style hunk)', () => {
const meta = { diffs: [{ path: 'f.txt', oldText: null, newText: 'x' }] }
expect(diffsFromMeta(m(meta))).toEqual(meta.diffs)
})
it('rejects undefined / non-object / array meta', () => {
expect(diffsFromMeta(undefined)).toBeUndefined()
expect(diffsFromMeta(null)).toBeUndefined()
expect(diffsFromMeta(m('nope'))).toBeUndefined()
expect(diffsFromMeta(m([]))).toBeUndefined()
})
it('rejects a missing / empty / non-array diffs field', () => {
expect(diffsFromMeta(m({}))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: 'x' }))).toBeUndefined()
})
it('rejects a diffs array containing a malformed entry', () => {
expect(diffsFromMeta(m({ diffs: [{ path: 'f.txt', oldText: 'a' }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [{ path: 1, oldText: 'a', newText: 'b' }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 5, newText: 'b' }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [{ path: 'f', oldText: 'a', newText: 7 }] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [null] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: ['x'] }))).toBeUndefined()
expect(diffsFromMeta(m({ diffs: [[]] }))).toBeUndefined()
})
})

View File

@@ -6,10 +6,11 @@
*/
import { describe, expect, it } from 'vitest'
import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
const READ_ALL: ReadWindow = { offset: 1, limit: 2000 }
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS }
/** Yield `text` as one chunk (whole-file read shape). */
async function* whole(text: string): AsyncIterable<string> {
@@ -34,7 +35,7 @@ describe('buildWindow', () => {
})
it('applies offset/limit', async () => {
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f')
const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f')
expect(result.lines.map(l => l.number)).toEqual([2, 3])
expect(result.totalLines).toBe(4)
})
@@ -62,7 +63,7 @@ describe('buildWindow', () => {
})
it('rejects an offset past EOF', async () => {
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' })
})
it('flushes a final line with no trailing newline', async () => {
@@ -76,9 +77,22 @@ describe('buildWindow', () => {
expect(result.totalLines).toBe(2)
})
describe('caps are per-request (the plugin config reaches the window)', () => {
it('truncates lines at a custom maxLineLength and names it in the suffix', async () => {
const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f')
expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)')
})
it('caps output at a custom maxBytes', async () => {
const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f')
expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb'])
expect(result.truncatedByBytes).toBe(true)
})
})
describe('chunked input (streamed read shape)', () => {
it('windows identically when text arrives in small chunks', async () => {
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f')
const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f')
expect(result.lines).toEqual([{ number: 2, text: 'two' }])
expect(result.totalLines).toBe(3)
})

View File

@@ -15,6 +15,7 @@ import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import type {
FsDirEntry,
FsEditOutcome,
FsEditRequest,
FsInfo,
@@ -39,7 +40,7 @@ class FakeFs extends FileSystem {
}
override async resolve(path: string): Promise<FsTarget> {
return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` }
}
override async stat(target: FsTarget): Promise<FsInfo | undefined> {
this.throwIfArmed()
@@ -54,19 +55,23 @@ class FakeFs extends FileSystem {
const content = this.files.get(target.targetKey) ?? ''
return (async function* () { yield content })()
}
override async listDir(_target: FsTarget): Promise<FsDirEntry[]> {
return []
}
override async writeText(target: FsTarget, content: string, expected?: FsWriteIntent): Promise<FsWriteOutcome> {
this.throwIfArmed()
this.writeIntents.push(expected)
const existed = this.files.has(target.targetKey)
const before = this.files.get(target.targetKey) ?? null
this.files.set(target.targetKey, content)
return { operation: existed ? 'update' : 'create', version: FsVersion('v2') }
return { operation: before !== null ? 'update' : 'create', version: FsVersion('v2'), before, after: content }
}
override async editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }): Promise<FsEditOutcome> {
this.throwIfArmed()
this.editIntents.push(expected)
const content = this.files.get(target.targetKey) ?? ''
this.files.set(target.targetKey, content.split(edit.oldString).join(edit.newString))
return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3') }
const after = content.split(edit.oldString).join(edit.newString)
this.files.set(target.targetKey, after)
return { version: FsVersion('v3'), before: content, after }
}
}
@@ -247,7 +252,7 @@ describe('read tool', () => {
})
describe('formatReadOutput footer variants', () => {
const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') }
const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 }
it('reports a byte-capped read', () => {
const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true })
@@ -352,34 +357,209 @@ describe('tool-owned presentation (pure presentCall)', () => {
return ctx.tools.get(name)?.presentCall?.(args)
}
it('read: titles by file, read kind, location with the offset line', async () => {
it('read: generic card titled by file with the read window, read kind, location with the offset line', async () => {
expect(await presentCall('read', { file_path: 'src/a.ts', offset: 12, limit: 40 })).toEqual({
title: 'Read src/a.ts', kind: 'read', rawInput: 'offset 12, limit 40',
card: 'generic', title: 'Read src/a.ts (12 - 51)', kind: 'read',
locations: [{ path: 'src/a.ts', line: 12 }],
})
})
it('read: omits rawInput and the location line when offset/limit are unset', async () => {
it('read: bare title and line-1 location when offset/limit are unset', async () => {
expect(await presentCall('read', { file_path: 'a.txt' })).toEqual({
title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt' }],
card: 'generic', title: 'Read a.txt', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
})
})
it('write: titles by file, edit kind, location', async () => {
expect(await presentCall('write', { file_path: 'out.txt', content: 'x' })).toEqual({
title: 'Write out.txt', kind: 'edit', locations: [{ path: 'out.txt' }],
it('read: "from line N" window when only offset is set', async () => {
expect(await presentCall('read', { file_path: 'a.txt', offset: 5 })).toEqual({
card: 'generic', title: 'Read a.txt (from line 5)', kind: 'read', locations: [{ path: 'a.txt', line: 5 }],
})
})
it('edit: titles by file, edit kind, an old→new rawInput summary, location', async () => {
expect(await presentCall('edit', { file_path: 'a.txt', old_string: 'foo', new_string: 'bar' })).toEqual({
title: 'Edit a.txt', kind: 'edit', rawInput: '"foo" → "bar"', locations: [{ path: 'a.txt' }],
it('write: diff card (new-file style, oldText null), location', async () => {
expect(await presentCall('write', { file_path: 'out.txt', content: 'hello' })).toEqual({
card: 'diff', title: 'Write out.txt',
diffs: [{ path: 'out.txt', oldText: null, newText: 'hello' }],
locations: [{ path: 'out.txt' }],
})
})
it('edit: clips a long old/new string in the rawInput summary', async () => {
const long = 'a'.repeat(60)
const p = await presentCall('edit', { file_path: 'a.txt', old_string: long, new_string: 'b' })
expect((p as { rawInput: string }).rawInput).toBe(`${JSON.stringify(`${'a'.repeat(40)}`)}${JSON.stringify('b')}`)
it('read: a limit with no offset windows from line 1', async () => {
expect(await presentCall('read', { file_path: 'a.txt', limit: 10 })).toEqual({
card: 'generic', title: 'Read a.txt (1 - 10)', kind: 'read', locations: [{ path: 'a.txt', line: 1 }],
})
})
it('edit: an empty old_string maps to oldText null (a whole-file replace diff)', async () => {
// presentCall runs on replay of raw logged args, which parseEditArgs does not
// gate — an empty old_string must still produce a valid diff (oldText null).
expect(await presentCall('edit', { file_path: 'a.txt', old_string: '', new_string: 'seed' })).toEqual({
card: 'diff', title: 'Edit a.txt',
diffs: [{ path: 'a.txt', oldText: null, newText: 'seed' }],
locations: [{ path: 'a.txt' }],
})
})
})
describe('result-time contextual diff (meta + presentResult)', () => {
// An edit records the applied contextual hunk on `tool/result` meta, and the
// tool's presentResult narrows it back into a `diff` result card the bridge
// renders. Drive execute end-to-end so the meta is the REAL computed hunk.
const withContext = 'a\nb\nc\nOLD\nd\ne\nf\n'
it('edit: execute attaches the applied hunk as meta { diffs }', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', withContext)
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toEqual({
diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
})
})
it('edit: presentResult turns the meta into a diff result card', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', withContext)
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'edit', { file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, { session })
const view = ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'OLD', new_string: 'NEW' }, result)
expect(view).toEqual({
card: 'diff', title: 'Edit a.txt',
diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }],
})
})
it('write OVERWRITE: execute attaches a contextual hunk; presentResult renders a diff card', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', withContext)
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'a\nb\nc\nNEW\nd\ne\nf\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toEqual({ diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'x' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: 'a\nb\nc\nOLD\nd\ne\nf', newText: 'a\nb\nc\nNEW\nd\ne\nf' }] })
})
it('write CREATE: no before-version → no meta, but presentResult still renders a whole-file diff card', async () => {
// A create has no prior content (no `meta`), yet the completed card must be a
// `diff` — an ACP tool_call_update.content REPLACES the call's content, so a
// non-diff result would clobber the pending new-file diff. The whole-file diff
// is derived from the args (oldText:null), replay-safe.
const { ctx } = await setup()
const session = { header: {} }
const result = await call(ctx, 'write', { file_path: 'new.txt', content: 'fresh\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'new.txt', content: 'fresh\n' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write new.txt', diffs: [{ path: 'new.txt', oldText: null, newText: 'fresh\n' }] })
})
it('write OVERWRITE with identical content: a before exists but yields no hunk → no meta, presentResult falls back to a whole-file diff', async () => {
const { ctx, fs } = await setup()
const session = { header: {} }
fs.files.set('key:a.txt', 'same\n')
await call(ctx, 'read', { file_path: 'a.txt' }, { session })
const result = await call(ctx, 'write', { file_path: 'a.txt', content: 'same\n' }, { session })
expect(result.isError).toBe(false)
expect(result.meta).toBeUndefined()
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'same\n' }, result)
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'same\n' }] })
})
it('presentResult returns undefined on an error result (nothing applied)', async () => {
const { ctx } = await setup()
const errorResult = { content: [{ type: 'text' as const, text: 'Error: boom' }], isError: true }
expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, errorResult)).toBeUndefined()
expect(ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, errorResult)).toBeUndefined()
})
it('edit presentResult returns undefined on malformed meta (defensive narrowing)', async () => {
// edit has no whole-file fallback (only a literal replacement), so a malformed
// meta yields the generic "updated successfully" rendering.
const { ctx } = await setup()
const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
expect(ctx.tools.get('edit')?.presentResult?.({ file_path: 'a.txt', old_string: 'x', new_string: 'y' }, badMeta)).toBeUndefined()
})
it('write presentResult falls back to a whole-file diff on malformed meta (never leaks the result text)', async () => {
// write always renders a diff card so the completed update can't clobber the
// pending diff with the model-facing text; a malformed meta falls back to the
// args-derived whole-file diff, same as a create.
const { ctx } = await setup()
const badMeta = { content: [{ type: 'text' as const, text: 'ok' }], isError: false, meta: { diffs: 'nope' } }
const view = ctx.tools.get('write')?.presentResult?.({ file_path: 'a.txt', content: 'y' }, badMeta)
expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] })
})
})
describe('read caps are plugin config', () => {
async function setupWith(config: ToolFs.Config) {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await ctx.plugin(FsPolicy)
await ctx.plugin(ToolFs, config)
return { ctx, fs: ctx.fs as FakeFs }
}
it('a configured readLimit is both the default and the cap, and the schema names it', async () => {
const { ctx, fs } = await setupWith({ readLimit: 2 })
fs.files.set('key:a.txt', 'one\ntwo\nthree\nfour')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(text(result)).toContain('(Showing lines 1-2 of 4. Use offset=3 to continue.)')
const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 })
expect(overCap.isError).toBe(true)
expect(text(overCap)).toContain('less than or equal to 2')
const readSchema = ctx.tools.schemas().find(s => s.name === 'read')
expect(JSON.stringify(readSchema)).toContain('Defaults to 2.')
})
it('a configured readMaxLineLength truncates lines at the configured length', async () => {
const { ctx, fs } = await setupWith({ readMaxLineLength: 4 })
fs.files.set('key:a.txt', 'abcdefgh')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(text(result)).toContain('1: abcd... (line truncated to 4 chars)')
})
it('a configured readMaxBytes caps the window at the configured bytes', async () => {
const { ctx, fs } = await setupWith({ readMaxBytes: 9 })
fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(text(result)).toContain('Output capped.')
expect(text(result)).not.toContain('cccc')
})
it('a configured readStreamMinSize routes smaller files to the streaming path', async () => {
const { ctx, fs } = await setupWith({ readStreamMinSize: 5 })
fs.files.set('key:a.txt', 'alpha\nbeta')
const readSpy = vi.spyOn(fs, 'readText')
const streamSpy = vi.spyOn(fs, 'streamText')
const result = await call(ctx, 'read', { file_path: 'a.txt' })
expect(result.isError).toBe(false)
expect(streamSpy).toHaveBeenCalled()
expect(readSpy).not.toHaveBeenCalled()
})
it.each([
['readLimit', { readLimit: 0 }],
['readLimit', { readLimit: 2.5 }],
['readMaxLineLength', { readMaxLineLength: -1 }],
['readMaxBytes', { readMaxBytes: Number.NaN }],
['readStreamMinSize', { readStreamMinSize: 0 }],
] as const)('rejects a non-positive or fractional %s at load', async (name, config) => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(FakeFs)
await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`))
})
it('has no default export (namespace plugin export shape)', () => {
expect('default' in ToolFs).toBe(false)
})
})

View File

@@ -8,6 +8,7 @@
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../llm/llm" },
{ "path": "../../core/tools" },
{ "path": "../../core/system-prompt" },

11
packages/hooks/README.md Normal file
View File

@@ -0,0 +1,11 @@
# hooks/ — hook bridges + shared protocol
The hooks subsystem lets users extend the agent at lifecycle points the way Claude Code and Codex do — by pointing a bridge plugin at an existing `hooks.json` (or settings) so those external shell hooks run faithfully. The canonical extension surface itself is the harness's typed interception seams ([the interception-seams RFC](../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)); a "native hook" is just an ordinary cordis plugin on those seams. These packages are the **bridges** that translate the external shell-hook protocol onto that same surface, plus the shared wire-protocol library they build on.
| Package | Role | Shape |
|---|---|---|
| `hook-protocol/` | Shared wire-protocol core: matcher primitive, exit-code/stdout codec, `runHook` (via `ctx.bash`), most-restrictive merge, `hook/*` session events | library (no plugin) |
| `hooks-claude/` | Bridge for a Claude Code `hooks.json` / settings | plugin |
| `hooks-codex/` | Bridge for a Codex `hooks.json` | plugin |
Codex deliberately reimplements a *subset* of the Claude Code protocol (same `hooks.json` shape, 5 events vs CC's many, command-only, regex-only matcher, no env/substitution), so `hook-protocol` owns the genuinely-identical primitives and each bridge owns only what differs (its per-event stdin payload, env, and the mapping of a hook's neutral outcome onto the harness's typed Decisions). See [hook-protocol/README.md](hook-protocol/README.md).

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-hook-protocol
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@deepseek-ai/dsh-hooks-claude`, `@deepseek-ai/dsh-hooks-codex`) import so neither re-implements the identical halves of the protocol.
Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.
## What's shared (here) vs. per-dialect (the bridges)
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) |
|---|---|---|
| Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) |
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** |
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision |
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
## Primitives
- **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws).
- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total.
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
## `hook/*` session events
Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog/log-events.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC.
## Input rewrite is parsed but not honored
`HookOutput.updatedInput` carries a hook's requested tool-input rewrite (CC `updatedInput`), but the harness does not honor it yet — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)). A bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.

View File

@@ -0,0 +1,34 @@
{
"name": "@deepseek-ai/dsh-hook-protocol",
"description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,166 @@
/**
* Parse a finished hook command's process outcome (exit code + stdout + stderr)
* into the dialect-neutral {@link HookOutput} both bridges map from.
*
* The exit-code contract is shared by Claude Code and Codex:
* - exit 0 → success; if stdout is structured JSON, parse it; else the plain
* stdout is available to the bridge (some events treat it as `additionalContext`).
* - exit 2 → BLOCKING error; stderr is the block reason fed back to the model.
* We surface this as `decision: 'block'` with `reason = stderr` so a bridge
* needs no separate exit-code branch — the neutral output already says "block".
* - other → non-blocking error; recorded (exitCode + stderr) but no decision.
*
* Structured-stdout fields are a SUPERSET across dialects (CC is richest); we
* parse every field we recognize and leave it to the bridge to honor only the
* subset meaningful for its dialect/hook point (Codex, e.g., ignores
* `allow`/`ask`/`updatedInput`).
*
* @module @deepseek-ai/dsh-hook-protocol/codec
*/
import type { HookOutput } from './types.ts'
/** The exit code a hook uses to signal a blocking error (stderr → model). */
const BLOCKING_EXIT_CODE = 2
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
function str(obj: Record<string, unknown>, key: string): string | undefined {
const v = obj[key]
return typeof v === 'string' ? v : undefined
}
/** Read a boolean field, or `undefined` if absent/wrong type. */
function bool(obj: Record<string, unknown>, key: string): boolean | undefined {
const v = obj[key]
return typeof v === 'boolean' ? v : undefined
}
/** A plain (non-null, non-array) object, or `undefined`. */
function obj(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/**
* The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference
* schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput.
* permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and
* ignored here (it must not become a real blocking decision).
*/
function topLevelDecisionOf(value: string | undefined): HookOutput['decision'] {
return value === 'approve' || value === 'block' ? value : undefined
}
/** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */
function permissionDecisionOf(value: string | undefined): HookOutput['decision'] {
return value === 'allow' || value === 'deny' || value === 'ask' ? value : undefined
}
/**
* Parse one finished hook command into a {@link HookOutput}. `stdout`/`stderr`
* are the captured streams; `exitCode` is the process exit (`undefined` when the
* hook could not be spawned at all). Pure and total — never throws; malformed
* JSON on a 0 exit is treated as "no structured output" (the plain stdout is
* still on the bridge to use), matching both reference engines' lenient parse of
* non-JSON stdout.
*
* `expectedEventName` is the event the hook is FIRING for (e.g. `'PreToolUse'`).
* The reference schemas key the `hookSpecificOutput` block by `hookEventName`,
* so a block whose `hookEventName` names a DIFFERENT event is malformed and its
* event-scoped fields (`permissionDecision`/`permissionDecisionReason`/
* `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a
* `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still
* surfaced (for the log/diagnostics), and the event-agnostic top-level fields
* (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`)
* are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the
* block as-is — a caller that doesn't key by event opts out of the check.
*/
export function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput {
const trimmedErr = stderr.trim()
const trimmedOut = stdout.trim()
// Keep the raw stdout verbatim: a clean-exit hook may emit PLAIN text the
// protocol renders/uses (CC output; Codex SessionStart/UserPromptSubmit
// additionalContext), so the bridge needs it even when there's no JSON.
const output: HookOutput = { exitCode, stderr: trimmedErr, stdout: trimmedOut }
// Exit 2 is a blocking error in both dialects: stderr is the reason. Surface
// it as a `block` decision so the bridge maps it uniformly with a structured
// `decision:'block'` — the exit code and the JSON channel converge here.
if (exitCode === BLOCKING_EXIT_CODE) {
output.decision = 'block'
if (trimmedErr.length > 0) output.reason = trimmedErr
}
// Structured stdout is only consulted on a clean (0) exit; on a blocking exit
// the stderr channel is authoritative. A non-zero/undefined exit other than 2
// carries no decision (the bridge records it as a non-blocking error).
if (exitCode === 0) {
// Only attempt JSON when stdout looks like a JSON object — matches the
// reference engines, which treat other stdout as plain text, not an error.
if (trimmedOut.startsWith('{')) {
let parsed: Record<string, unknown> | undefined
try {
parsed = obj(JSON.parse(trimmedOut))
} catch {
// Malformed JSON on a clean exit = no structured output (lenient, as the
// reference engines are). The plain stdout remains the bridge's to use.
parsed = undefined
}
if (parsed) applyStructured(output, parsed, expectedEventName)
}
}
return output
}
/**
* Fold a parsed structured-stdout object into `output` (mutates in place).
* `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput`
* block: a block whose `hookEventName` names a different event — OR omits it — has
* its event-scoped fields discarded (any present `hookEventName` is still recorded).
*/
function applyStructured(output: HookOutput, parsed: Record<string, unknown>, expectedEventName?: string): void {
const cont = bool(parsed, 'continue')
if (cont !== undefined) output.continue = cont
const stopReason = str(parsed, 'stopReason')
if (stopReason !== undefined) output.stopReason = stopReason
const sysMsg = str(parsed, 'systemMessage')
if (sysMsg !== undefined) output.systemMessage = sysMsg
// Top-level legacy `decision` (approve/block ONLY — allow/deny/ask there are
// invalid per both schemas) + its `reason`.
const topDecision = topLevelDecisionOf(str(parsed, 'decision'))
if (topDecision !== undefined) output.decision = topDecision
const topReason = str(parsed, 'reason')
if (topReason !== undefined) output.reason = topReason
// hookSpecificOutput: the per-event channel, keyed by `hookEventName`. The
// permissionDecision (allow/deny/ask) OVERRIDES the legacy top-level decision;
// additionalContext and updatedInput live here too.
const hso = obj(parsed.hookSpecificOutput)
if (hso) {
const eventName = str(hso, 'hookEventName')
// Always surface the discriminator (for the log/diagnostics), even on a
// mismatch — the record should show what the malformed block claimed.
if (eventName !== undefined) output.hookEventName = eventName
// The schemas key this block by event: when a caller passes the firing event
// (`expectedEventName`), the block's `hookEventName` MUST name it. A different
// name — or a MISSING one — is malformed under the keyed schema, so discard the
// event-scoped fields (a PreToolUse block must not deny a Stop hook; nor may a
// discriminator-less block silently apply PreToolUse-scoped permission fields to
// whatever event is firing). A caller that passes no expectedEventName opts out
// of the check (applies the block as-is).
if (expectedEventName !== undefined && eventName !== expectedEventName) {
return
}
const permission = permissionDecisionOf(str(hso, 'permissionDecision'))
if (permission !== undefined) output.decision = permission
const permissionReason = str(hso, 'permissionDecisionReason')
if (permissionReason !== undefined) output.reason = permissionReason
const addCtx = str(hso, 'additionalContext')
if (addCtx !== undefined) output.additionalContext = addCtx
const updated = obj(hso.updatedInput)
if (updated !== undefined) output.updatedInput = updated
}
}

View File

@@ -0,0 +1,107 @@
/**
* Append helpers for the log-only `hook/*` session events — the durable record
* that a hook ran and what it decided. Thin wrappers over `session.append` so a
* bridge does not hand-build the payloads (and so the `turn`-enclosure +
* invoked/result pairing stay consistent across both bridges).
*
* `hook/*` events are log-only (not {@link SurfaceEventType}), so they carry no
* `surfaceOp` and append with no surface intent — but, like every event, they
* must sit inside an OPEN turn (the invariants oracle rejects an un-enclosed
* event). The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/
* `Stop`) fire inside the loop's open turn by construction; `SessionStart` is the
* exception (its injected `context/message` is the durable evidence instead), so
* a bridge does NOT write `hook/*` for session-start — see the hooks RFC.
*
* @module @deepseek-ai/dsh-hook-protocol/events
*/
import type { Session } from '@deepseek-ai/dsh-session'
import type { HookDialect, HookOutput } from './types.ts'
/** What identifies a hook invocation across its invoked/result pair. */
export interface HookInvocation {
/** The open turn the invocation lives inside. */
turn: number
/** The hook point (`PreToolUse`, `Stop`, …). */
point: string
/** The bridge dialect that ran it. */
dialect: HookDialect
/** A stable id correlating the invoked event with its result. */
handlerId: string
/** The matcher-group pattern that selected it (absent for match-all). */
matcher?: string
}
/** The decided outcome half of the pair. */
export interface HookResultRecord {
turn: number
point: string
handlerId: string
/**
* The decoded outcome the run produced. {@link appendHookResult} derives the
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
* event's semantics live here, in the lib that declares it, not per-bridge.
*/
output: HookOutput
/**
* Character cap for the derived `stderrSummary`. The bound is the bridge's
* to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
* {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
*/
stderrSummaryMaxChars: number
/** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
durationMs: number
}
/**
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
* (both bridges' config default). It lives here, once, next to the truncation
* rule it bounds, so the bridges cannot drift apart on the shared event's
* default cap.
*/
export const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500
/**
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
* the config default and passes it in.
*/
export function summarizeStderr(stderr: string, maxChars: number): string | undefined {
const t = stderr.trim()
if (t.length === 0) return undefined
return t.length > maxChars ? t.slice(0, maxChars) + '…' : t
}
/** Append a `hook/invoked` provenance event to `session`. */
export function appendHookInvoked(session: Session, invocation: HookInvocation): void {
session.append('hook/invoked', {
turn: invocation.turn,
point: invocation.point,
dialect: invocation.dialect,
handlerId: invocation.handlerId,
...invocation.matcher !== undefined ? { matcher: invocation.matcher } : {},
})
}
/**
* Append a `hook/result` outcome event to `session` (pairs with a prior
* `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's
* parsed decision, else `'stop'` when it asked to halt (`continue: false`),
* else `'pass'`; `stderrSummary` is the trimmed stderr truncated to
* `record.stderrSummaryMaxChars` characters (omitted when empty); `exitCode`
* is omitted when the hook never ran.
*/
export function appendHookResult(session: Session, record: HookResultRecord): void {
const { output } = record
const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars)
session.append('hook/result', {
turn: record.turn,
point: record.point,
handlerId: record.handlerId,
decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'),
...output.exitCode !== undefined ? { exitCode: output.exitCode } : {},
...stderrSummary !== undefined ? { stderrSummary } : {},
durationMs: record.durationMs,
})
}

View File

@@ -0,0 +1,40 @@
/**
* `@deepseek-ai/dsh-hook-protocol` — the shared core of the Claude Code / Codex
* hook wire protocol. NOT a cordis plugin: it registers nothing and injects
* nothing. It is a LIBRARY of dialect-neutral primitives the two bridge plugins
* (`dsh-hooks-claude`, `dsh-hooks-codex`) import to avoid re-implementing the
* identical halves of the protocol:
*
* - {@link matchesMatcher} — the matcher primitive (literal-or-regex by dialect).
* - {@link runHook} + {@link parseHookOutput} — run a command hook via `ctx.bash`
* (stdin payload + env) and decode its exit-code/stdout/stderr into a neutral
* {@link HookOutput}.
* - {@link mergeHookOutputs} — fold multiple matched hooks into one
* most-restrictive {@link MergedHookOutcome} (deny > ask > allow).
* - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*`
* session-event helpers (declaration-merged into `SessionEventMap`);
* `appendHookResult` derives the durable `decision`/`stderrSummary` from the
* {@link HookOutput} so the shared event's semantics live in one place.
*
* Each bridge owns what genuinely DIFFERS: building the per-event stdin payload
* (CC vs Codex field sets), the dialect's env/substitution, and mapping the
* neutral outcome onto the harness's seam-specific typed Decisions.
*
* @module @deepseek-ai/dsh-hook-protocol
*/
export type {
CommandHook,
HookDialect,
HookOutput,
MatcherGroup,
MatcherMode,
} from './types.ts'
export { matchesMatcher } from './matcher.ts'
export { parseHookOutput } from './codec.ts'
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts'
export type { RunHookOptions, RunHookResult } from './runner.ts'
export { mergeHookOutputs } from './merge.ts'
export type { MergedDecision, MergedHookOutcome } from './merge.ts'
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts'
export type { HookInvocation, HookResultRecord } from './events.ts'

View File

@@ -0,0 +1,54 @@
/**
* The matcher primitive shared by both hook dialects: decide whether a matcher
* pattern selects a given query (a tool name, a session source, …).
*
* The two dialects differ ONLY in how a non-empty pattern is interpreted, so
* that single axis is the {@link MatcherMode} parameter:
* - `claude`: a pattern of purely `[A-Za-z0-9_|]+` is a LITERAL (pipe =
* exact-match alternation, e.g. `Edit|Write`); anything else is a regex.
* - `codex`: every pattern is an unanchored regex (no literal fast path).
*
* Both treat an absent / empty / `'*'` pattern as match-all, and both treat an
* invalid regex as a non-match: a broken matcher selects nothing rather than
* throwing into the loop. This is SILENT — the boolean return cannot distinguish
* "did not match" from "failed to compile", so a typo'd pattern (e.g. `[`)
* quietly disables that matcher with no warning. Surfacing bad config would need
* a diagnostic-returning variant or parse-time validation (`TODO(matcher-diagnostics)`).
*
* @module @deepseek-ai/dsh-hook-protocol/matcher
*/
import type { MatcherMode } from './types.ts'
/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
function isMatchAll(matcher: string | undefined): boolean {
return matcher === undefined || matcher === '' || matcher === '*'
}
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/
/**
* Whether `matcher` selects `query` under the given dialect {@link MatcherMode}.
* Match-all sentinels (absent/`''`/`'*'`) always match. A `claude` literal
* pattern exact-matches the query (splitting `|` into alternatives); every other
* `claude` pattern and ALL `codex` patterns are tested as an unanchored regex.
* An invalid regex matches nothing (never throws).
*/
export function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean {
if (isMatchAll(matcher)) return true
// matcher is a non-empty string past the match-all guard.
const pattern = matcher as string
if (mode === 'claude' && CLAUDE_LITERAL.test(pattern)) {
return pattern.split('|').includes(query)
}
try {
return new RegExp(pattern).test(query)
} catch {
// Invalid regex: a broken matcher selects nothing rather than throwing into
// the agent loop. This is silent — callers get `false`, indistinguishable
// from a genuine non-match, so a typo'd pattern quietly disables the matcher.
// Surfacing it needs a diagnostic-returning variant (TODO(matcher-diagnostics)).
return false
}
}

View File

@@ -0,0 +1,116 @@
/**
* Merge the outcomes of MULTIPLE hooks that matched one hook point into a single
* most-restrictive {@link MergedHookOutcome}. Both reference engines run matched
* hooks concurrently and fold their results; the precedence rules here are the
* intersection both dialects agree on (and the strictest interpretation where
* they differ), so a bridge gets one decision to map onto its seam:
*
* - **permission precedence `deny > ask > allow`**: any `deny`/`block` wins; an
* `ask` overrides `allow`; `allow`/`approve` only stands if nothing stricter
* appeared. (Claude Code's explicit precedence; Codex only ever blocks, so the
* rule degenerates correctly for it.)
* - **halt is sticky**: the first hook with `continue:false` sets `stop` and its
* `stopReason`.
* - **reasons accumulate**: block/deny reasons are joined with `\n\n` (Codex's
* `join_text_chunks`), so the model sees every objection, not just the first.
* - **context accumulates**: `additionalContext` from every hook is collected in
* order (CC concatenates; Codex keeps them as separate developer messages —
* either way the bridge gets the ordered list).
* - **systemMessages accumulate** likewise.
*
* @module @deepseek-ai/dsh-hook-protocol/merge
*/
import type { HookOutput } from './types.ts'
/** The single decision a hook point resolves to after merging all matched hooks. */
export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none'
/** The folded outcome of every hook that matched one point. */
export interface MergedHookOutcome {
/**
* The most-restrictive permission decision across all hooks (`deny` > `ask` >
* `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to
* `deny`; `approve`/`allow` both fold to `allow`.
*/
decision: MergedDecision
/** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */
reason?: string
/** `true` when any hook asked to halt (`continue:false`). */
stop: boolean
/** The first halting hook's `stopReason`, when one halted. */
stopReason?: string
/** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */
additionalContext: string[]
/** Every hook's `systemMessage`, in hook order. */
systemMessages: string[]
}
/** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */
function rank(decision: HookOutput['decision']): number {
switch (decision) {
case 'deny': case 'block': return 3
case 'ask': return 2
case 'approve': case 'allow': return 1
default: return 0 // no decision
}
}
/** Collapse a ranked decision back to the merged enum. */
function decisionForRank(maxRank: number): MergedDecision {
switch (maxRank) {
case 3: return 'deny'
case 2: return 'ask'
case 1: return 'allow'
default: return 'none'
}
}
/**
* Fold `outputs` (the results of every hook that matched a point, in hook order)
* into one {@link MergedHookOutcome} by the precedence rules above. An empty list
* yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
* caller treats that as "no hook had anything to say".
*/
export function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome {
let maxRank = 0
// Reasons collected PER RANK, so the merged reason can be the one explaining
// the WINNING decision (a deny-winning outcome surfaces deny reasons; an
// ask-winning outcome surfaces ask reasons). An `allow`'s reason is never an
// objection the model needs, so rank 1 collects none.
const reasonsByRank = new Map<number, string[]>()
let stop = false
let stopReason: string | undefined
const additionalContext: string[] = []
const systemMessages: string[] = []
for (const out of outputs) {
const r = rank(out.decision)
if (r > maxRank) maxRank = r
if ((r === 3 || r === 2) && out.reason !== undefined && out.reason.length > 0) {
const list = reasonsByRank.get(r) ?? []
list.push(out.reason)
reasonsByRank.set(r, list)
}
if (out.continue === false && !stop) {
stop = true
if (out.stopReason !== undefined) stopReason = out.stopReason
}
if (out.additionalContext !== undefined && out.additionalContext.length > 0) {
additionalContext.push(out.additionalContext)
}
if (out.systemMessage !== undefined && out.systemMessage.length > 0) {
systemMessages.push(out.systemMessage)
}
}
const reasons = reasonsByRank.get(maxRank) ?? []
return {
decision: decisionForRank(maxRank),
...reasons.length > 0 ? { reason: reasons.join('\n\n') } : {},
stop,
...stopReason !== undefined ? { stopReason } : {},
additionalContext,
systemMessages,
}
}

View File

@@ -0,0 +1,113 @@
/**
* Run one configured command hook through the `ctx.bash` executor seam and parse
* its outcome into a {@link HookOutput}. This is where the wire protocol's
* EXECUTION half lives: feed the hook its JSON payload on stdin, hand it the
* dialect's env vars, honor its timeout, capture stdout/stderr/exit, and decode.
*
* It runs hooks through `ctx.bash` (not a bespoke `spawn`) deliberately — the
* bash seam already provides the scrubbed-but-overridable env, process-group
* kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields
* are the trusted-plugin surface (added for exactly this) that a hook bridge —
* an in-process plugin, not model output — is allowed to use.
*
* @module @deepseek-ai/dsh-hook-protocol/runner
*/
import type { BashExecutor } from '@deepseek-ai/dsh-bash'
import { parseHookOutput } from './codec.ts'
import type { CommandHook, HookOutput } from './types.ts'
/**
* The reference default per-hook timeout, in ms (10 minutes) — the value both
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
* override surface.
*/
export const DEFAULT_HOOK_TIMEOUT_MS = 600_000
/** Everything a single hook invocation needs beyond its command line. */
export interface RunHookOptions {
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
payload: unknown
/** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */
env?: Record<string, string>
/** Working directory for the hook (defaults to the executor's own default when omitted). */
cwd?: string
/** Abort signal — cancels the hook run when fired (the parent step aborts). */
signal?: AbortSignal
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
trailingNewline: boolean
/**
* Timeout applied when the hook's config sets no `timeout` of its own. The
* bridge owns the default (its `defaultTimeoutMs` config, reference default
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
*/
defaultTimeoutMs: number
/**
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
* event is treated as malformed and its event-scoped fields are discarded (see
* {@link parseHookOutput}). Omit it to apply any block as-is.
*/
expectedEventName?: string
}
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
export interface RunHookResult {
output: HookOutput
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
durationMs: number
}
/**
* Run `hook` via `bash` with `options.payload` serialized to its stdin, then
* decode the result into a {@link HookOutput}. The hook's configured
* `timeoutSec` (wire unit: seconds) overrides `options.defaultTimeoutMs`.
* The command runs with the dialect's `env` merged after the executor's
* credential scrub (the trusted-plugin path). NEVER throws: an infrastructure
* failure (the executor rejecting) is surfaced as a {@link HookOutput} with
* `exitCode: undefined`, so the caller's merge logic treats it as a
* non-blocking error rather than crashing the turn. `now` is injected for
* testable durations.
*/
export async function runHook(
bash: BashExecutor,
hook: CommandHook,
options: RunHookOptions,
now: () => number,
): Promise<RunHookResult> {
const started = now()
const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '')
const request = {
command: hook.command,
timeoutMs,
stdin,
...options.cwd !== undefined ? { workdir: options.cwd } : {},
...options.env !== undefined ? { env: options.env } : {},
...options.signal ? { signal: options.signal } : {},
}
try {
const result = await bash.run(bash.resolve(request))
// BashRunResult.exitCode is `number | null` (null = died by signal); the
// protocol's exit-code contract is numeric, so a signal death maps to
// `undefined` (a non-blocking error — no clean exit code to act on).
const exitCode = result.exitCode ?? undefined
return {
output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName),
durationMs: now() - started,
}
} catch (error: unknown) {
// The executor rejects only on infrastructure faults (unusable workdir,
// missing shell). A hook that cannot run is a non-blocking error: no exit
// code, the failure on stderr for the record. The turn proceeds.
const message = error instanceof Error ? error.message : String(error)
return {
output: parseHookOutput(undefined, '', message),
durationMs: now() - started,
}
}
}

View File

@@ -0,0 +1,157 @@
/**
* Dialect-neutral vocabulary for the Claude Code / Codex hook wire protocol,
* plus the log-only `hook/*` session events. Types only — runtime helpers live
* in the sibling modules (`matcher`, `codec`, `runner`, `merge`, `events`).
*
* This package is the SHARED CORE: the truly-identical primitives both the
* `dsh-hooks-claude` and `dsh-hooks-codex` bridges build on. Each bridge owns
* its own per-dialect stdin-payload construction and decision mapping on top of
* these primitives — the divergences (which events exist, literal-vs-regex
* matching, env/substitution, snake_case extras, allow/ask support) are the
* BRIDGE's concern, not this lib's.
*
* @module @deepseek-ai/dsh-hook-protocol/types
*/
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/**
* A hook command was invoked at a hook point — log-only provenance (like
* `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
* pattern that selected it (absent for match-all), `handlerId` a stable id
* for the command (so an invoked/result pair correlates). `turn` is the open
* turn the invocation lives inside.
*/
'hook/invoked': {
turn: number
point: string
dialect: HookDialect
matcher?: string
handlerId: string
}
/**
* A hook command's outcome — log-only, paired with a prior `hook/invoked`
* (same `handlerId`). `decision` is the dialect-neutral outcome derived by
* `appendHookResult` (which owns the rule): the hook's parsed decision
* (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to
* halt via `continue:false`, else `'pass'`. `exitCode` is the process exit
* (absent if it never ran), `stderrSummary` the trimmed stderr truncated to
* the bridge's configured cap (the block reason source on exit 2),
* `durationMs` the wall-clock runtime (audit timing; snapshot replay
* normalizes it). `turn` matches the `hook/invoked`.
*/
'hook/result': {
turn: number
point: string
handlerId: string
decision: string
exitCode?: number
stderrSummary?: string
durationMs: number
}
}
}
/**
* The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex
* bridge `'codex'`. A native plugin on the interception seams is not a bridge
* and writes no `hook/*` provenance (see the interception-seams RFC).
*/
export type HookDialect = 'claude' | 'codex'
/**
* One configured command hook (the `{ type: 'command', command, timeout? }`
* shape shared by both dialects). Non-command hook types (CC's `prompt`/`agent`/
* `http`) are parsed-and-skipped by a bridge, so only this shape reaches the
* runner.
*/
export interface CommandHook {
/** The shell command line to run. */
command: string
/** Per-hook timeout in SECONDS (the wire unit); the runner converts to ms. */
timeoutSec?: number
}
/**
* One matcher group: a `matcher` pattern (absent / `''` / `'*'` = match-all)
* plus the command hooks that run when it matches. Both dialects share this
* shape (CC's `hooks.json` and Codex's `hooks.json`).
*/
export interface MatcherGroup {
matcher?: string
hooks: CommandHook[]
}
/**
* How a matcher pattern is interpreted. Claude Code uses {@link literal} when the
* pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
* mode for its dialect.
*/
export type MatcherMode = 'claude' | 'codex'
/**
* The dialect-neutral OUTCOME a hook produced, parsed from its exit code +
* stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a
* seam-specific typed Decision (PreToolDecision, PromptDecision, …). Every field
* is OPTIONAL because a hook may exercise any subset; the bridge decides which
* fields are meaningful for its hook point and which it ignores (faithful-but-
* degraded — e.g. Codex ignores `allow`/`ask`).
*/
export interface HookOutput {
/** The raw process exit code (`undefined` if the hook could not be run). */
exitCode: number | undefined
/** Trimmed stderr — the block-reason source on a blocking (exit 2) hook. */
stderr: string
/**
* Trimmed stdout, verbatim. On a clean exit a hook may emit PLAIN (non-JSON)
* stdout that the protocol renders as output (CC) or treats as
* `additionalContext` (Codex SessionStart/UserPromptSubmit) — so the bridge
* needs the raw text, not just the parsed structured fields. Empty string when
* the hook produced no stdout.
*/
stdout: string
/**
* `false` ⇒ the hook asked to halt (CC/Codex `continue:false`); pairs with
* {@link stopReason}. `true`/absent ⇒ proceed.
*/
continue?: boolean
/** Human-readable reason shown when {@link continue} is `false`. */
stopReason?: string
/**
* The neutral blocking decision a hook expressed, folded from the two channels
* the reference protocols keep DISTINCT: the legacy top-level `decision`
* (`approve`/`block` only) and `hookSpecificOutput.permissionDecision`
* (`allow`/`deny`/`ask`). We normalize them to one enum — `'block'`/`'deny'`
* forbid, `'approve'`/`'allow'` permit, `'ask'` requests confirmation — but
* `'allow'`/`'deny'`/`'ask'` arise ONLY from a `permissionDecision`, never from
* a top-level `decision` (an out-of-band `{"decision":"deny"}` is invalid and
* ignored, matching the schemas). Absent ⇒ no explicit decision (exit code governs).
*/
decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask'
/** The reason/explanation accompanying {@link decision}. */
reason?: string
/**
* The `hookSpecificOutput.hookEventName` discriminator, when the hook emitted
* a `hookSpecificOutput` block. The reference schemas key that block by event,
* so a block whose `hookEventName` names a DIFFERENT event than the one firing
* is malformed: {@link parseHookOutput} DISCARDS its event-scoped fields when
* given the firing event's `expectedEventName` (a hook claiming `PreToolUse`
* output on a `Stop` event does not affect the `Stop`). This field is still
* surfaced even on a mismatch — the record shows what the block claimed. Absent
* when the hook emitted no `hookSpecificOutput`.
*/
hookEventName?: string
/** Extra context to inject for the next model request (CC `additionalContext`). */
additionalContext?: string
/** A warning surfaced to the user (CC `systemMessage`). */
systemMessage?: string
/**
* A tool-input rewrite a hook requested (CC `updatedInput`). PARSED but NOT
* honored — input rewrite is deferred (see the interception-seams RFC); a
* bridge logs + warns when this is present.
*/
updatedInput?: Record<string, unknown>
}

View File

@@ -0,0 +1,193 @@
import { describe, expect, it } from 'vitest'
import { parseHookOutput } from '@deepseek-ai/dsh-hook-protocol'
describe('parseHookOutput — exit code semantics', () => {
it('exit 0 with no stdout is a neutral success', () => {
const out = parseHookOutput(0, '', '')
expect(out.exitCode).toBe(0)
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('exit 2 is a blocking error: stderr becomes the block decision + reason', () => {
const out = parseHookOutput(2, '', 'this command is not allowed')
expect(out.decision).toBe('block')
expect(out.reason).toBe('this command is not allowed')
expect(out.stderr).toBe('this command is not allowed')
})
it('exit 2 with empty stderr still blocks, with no reason', () => {
const out = parseHookOutput(2, '', ' ')
expect(out.decision).toBe('block')
expect(out.reason).toBeUndefined()
})
it('other non-zero exit is a non-blocking error (no decision, stderr recorded)', () => {
const out = parseHookOutput(1, '', 'some warning')
expect(out.decision).toBeUndefined()
expect(out.exitCode).toBe(1)
expect(out.stderr).toBe('some warning')
})
it('undefined exit (could not run) carries no decision', () => {
const out = parseHookOutput(undefined, '', 'spawn failed: ENOENT')
expect(out.exitCode).toBeUndefined()
expect(out.decision).toBeUndefined()
expect(out.stderr).toBe('spawn failed: ENOENT')
})
})
describe('parseHookOutput — structured stdout (exit 0 only)', () => {
it('parses top-level continue/stopReason/systemMessage', () => {
const out = parseHookOutput(0, JSON.stringify({
continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up',
}), '')
expect(out.continue).toBe(false)
expect(out.stopReason).toBe('budget exceeded')
expect(out.systemMessage).toBe('heads up')
})
it('parses legacy top-level decision + reason (approve/block ONLY)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'block', reason: 'nope' }), '').decision).toBe('block')
expect(parseHookOutput(0, JSON.stringify({ decision: 'approve' }), '').decision).toBe('approve')
})
it('a top-level decision of allow/deny/ask is INVALID and ignored (reserved for permissionDecision)', () => {
// Both reference schemas restrict the legacy top-level `decision` to
// approve/block; allow/deny/ask must come from hookSpecificOutput.permissionDecision.
expect(parseHookOutput(0, JSON.stringify({ decision: 'deny' }), '').decision).toBeUndefined()
expect(parseHookOutput(0, JSON.stringify({ decision: 'allow' }), '').decision).toBeUndefined()
expect(parseHookOutput(0, JSON.stringify({ decision: 'ask' }), '').decision).toBeUndefined()
})
it('captures hookEventName from hookSpecificOutput (the discriminator a bridge validates)', () => {
const out = parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), '')
expect(out.hookEventName).toBe('PreToolUse')
expect(out.decision).toBe('deny')
})
it('hookSpecificOutput.permissionDecision OVERRIDES the legacy top-level decision', () => {
const out = parseHookOutput(0, JSON.stringify({
decision: 'approve',
hookSpecificOutput: { permissionDecision: 'deny', permissionDecisionReason: 'denied by policy' },
}), '')
expect(out.decision).toBe('deny')
expect(out.reason).toBe('denied by policy')
})
it('parses allow/ask permissionDecision (the bridge decides whether to honor)', () => {
expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'allow' } }), '').decision).toBe('allow')
expect(parseHookOutput(0, JSON.stringify({ hookSpecificOutput: { permissionDecision: 'ask' } }), '').decision).toBe('ask')
})
it('parses additionalContext and updatedInput from hookSpecificOutput', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { additionalContext: 'remember X', updatedInput: { command: 'safe' } },
}), '')
expect(out.additionalContext).toBe('remember X')
expect(out.updatedInput).toEqual({ command: 'safe' })
})
it('an unknown decision string is ignored (not coerced)', () => {
expect(parseHookOutput(0, JSON.stringify({ decision: 'maybe' }), '').decision).toBeUndefined()
})
it('DISCARDS a hookSpecificOutput block whose hookEventName mismatches the firing event', () => {
// A PreToolUse block emitted on a Stop hook is malformed — its event-scoped
// fields must not take effect (a stray PreToolUse deny must not deny the Stop).
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'no', additionalContext: 'x', updatedInput: { command: 'y' } },
}), '', 'Stop')
expect(out.hookEventName).toBe('PreToolUse') // still recorded for the log
expect(out.decision).toBeUndefined() // event-scoped fields discarded
expect(out.reason).toBeUndefined()
expect(out.additionalContext).toBeUndefined()
expect(out.updatedInput).toBeUndefined()
})
it('APPLIES a hookSpecificOutput block whose hookEventName matches the firing event', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', additionalContext: 'x' },
}), '', 'PreToolUse')
expect(out.decision).toBe('deny')
expect(out.additionalContext).toBe('x')
})
it('applies the block when expectedEventName is omitted (opt-out) even if it names an event', () => {
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' },
}), '')
expect(out.decision).toBe('deny')
})
it('DISCARDS a block with NO hookEventName when a firing event is expected', () => {
// Under the keyed schema a missing discriminator is as malformed as a
// mismatched one: a discriminator-less block must not apply its event-scoped
// permission fields to whatever event happens to be firing.
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { permissionDecision: 'deny', additionalContext: 'x' },
}), '', 'Stop')
expect(out.hookEventName).toBeUndefined() // none to record
expect(out.decision).toBeUndefined() // event-scoped fields discarded
expect(out.additionalContext).toBeUndefined()
})
it('applies a discriminator-less block when expectedEventName is omitted (opt-out)', () => {
// With no firing event to validate against, the block applies as-is.
const out = parseHookOutput(0, JSON.stringify({
hookSpecificOutput: { permissionDecision: 'deny' },
}), '')
expect(out.decision).toBe('deny')
})
it('a mismatched block does NOT discard the event-agnostic top-level decision/continue', () => {
// Only the per-event block is scoped; top-level fields are event-agnostic.
const out = parseHookOutput(0, JSON.stringify({
decision: 'block', reason: 'top', continue: false, stopReason: 'halt',
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'allow' },
}), '', 'Stop')
expect(out.decision).toBe('block') // top-level survives; the allow block was discarded
expect(out.reason).toBe('top')
expect(out.continue).toBe(false)
expect(out.stopReason).toBe('halt')
})
it('malformed JSON on a clean exit is lenient (no structured output, no throw)', () => {
const out = parseHookOutput(0, '{ not valid json', '')
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
})
it('non-object stdout (plain text) on exit 0 is left for the bridge (no JSON attempt)', () => {
const out = parseHookOutput(0, 'just some text output', '')
expect(out.decision).toBeUndefined()
expect(out.continue).toBeUndefined()
// The raw stdout is preserved verbatim so the bridge can render/use it
// (CC output; Codex additionalContext) — trimmed.
expect(out.stdout).toBe('just some text output')
})
it('preserves raw stdout (trimmed) alongside parsed structured fields', () => {
const json = JSON.stringify({ decision: 'block' })
const out = parseHookOutput(0, ` ${json} \n`, '')
expect(out.stdout).toBe(json)
expect(out.decision).toBe('block')
})
it('stdout is empty string when the hook emits none', () => {
expect(parseHookOutput(0, '', '').stdout).toBe('')
})
it('a JSON array stdout parses but yields no fields (not an object)', () => {
// Starts with '{'? No — '[' — so it is not even attempted. Neutral.
const out = parseHookOutput(0, '[1,2,3]', '')
expect(out.decision).toBeUndefined()
})
it('structured stdout is IGNORED on a blocking (exit 2) run — stderr is authoritative', () => {
const out = parseHookOutput(2, JSON.stringify({ decision: 'approve' }), 'blocked')
// exit 2 forces block regardless of what stdout claims
expect(out.decision).toBe('block')
expect(out.reason).toBe('blocked')
})
})

View File

@@ -0,0 +1,123 @@
import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import { appendHookInvoked, appendHookResult, summarizeStderr, type HookOutput } from '@deepseek-ai/dsh-hook-protocol'
/** A {@link HookOutput} with the required stream fields defaulted. */
function output(over: Partial<HookOutput> = {}): HookOutput {
return { exitCode: 0, stderr: '', stdout: '', ...over }
}
describe('hook/* session events', () => {
it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
const ev = [...session.events].find(e => e.type === 'hook/invoked')
expect(ev?.type).toBe('hook/invoked')
if (ev?.type === 'hook/invoked') {
expect(ev.data).toMatchObject({ turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'h1', matcher: 'Bash' })
}
// Log-only: no surfaceOp on the event.
expect((ev as unknown as { surfaceOp?: unknown }).surfaceOp).toBeUndefined()
})
it('omits matcher when absent (match-all hook)', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' })
const ev = [...session.events].find(e => e.type === 'hook/invoked')
if (ev?.type === 'hook/invoked') {
expect('matcher' in ev.data).toBe(false)
}
})
it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'h1',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }),
})
const full = [...session.events].find(e => e.type === 'hook/result')
if (full?.type === 'hook/result') {
expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 })
}
// A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys.
const session2 = new Session(SessionId('s2'))
appendHookResult(session2, {
turn: 1, point: 'Stop', handlerId: 'h3',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }),
})
const sparse = [...session2.events].find(e => e.type === 'hook/result')
if (sparse?.type === 'hook/result') {
expect('exitCode' in sparse.data).toBe(false)
expect('stderrSummary' in sparse.data).toBe(false)
expect(sparse.data.decision).toBe('allow')
}
})
it('the decision falls back to stop on continue:false, else pass', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) })
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() })
// An explicit decision wins over the continue:false fallback.
appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) })
const decisions = [...session.events]
.filter(e => e.type === 'hook/result')
.map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : [])
expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']])
})
it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'long',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }),
})
const ev = [...session.events].find(e => e.type === 'hook/result')
if (ev?.type === 'hook/result') {
expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…')
}
})
it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => {
const session = new Session(SessionId('s'))
appendHookResult(session, {
turn: 1, point: 'PreToolUse', handlerId: 'edge',
stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }),
})
const ev = [...session.events].find(e => e.type === 'hook/result')
if (ev?.type === 'hook/result') {
expect(ev.data.stderrSummary).toBe('y'.repeat(500))
}
})
it('an invoked/result pair correlates by handlerId', () => {
const session = new Session(SessionId('s'))
appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' })
appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) })
const invoked = [...session.events].find(e => e.type === 'hook/invoked')
const result = [...session.events].find(e => e.type === 'hook/result')
expect(invoked?.type === 'hook/invoked' && invoked.data.handlerId).toBe('pair-1')
expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1')
})
})
describe('summarizeStderr', () => {
it('returns undefined for empty/whitespace stderr', () => {
expect(summarizeStderr('', 500)).toBeUndefined()
expect(summarizeStderr(' \n\t ', 500)).toBeUndefined()
})
it('passes through a summary at or under the cap, trimmed', () => {
expect(summarizeStderr(' blocked: bad tool ', 500)).toBe('blocked: bad tool')
expect(summarizeStderr('abc', 3)).toBe('abc')
})
it('truncates past the cap with an ellipsis', () => {
expect(summarizeStderr('abcdef', 4)).toBe('abcd…')
expect(summarizeStderr('x'.repeat(600), 500)).toBe('x'.repeat(500) + '…')
})
})

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { matchesMatcher } from '@deepseek-ai/dsh-hook-protocol'
describe('matchesMatcher — match-all sentinels (both dialects)', () => {
for (const mode of ['claude', 'codex'] as const) {
it(`${mode}: absent / empty / '*' match everything`, () => {
expect(matchesMatcher(undefined, 'Bash', mode)).toBe(true)
expect(matchesMatcher('', 'anything', mode)).toBe(true)
expect(matchesMatcher('*', 'whatever', mode)).toBe(true)
})
}
})
describe('matchesMatcher — claude dialect (literal-or-regex)', () => {
it('a pure word-char pattern is a LITERAL exact match (not substring)', () => {
expect(matchesMatcher('Bash', 'Bash', 'claude')).toBe(true)
// literal exact: "Bash" must NOT match "BashOutput" (a regex would, substring)
expect(matchesMatcher('Bash', 'BashOutput', 'claude')).toBe(false)
})
it('a pipe pattern is literal ALTERNATION (exact match any alternative)', () => {
expect(matchesMatcher('Edit|Write', 'Edit', 'claude')).toBe(true)
expect(matchesMatcher('Edit|Write', 'Write', 'claude')).toBe(true)
expect(matchesMatcher('Edit|Write', 'Read', 'claude')).toBe(false)
// still exact per-alternative, not substring
expect(matchesMatcher('Edit|Write', 'EditFile', 'claude')).toBe(false)
})
it('a non-word pattern falls through to regex (unanchored)', () => {
expect(matchesMatcher('^Bash$', 'Bash', 'claude')).toBe(true)
expect(matchesMatcher('Bash.*', 'BashOutput', 'claude')).toBe(true)
expect(matchesMatcher('.*\\.ts$', 'foo.ts', 'claude')).toBe(true)
expect(matchesMatcher('.*\\.ts$', 'foo.js', 'claude')).toBe(false)
})
})
describe('matchesMatcher — codex dialect (always regex)', () => {
it('a word pattern is an unanchored regex (substring matches, unlike claude literal)', () => {
expect(matchesMatcher('Bash', 'Bash', 'codex')).toBe(true)
// codex has NO literal fast path: "Bash" is /Bash/, so it DOES match a substring
expect(matchesMatcher('Bash', 'BashOutput', 'codex')).toBe(true)
})
it('regex alternation and anchors work', () => {
expect(matchesMatcher('Edit|Write', 'Edit', 'codex')).toBe(true)
expect(matchesMatcher('^Bash$', 'Bash', 'codex')).toBe(true)
expect(matchesMatcher('^Bash$', 'BashOutput', 'codex')).toBe(false)
})
})
describe('matchesMatcher — invalid regex is a non-match (never throws)', () => {
it('an unbalanced pattern matches nothing rather than throwing', () => {
// '(' is not the claude-literal charset, so it goes to the regex path and is invalid.
expect(() => matchesMatcher('(', 'x', 'claude')).not.toThrow()
expect(matchesMatcher('(', 'x', 'claude')).toBe(false)
expect(matchesMatcher('[', 'x', 'codex')).toBe(false)
})
})

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest'
import { mergeHookOutputs } from '@deepseek-ai/dsh-hook-protocol'
import type { HookOutput } from '@deepseek-ai/dsh-hook-protocol'
function out(over: Partial<HookOutput> = {}): HookOutput {
return { exitCode: 0, stderr: '', stdout: '', ...over }
}
describe('mergeHookOutputs — permission precedence deny > ask > allow', () => {
it('empty list yields a neutral outcome', () => {
const m = mergeHookOutputs([])
expect(m.decision).toBe('none')
expect(m.stop).toBe(false)
expect(m.additionalContext).toEqual([])
expect(m.systemMessages).toEqual([])
})
it('a single allow yields allow', () => {
expect(mergeHookOutputs([out({ decision: 'allow' })]).decision).toBe('allow')
expect(mergeHookOutputs([out({ decision: 'approve' })]).decision).toBe('allow')
})
it('deny beats ask beats allow regardless of order', () => {
expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'ask' })]).decision).toBe('ask')
expect(mergeHookOutputs([out({ decision: 'ask' }), out({ decision: 'deny' })]).decision).toBe('deny')
expect(mergeHookOutputs([out({ decision: 'deny' }), out({ decision: 'allow' })]).decision).toBe('deny')
// block folds to deny
expect(mergeHookOutputs([out({ decision: 'allow' }), out({ decision: 'block' })]).decision).toBe('deny')
})
it('no decision anywhere yields none', () => {
expect(mergeHookOutputs([out(), out()]).decision).toBe('none')
})
})
describe('mergeHookOutputs — reasons, stop, context, systemMessages accumulate', () => {
it('joins block/deny reasons with a blank line (only from blocking hooks)', () => {
const m = mergeHookOutputs([
out({ decision: 'deny', reason: 'first objection' }),
out({ decision: 'allow', reason: 'this allow reason is NOT collected' }),
out({ decision: 'block', reason: 'second objection' }),
])
expect(m.reason).toBe('first objection\n\nsecond objection')
})
it('no reason when nothing blocked', () => {
expect(mergeHookOutputs([out({ decision: 'allow' })]).reason).toBeUndefined()
})
it('surfaces the reason of the WINNING decision: an ask-winning outcome shows the ask reason', () => {
const m = mergeHookOutputs([
out({ decision: 'allow', reason: 'allow reason — not surfaced' }),
out({ decision: 'ask', reason: 'needs approval' }),
])
expect(m.decision).toBe('ask')
expect(m.reason).toBe('needs approval')
})
it('when deny wins over ask, the ask reasons are dropped (only the winning rank\'s reasons)', () => {
const m = mergeHookOutputs([
out({ decision: 'ask', reason: 'ask reason — not surfaced once deny wins' }),
out({ decision: 'deny', reason: 'the real objection' }),
])
expect(m.decision).toBe('deny')
expect(m.reason).toBe('the real objection')
})
it('stop is sticky on the first continue:false, capturing its stopReason', () => {
const m = mergeHookOutputs([
out({ continue: true }),
out({ continue: false, stopReason: 'halt now' }),
out({ continue: false, stopReason: 'second halt — ignored' }),
])
expect(m.stop).toBe(true)
expect(m.stopReason).toBe('halt now')
})
it('no stop when every hook continues', () => {
const m = mergeHookOutputs([out({ continue: true }), out()])
expect(m.stop).toBe(false)
expect(m.stopReason).toBeUndefined()
})
it('a continue:false with no stopReason stops with an undefined reason', () => {
const m = mergeHookOutputs([out({ continue: false })])
expect(m.stop).toBe(true)
expect(m.stopReason).toBeUndefined()
})
it('collects additionalContext and systemMessages in hook order, skipping empties', () => {
const m = mergeHookOutputs([
out({ additionalContext: 'ctx-A', systemMessage: 'warn-A' }),
out({ additionalContext: '', systemMessage: '' }), // empties skipped
out({ additionalContext: 'ctx-B' }),
out({ systemMessage: 'warn-B' }),
])
expect(m.additionalContext).toEqual(['ctx-A', 'ctx-B'])
expect(m.systemMessages).toEqual(['warn-A', 'warn-B'])
})
})

View File

@@ -0,0 +1,148 @@
import { describe, expect, it } from 'vitest'
import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash'
import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol'
/**
* A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook}
* actually calls (`resolve` then `run`). `runHook` is pure plumbing over those
* two methods, so a duck-typed recorder is the right test seam — the REAL
* executor (dsh-bash-local) is exercised end-to-end by the hook-bridge plugins
* that consume this library, not here.
*/
function recordingBash(run: (spec: BashExecSpec) => Promise<BashRunResult>): {
bash: BashExecutor
specs: BashExecSpec[]
} {
const specs: BashExecSpec[] = []
const bash = {
resolve(request: BashExecRequest): BashExecSpec {
// Carry the request through verbatim, defaulting the required spec fields —
// exactly what dsh-bash-local's resolve does for the fields runHook sets.
return {
command: request.command,
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
...request.stdin !== undefined ? { stdin: request.stdin } : {},
...request.env !== undefined ? { env: request.env } : {},
owner: request.owner,
}
},
async run(spec: BashExecSpec): Promise<BashRunResult> {
specs.push(spec)
return run(spec)
},
} as unknown as BashExecutor
return { bash, specs }
}
function result(over: Partial<BashRunResult> = {}): BashRunResult {
return {
exitCode: 0,
signal: null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
...over,
}
}
const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5
describe('runHook — payload + env + stdin plumbing', () => {
it('serializes the payload to stdin (with trailing newline when requested)', async () => {
const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } }))
await runHook(bash, { command: 'my-hook.sh' }, {
payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' },
defaultTimeoutMs: 60000,
trailingNewline: true,
}, clock())
expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n')
expect(specs[0]!.command).toBe('my-hook.sh')
})
it('omits the trailing newline when trailingNewline is false (Codex)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock())
expect(specs[0]!.stdin).toBe('{"a":1}')
})
it('threads env and cwd into the request', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, {
payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work',
defaultTimeoutMs: 1000, trailingNewline: true,
}, clock())
expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' })
expect(specs[0]!.workdir).toBe('/work')
})
it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(3000)
})
it('falls back to the default timeout when the hook sets none', async () => {
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock())
expect(specs[0]!.timeoutMs).toBe(60000)
expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes)
})
it('passes the abort signal through', async () => {
const controller = new AbortController()
const { bash, specs } = recordingBash(async () => result())
await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(specs[0]!.signal).toBe(controller.signal)
})
})
describe('runHook — outcome decoding + duration', () => {
it('decodes a clean exit with structured stdout and reports a duration', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false },
}))
const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.decision).toBe('block')
expect(output.reason).toBe('no')
expect(durationMs).toBe(5)
})
it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => {
const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } }))
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.decision).toBeUndefined()
expect(output.stderr).toBe('killed')
})
it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => {
const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.exitCode).toBeUndefined()
expect(output.stderr).toBe('bad workdir: ENOENT')
expect(output.decision).toBeUndefined()
})
it('a non-Error rejection is stringified onto stderr', async () => {
const { bash } = recordingBash(async () => { throw 'plain string fault' })
const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock())
expect(output.stderr).toBe('plain string fault')
})
it('threads expectedEventName so a mismatched hookSpecificOutput block is discarded', async () => {
const { bash } = recordingBash(async () => result({
exitCode: 0,
stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false },
}))
const { output } = await runHook(bash, { command: 'h' }, {
payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop',
}, clock())
// A PreToolUse block on a Stop hook is malformed → its decision is discarded.
expect(output.hookEventName).toBe('PreToolUse')
expect(output.decision).toBeUndefined()
})
})

View File

@@ -0,0 +1,24 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../bash/bash"
},
{
"path": "../../core/session"
}
]
}

View File

@@ -0,0 +1,55 @@
# @deepseek-ai/dsh-hooks-claude
A cordis plugin that runs a user's existing **Claude Code** hook config (a `hooks.json`, or a settings file's `hooks` key) on the harness's canonical interception seams. It is the **CC dialect** half of the hooks subsystem: it owns CC's per-event stdin payloads, CC's env + `${CLAUDE_PLUGIN_ROOT}`/`${CLAUDE_PROJECT_DIR}` substitution, and the mapping from a hook's neutral outcome onto the harness's typed Decisions. The dialect-agnostic primitives (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive merge, the `hook/*` events) come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md).
A native cordis plugin could do everything this bridge does — more powerfully, with typed returns and no serialization boundary. **The bridge exists only to run UNMODIFIED external CC hooks faithfully**; anything bespoke should be a native plugin on the same seams (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
## Config
```ts
import type { Config } from '@deepseek-ai/dsh-hooks-claude'
const config: Config = {
configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key
pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings
projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default)
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
}
```
In a `cordis.yml`:
```yaml
- dsh-hooks-claude:
configPath: ./.claude/hooks.json
pluginRoot: ./.claude/plugins/my-plugin
projectDir: .
```
The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default).
The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir.
## Hook points → seam Decisions
| CC hook | Harness seam | Mapping |
|---|---|---|
| `SessionStart` | `agent/session-start` (emit) | additionalContext → `agent.inject()` into the new session (cannot block) |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `deny``PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision (a later listener can still block/rewrite) |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `deny``PreToolDecision.deny`; `ask``PreToolDecision.ask` |
| `PostToolUse` | `tools/post-execute` (waterfall) | `deny``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue`, feeding its reason as next-step steering |
| `SubagentStart` | `subagent/start` (emit) | additionalContext → `agent.inject()` into the live child |
| `SubagentStop` | `subagent/end` (emit) | observe-only |
The matcher subject is the tool name (`PreToolUse`/`PostToolUse`), the session source (`SessionStart`), or a constant `agent_type` of `general-purpose` (`SubagentStart`/`SubagentStop` — the harness subagent seam carries no per-kind label, so the bridge reports Claude Code's own Task-tool default; a default/`*`/empty `agent_type` matcher fires, a specific-kind matcher does not); `UserPromptSubmit`/`Stop` ignore matchers. Multiple file-configured hooks on one point run **serially, in config order**, and fold most-restrictively (`deny > ask > allow`, see `dsh-hook-protocol`); serial keeps each hook's `hook/invoked`/`hook/result` pair adjacent in the log, and the fold is order-independent for the decision (see the RFC's "run serially, not concurrently" note).
## Context source
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-claude' }` source. `agent.inject()` defaults a missing source to `{ kind: 'user' }`, which would mislabel plugin context as a user prompt — so the bridge always names itself.
## Deferred (faithful-but-degraded)
- **`updatedInput` (tool-input rewrite)** is logged + warned, **not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)).
- **`systemMessage`** (a hook's user-facing warning) is logged + warned, **not surfaced** — there is no user-message channel on these seams yet (only model-facing `additionalContext`). The shared merge collects it; the bridge does not yet render it.
- **Stop loop-guard.** CC breaks an infinite force-continue with `stop_hook_active` (true once a Stop hook has fired this run) plus a max-consecutive cap; both are deferred (`TODO(stop-loop-guard)`). Today `stop_hook_active` is always `false`, so a Stop hook that unconditionally blocks would force-continue every step — a hook author must self-limit until the guard lands.

View File

@@ -0,0 +1,49 @@
{
"name": "@deepseek-ai/dsh-hooks-claude",
"description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^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": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,100 @@
/**
* Parse a Claude Code hook config file into the shared {@link MatcherGroup}
* shape, faithfully to CC's `hooks.json` / settings `hooks` key format.
*
* A CC config maps each event name to an array of matcher groups, each holding
* an array of typed hooks. Only `type: 'command'` hooks run here; other types
* (`prompt`/`agent`/`http`) are PARSED but skipped with a warning (faithful-but-
* degraded — the same stance Codex takes). The `command` string undergoes
* `${CLAUDE_PLUGIN_ROOT}` substitution at parse time so the runner sees a literal.
*
* @module @deepseek-ai/dsh-hooks-claude/config
*/
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
/** A parsed CC config: event name → its matcher groups (command hooks only). */
export type ClaudeHookConfig = Record<string, MatcherGroup[]>
/** A skipped non-command hook, surfaced so the bridge can warn about it. */
export interface SkippedHook {
event: string
type: string
}
/** The outcome of parsing one config file: the runnable groups + what was skipped. */
export interface ParsedClaudeConfig {
config: ClaudeHookConfig
skipped: SkippedHook[]
}
/** Substitution variables applied to each `command` string at parse time. */
export interface SubstitutionVars {
/** Replaces `${CLAUDE_PLUGIN_ROOT}` — the plugin's root dir. */
pluginRoot?: string
/** Replaces `${CLAUDE_PROJECT_DIR}` — the project root. */
projectDir?: string
}
/** A plain (non-null, non-array) object, else undefined. */
function asObject(value: unknown): Record<string, unknown> | undefined {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: undefined
}
/** Apply `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution to a command string. */
export function substituteCommand(command: string, vars: SubstitutionVars): string {
let out = command
if (vars.pluginRoot !== undefined) out = out.split('${CLAUDE_PLUGIN_ROOT}').join(vars.pluginRoot)
if (vars.projectDir !== undefined) out = out.split('${CLAUDE_PROJECT_DIR}').join(vars.projectDir)
return out
}
/**
* Parse a raw Claude Code config object (the value under the `hooks` key, or a
* `hooks.json` whose top level IS that map) into runnable {@link MatcherGroup}s.
* Non-command hooks and malformed entries are dropped (recorded in `skipped` /
* silently ignored) rather than throwing — a bad hook config must not crash boot.
* `vars` are substituted into every surviving `command`.
*/
export function parseClaudeConfig(raw: unknown, vars: SubstitutionVars = {}): ParsedClaudeConfig {
const config: ClaudeHookConfig = {}
const skipped: SkippedHook[] = []
// Accept either `{ hooks: { … } }` (a settings file) or the bare event map.
const root = asObject(raw)
const hooksMap = root ? asObject(root.hooks) ?? root : undefined
if (!hooksMap) return { config, skipped }
for (const [event, rawGroups] of Object.entries(hooksMap)) {
if (!Array.isArray(rawGroups)) continue
const groups: MatcherGroup[] = []
for (const rawGroup of rawGroups) {
const group = asObject(rawGroup)
if (!group || !Array.isArray(group.hooks)) continue
const commands: MatcherGroup['hooks'] = []
for (const rawHook of group.hooks) {
const hook = asObject(rawHook)
if (!hook) continue
const type = typeof hook.type === 'string' ? hook.type : 'command'
if (type !== 'command') {
skipped.push({ event, type })
continue
}
if (typeof hook.command !== 'string') continue
commands.push({
command: substituteCommand(hook.command, vars),
...typeof hook.timeout === 'number' ? { timeoutSec: hook.timeout } : {},
})
}
if (commands.length === 0) continue
groups.push({
...typeof group.matcher === 'string' ? { matcher: group.matcher } : {},
hooks: commands,
})
}
if (groups.length > 0) config[event] = groups
}
return { config, skipped }
}

View File

@@ -0,0 +1,414 @@
/**
* `dsh-hooks-claude` — a bridge plugin that runs a user's existing Claude Code
* hook config (`hooks.json` / a settings file's `hooks` key) on the harness's
* canonical interception seams. It is the CC DIALECT half of the hooks
* subsystem: it owns CC's per-event stdin payloads, CC's env +
* `${CLAUDE_PLUGIN_ROOT}` substitution, and the mapping from a hook's neutral
* outcome onto the harness's typed Decisions. The dialect-agnostic primitives
* (matcher, exit-code/stdout codec, `ctx.bash` execution, most-restrictive
* merge, the `hook/*` events) come from `@deepseek-ai/dsh-hook-protocol`.
*
* A native cordis plugin could do everything this bridge does — more powerfully,
* with typed returns and no serialization boundary. The bridge exists only to
* run UNMODIFIED external CC hooks faithfully; anything bespoke should be a
* native plugin on the same seams.
*
* Scope: the seven in-scope hook points (`SessionStart`, `UserPromptSubmit`,
* `PreToolUse`, `PostToolUse`, `Stop`, `SubagentStart`, `SubagentStop`). Only
* `type: 'command'` hooks run; the matcher group config + exit-code/stdout
* protocol are byte-faithful to CC. `updatedInput` (tool-input rewrite) is
* logged + warned, not honored (deferred — see the interception-seams RFC).
*
* @module @deepseek-ai/dsh-hooks-claude
*/
import { readFileSync } from 'node:fs'
import type { Context } from 'cordis'
import z from 'schemastery'
import type { Agent, ContinuationDecision, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, PreToolDecision, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import {
appendHookInvoked,
appendHookResult,
DEFAULT_HOOK_TIMEOUT_MS,
DEFAULT_STDERR_SUMMARY_MAX_CHARS,
matchesMatcher,
mergeHookOutputs,
runHook,
type HookOutput,
type MatcherGroup,
type MergedHookOutcome,
} from '@deepseek-ai/dsh-hook-protocol'
// Side-effect type import: pulls in the `subagent/start` + `subagent/end` event
// declarations (declaration-merged into cordis `Events` by dsh-subagent) so the
// SubagentStart/SubagentStop listeners below type-check.
import type {} from '@deepseek-ai/dsh-subagent'
import { parseClaudeConfig, type ClaudeHookConfig } from './config.ts'
export const name = 'hooks-claude'
// `bash` is required to run hooks; the rest are read opportunistically via
// ctx.get so a deployment can load this bridge without every seam present.
export const inject = ['bash']
/** Plugin config: where the CC hook config lives + substitution roots. */
export interface Config {
/**
* Path to a `hooks.json` or a settings file whose `hooks` key holds the config.
* PROCESS-LEVEL: read once at load, a relative path resolves against the process
* launch cwd, so one config applies to the whole process.
* TODO(per-session-hook-config): per-session discovery of a project-local
* `hooks.json` from each `session/new.cwd` is not yet implemented.
*/
configPath: string
/**
* Replaces `${CLAUDE_PLUGIN_ROOT}` in command strings (the plugin's root dir).
*/
pluginRoot?: string
/**
* Replaces `${CLAUDE_PROJECT_DIR}` in command strings AND is exported as the
* `CLAUDE_PROJECT_DIR` env var for hook processes. When omitted, the env var
* defaults per-run to the agent's session workspace (`session.header.cwd`, the
* same dir the hook runs in) — Claude Code always exports this var, and common
* unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths.
*/
projectDir?: string
/** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */
defaultTimeoutMs?: number
/** Character cap for the `hook/result` event's persisted stderr summary. */
stderrSummaryMaxChars?: number
}
export const Config: z<Config> = z.object({
configPath: z.string().required(),
pluginRoot: z.string(),
projectDir: z.string(),
defaultTimeoutMs: z.number().default(DEFAULT_HOOK_TIMEOUT_MS),
stderrSummaryMaxChars: z.number().default(DEFAULT_STDERR_SUMMARY_MAX_CHARS),
})
/** A stable per-handler id so an invoked/result pair correlates in the log. */
let handlerCounter = 0
function nextHandlerId(point: string): string {
return `claude:${point}:${++handlerCounter}`
}
/** The `{kind:'plugin'}` source stamped on every context this bridge injects. */
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' }
/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`hooks-claude: ${name} must be a positive integer`)
}
}
export function apply(ctx: Context, config: Config): void {
// Validate the cap BEFORE the config-file parse: a bad value must fail the
// load loudly, not be skipped by the parse-failure early return.
const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? DEFAULT_STDERR_SUMMARY_MAX_CHARS
assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars)
const defaultTimeoutMs = config.defaultTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS
// --- Parse the config ONCE at load. A read/parse failure is contained: the
// bridge logs and registers nothing rather than crashing boot (a typo'd path
// must not take the agent down). ---
let parsed: ClaudeHookConfig = {}
try {
const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8'))
const result = parseClaudeConfig(raw, {
...config.pluginRoot !== undefined ? { pluginRoot: config.pluginRoot } : {},
...config.projectDir !== undefined ? { projectDir: config.projectDir } : {},
})
parsed = result.config
for (const s of result.skipped) {
ctx.logger.warn(`hooks-claude: skipping unsupported "${s.type}" hook on ${s.event} (only command hooks run)`)
}
} catch (error: unknown) {
ctx.logger.warn(`hooks-claude: could not load hook config "${config.configPath}": ${String(error)} — no hooks registered`)
return
}
/**
* Run every command hook configured for `point` whose matcher selects
* `matchQuery`, with the per-event `payload` on stdin, and fold the results.
* Writes a `hook/invoked`/`hook/result` pair per hook into the session when one
* is available (the mid-turn points always have an open turn). Returns the
* merged outcome (a neutral, already-most-restrictive view) for the caller to
* map onto its seam decision. `matchQuery` is the event's matcher subject
* (tool name, session source, …); `''` for events that ignore matchers.
*/
async function runPoint(
point: string,
matchQuery: string,
payload: unknown,
opts: { agent?: Agent; turn?: number; signal?: AbortSignal },
): Promise<MergedHookOutcome> {
const groups: MatcherGroup[] = parsed[point] ?? []
const outputs: HookOutput[] = []
// Run the hook in the AGENT'S session workspace (the `session/new` cwd on the
// session header), not the executor default (the ACP server's launch dir).
// A hook that does `pwd`, reads a relative file, or writes a marker must
// operate in the user's project tree. Absent for a no-agent run (falls back
// to the executor default).
const workdir = opts.agent?.session.header.cwd
// CLAUDE_PROJECT_DIR: an explicit config value wins; otherwise default it to
// the session workspace (the same dir the hook RUNS in). Claude Code always
// exports this var, and common unmodified hooks reference `$CLAUDE_PROJECT_DIR`
// (shell expansion at run time) for project-relative paths — leaving it empty
// in the default ACP wiring (no `projectDir` configured) would break them even
// though the bridge already knows the workspace. Absent only for a no-agent run
// with no configured projectDir (nothing to point at).
const projectDir = config.projectDir ?? workdir
const hookEnv = projectDir !== undefined ? { CLAUDE_PROJECT_DIR: projectDir } : undefined
for (const group of groups) {
if (!matchesMatcher(group.matcher, matchQuery, 'claude')) continue
for (const hook of group.hooks) {
const handlerId = nextHandlerId(point)
const session = opts.agent?.session
if (session && opts.turn !== undefined) {
appendHookInvoked(session, {
turn: opts.turn, point, dialect: 'claude', handlerId,
...group.matcher !== undefined ? { matcher: group.matcher } : {},
})
}
const { output, durationMs } = await runHook(ctx.bash, hook, {
payload,
defaultTimeoutMs,
...hookEnv ? { env: hookEnv } : {},
...workdir !== undefined ? { cwd: workdir } : {},
...opts.signal ? { signal: opts.signal } : {},
trailingNewline: true,
// Discard a `hookSpecificOutput` block whose `hookEventName` names a
// different event than the one firing (the schemas key it by event).
expectedEventName: point,
}, () => performance.now())
outputs.push(output)
if (output.updatedInput !== undefined) {
ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`)
}
if (output.systemMessage !== undefined) {
ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`)
}
if (session && opts.turn !== undefined) {
appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs })
}
}
}
return mergeHookOutputs(outputs)
}
// TODO(hook-continue-false): the merge computes `merged.stop`/`stopReason` from
// a hook's `continue:false`, but no seam below honors it — there is no
// "hard-halt the whole agent" primitive on the interception seams yet (a
// Decision can block/deny/steer a single point, not stop the run). Honoring it
// needs that primitive; deferred with the loop-guard work. Until then a
// `continue:false` hook still has its per-point effect (its decision/context),
// and the halt request is recorded in the `hook/result` log but not acted on.
/** Build a HookContext from accumulated additionalContext strings, or undefined when none. */
function contextFrom(merged: MergedHookOutcome): HookContext | undefined {
if (merged.additionalContext.length === 0) return undefined
const content: ContentBlock[] = merged.additionalContext.map(text => ({ type: 'text', text }))
return { content, source: PLUGIN_SOURCE }
}
/**
* Concatenate this bridge's {@link HookContext} (`ours`, always present at the
* call sites) with a downstream listener's optional one, so folding our
* additionalContext onto a delegated decision drops neither. The merged block
* carries a single `source` — this bridge's — because a `HookContext` holds one
* `MessageSource` and the seam cannot represent mixed provenance; the rendered
* `context/message` only distinguishes by `source.kind` ('plugin'), so a
* downstream plugin's text is still correctly framed as plugin context, not a
* user prompt.
*/
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
// --- SessionStart: emit (cannot block). Inject any additionalContext into the
// agent. The matcher subject is the source.
// TODO(session-start-gating): `agent/session-start` is a SYNCHRONOUS emit and
// this hook runs on a detached `.then`, so the injected context is BEST-EFFORT
// — it is not guaranteed to land before the first turn reaches the model. A
// slow hook can miss the first request (the context then arrives as a later
// injection turn). Gating startup on the hook is a loop-level change deferred
// to the interception seams; today the contract is "injected as soon as the
// hook resolves", not "before the first request". ---
ctx.on('agent/session-start', (agent, source) => {
void runPoint('SessionStart', source, sessionStartPayload(agent, source), { agent })
.then((merged) => {
const context = contextFrom(merged)
if (context) agent.inject(context.content, { source: context.source })
})
.catch((error: unknown) => {
ctx.logger.warn(`hooks-claude: SessionStart hook failed: ${String(error)}`)
})
})
// --- UserPromptSubmit → PromptDecision. The prompt text is the payload; no
// matcher subject (CC ignores matchers for this event). ---
ctx.on('agent/prompt-submit', async (agent, content, _source, next): Promise<PromptDecision> => {
const turn = lastTurn(agent)
const merged = await runPoint('UserPromptSubmit', '', promptPayload(agent, content), { agent, turn })
if (merged.decision === 'deny') {
return { kind: 'block', reason: merged.reason ?? 'blocked by UserPromptSubmit hook' }
}
// Our hooks did not block. DELEGATE (attaching context alone is not a veto):
// a later `agent/prompt-submit` listener must still get to block or rewrite.
// Then fold our additionalContext onto its decision — a downstream block wins
// (a dropped prompt makes the context moot; `block` carries no context field).
const downstream = await next()
const ours = contextFrom(merged)
if (!ours || downstream.kind !== 'allow') return downstream
return {
kind: 'allow',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(ours, downstream.additionalContext),
}
})
// --- PreToolUse → PreToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/pre-execute', async (exec, next): Promise<PreToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PreToolUse', exec.name, preToolPayload(exec), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
if (merged.decision === 'deny') return { kind: 'deny', reason: merged.reason ?? 'blocked by PreToolUse hook' }
if (merged.decision === 'ask') return { kind: 'ask', ...merged.reason !== undefined ? { reason: merged.reason } : {} }
return next()
})
// --- PostToolUse → PostToolDecision. Matcher subject is the tool name. ---
ctx.on('tools/post-execute', async (exec, result, next): Promise<PostToolDecision> => {
const turn = lastTurn(exec.agent)
const merged = await runPoint('PostToolUse', exec.name, postToolPayload(exec, result), { ...exec.agent ? { agent: exec.agent } : {}, turn, ...exec.signal ? { signal: exec.signal } : {} })
const context = contextFrom(merged)
if (merged.decision === 'deny') {
return { kind: 'block', feedback: [{ type: 'text', text: merged.reason ?? 'blocked by PostToolUse hook' }], ...context ? { additionalContext: context } : {} }
}
// Our hooks did not block. DELEGATE so a later listener can still block/replace,
// then fold our context onto its decision (a downstream block carries it too).
const downstream = await next()
if (!context) return downstream
if (downstream.kind === 'block') {
return { ...downstream, additionalContext: concatContext(context, downstream.additionalContext) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(context, downstream.additionalContext),
}
})
// --- Stop → ContinuationDecision. CC's Stop hook can force the conversation to
// CONTINUE (block the stop) with stderr/reason as the continuation. No matcher.
// TODO(stop-loop-guard): CC breaks an infinite force-continue with
// `stop_hook_active` (set true once a Stop hook has already fired this run) plus
// a max-consecutive cap; both are deferred. Today `stop_hook_active` is always
// false, so a Stop hook that unconditionally blocks would force-continue every
// step — a hook author must self-limit until the guard lands. ---
ctx.on('agent/turn-continuation', async (agent, turn, _default, next): Promise<ContinuationDecision> => {
const merged = await runPoint('Stop', '', stopPayload(agent), { agent, turn })
if (merged.decision === 'deny') {
// A blocking Stop hook forces continuation. It carries its reason as
// next-step steering; a blocking hook that emitted no reason (exit 2, empty
// stderr) still forces the turn to continue — the block is what matters, so
// fall back to a generic steering line rather than letting the turn stop.
const text = merged.reason ?? 'continue: blocked by Stop hook'
return { action: 'continue', reason: { content: [{ type: 'text', text }], source: PLUGIN_SOURCE } }
}
return next()
})
// --- SubagentStart / SubagentStop: observe-only emits (the subagent seam is
// observe-only this cut). A SubagentStart hook's additionalContext is injected
// into the live child; SubagentStop only observes. Both look the live child up
// so the hook runs in the child's session workspace and the payload carries
// the child's session_id/cwd (see subagentPayload). The matcher subject is the
// CC-default `agent_type` (SUBAGENT_TYPE) — the harness seam carries no
// per-kind label, so a config's default/`*`/empty agent_type matcher fires and
// a specific-kind matcher does not (documented in the RFC). ---
ctx.on('subagent/start', (info) => {
const child = ctx.get('agents')?.get(info.id)
void runPoint('SubagentStart', SUBAGENT_TYPE, subagentPayload('SubagentStart', info, child), { ...child ? { agent: child } : {} })
.then((merged) => {
const context = contextFrom(merged)
if (context && child) child.inject(context.content, { source: context.source })
})
.catch((error: unknown) => { ctx.logger.warn(`hooks-claude: SubagentStart hook failed: ${String(error)}`) })
})
ctx.on('subagent/end', (info) => {
// Look up the child (still recoverable: `subagent/end` fires from the
// service's detached `.then` BEFORE the tool caller's `await run.result`
// disposes it) so the hook runs in the child's cwd, not the server default.
// No `.then`/inject follows (SubagentStop only observes), and no `turn` is
// passed (so no `hook/*` log records), so runPoint has nothing that can
// reject — no `.catch` is needed. Fire-and-forget.
const child = ctx.get('agents')?.get(info.id)
void runPoint('SubagentStop', SUBAGENT_TYPE, subagentPayload('SubagentStop', info, child), { ...child ? { agent: child } : {} })
})
}
/**
* The `agent_type` value the bridge reports for SubagentStart/Stop. The harness
* subagent seam carries no per-kind label, so the bridge uses Claude Code's own
* Task-tool default — a hooks.json with a default/`*`/empty `agent_type` matcher
* fires; a config matching a specific kind (e.g. `code-reviewer`) does not.
*/
const SUBAGENT_TYPE = 'general-purpose'
// --- Per-event stdin payloads (the CC DIALECT shape). Field names match CC's
// hook input schema; this is the part a bridge owns. ---
/** The last (open or just-closed) turn number in the agent's log, or 0. */
function lastTurn(agent: Agent | undefined): number {
if (!agent) return 0
const last = [...agent.session.events].findLast(e => e.type === 'turn/start')
/* v8 ignore next -- the `: 0` arm is a defensive fallback: lastTurn is only
called from the mid-turn seams (prompt-submit/pre-/post-execute/continuation),
which always run inside an open turn, so `last` is always a turn/start here. */
return last?.type === 'turn/start' ? last.data.turn : 0
}
/** Flatten content blocks to the text a hook payload carries (the common case). */
function blocksToText(content: ContentBlock[]): string {
return content.filter((b): b is Extract<ContentBlock, { type: 'text' }> => b.type === 'text').map(b => b.text).join('')
}
function base(agent: Agent | undefined, event: string): Record<string, unknown> {
return {
session_id: agent?.session.header.id ?? '',
cwd: agent?.session.header.cwd ?? process.cwd(),
hook_event_name: event,
}
}
function sessionStartPayload(agent: Agent, source: string): Record<string, unknown> {
return { ...base(agent, 'SessionStart'), source }
}
function promptPayload(agent: Agent, content: ContentBlock[]): Record<string, unknown> {
return { ...base(agent, 'UserPromptSubmit'), prompt: blocksToText(content) }
}
function preToolPayload(exec: ToolExecution): Record<string, unknown> {
return { ...base(exec.agent, 'PreToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId }
}
function postToolPayload(exec: ToolExecution, result: ToolExecutionResult): Record<string, unknown> {
return { ...base(exec.agent, 'PostToolUse'), tool_name: exec.name, tool_input: exec.arguments, tool_use_id: exec.callId, tool_response: blocksToText(result.content) }
}
function stopPayload(agent: Agent): Record<string, unknown> {
return { ...base(agent, 'Stop'), stop_hook_active: false }
}
/**
* Build a SubagentStart/SubagentStop payload from the CC base (the child's
* `session_id`/`cwd` when the child agent is available) plus the subagent-hook
* fields. `agent_type` is the CC-default {@link SUBAGENT_TYPE}; `stop_hook_active`
* is present on SubagentStop only (the loop-guard flag, always false this cut).
*/
function subagentPayload(event: 'SubagentStart' | 'SubagentStop', info: { id: string }, child: Agent | undefined): Record<string, unknown> {
return {
...base(child, event),
agent_id: info.id,
agent_type: SUBAGENT_TYPE,
...event === 'SubagentStop' ? { stop_hook_active: false } : {},
}
}

View File

@@ -0,0 +1,365 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, chmodSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop bridge tests: a scripted mock MODEL drives the REAL agent loop + REAL
* bash executor, and the REAL `dsh-hooks-claude` bridge runs REAL shell hook
* scripts written to a temp dir — only the model is mocked (the "prefer the real
* implementation" rule). Each test writes a `hooks.json` + executable scripts,
* loads the bridge pointed at them, and asserts the hook's effect on the loop.
*/
const dirs: string[] = []
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
/** Write a hooks.json + named executable scripts into a fresh temp dir. */
function writeConfig(hooks: unknown, scripts: Record<string, string> = {}): string {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
for (const [name, body] of Object.entries(scripts)) {
const path = join(dir, name)
writeFileSync(path, body)
chmodSync(path, 0o755)
}
return dir
}
async function harness(configDir: string, adapter: MockAdapter): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: join(configDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') { dispose(); resolve() }
})
})
}
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
/**
* Poll `predicate` until it returns true or the deadline passes. Detached
* emit-listener hooks (session-start, subagent) fire on a `.then` the test can't
* await directly; polling for the observable EFFECT is robust under load, where a
* single fixed sleep flakes ("async state is not synchronous state").
*/
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
await new Promise(r => setTimeout(r, interval))
}
}
describe('hooks-claude bridge — UserPromptSubmit', () => {
it('a UserPromptSubmit hook that exits 2 blocks the prompt (rejected turn)', async () => {
// The UserPromptSubmit hook exits 2 (blocking) with a reason on stderr.
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const block = join(dir, 'block.sh')
writeFileSync(block, '#!/usr/bin/env bash\necho "prompt denied by policy" >&2\nexit 2\n')
chmodSync(block, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: block }] }] } }))
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'do something' }])
await waitForIdle(ctx, agent)
// The prompt was blocked: model never called, turn ended rejected.
expect(adapter.requests).toHaveLength(0)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('rejected')
// The hook ran and was recorded.
expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'UserPromptSubmit')).toBe(true)
expect(events(agent).some(e => e.type === 'hook/result' && e.data.decision === 'block')).toBe(true)
})
it('a UserPromptSubmit hook printing additionalContext injects it for the model', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const ctxScript = join(dir, 'ctx.sh')
writeFileSync(ctxScript, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"remember: be brief"}}\'\n')
chmodSync(ctxScript, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { UserPromptSubmit: [{ hooks: [{ type: 'command', command: ctxScript }] }] } }))
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The injected context reached the model and is recorded with the plugin source.
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('remember: be brief')
const ctxMsg = events(agent).find(e => e.type === 'context/message')
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.source).toEqual({ kind: 'plugin', plugin: 'hooks-claude' })
})
})
describe('hooks-claude bridge — PreToolUse', () => {
it('a matching PreToolUse hook that exits 2 denies the tool (isError result), tool never runs', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const deny = join(dir, 'deny.sh')
writeFileSync(deny, '#!/usr/bin/env bash\necho "danger tool blocked" >&2\nexit 2\n')
chmodSync(deny, 0o755)
// Matcher "danger" (literal) selects only the danger tool.
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
const adapter = new MockAdapter([toolCallResponse('c1', 'danger', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'use danger' }])
await waitForIdle(ctx, agent)
expect(ran).toBe(false)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('danger tool blocked'))).toBe(true)
})
it('a PreToolUse hook whose matcher does NOT match leaves the tool alone', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const deny = join(dir, 'deny.sh')
writeFileSync(deny, '#!/usr/bin/env bash\nexit 2\n')
chmodSync(deny, 0o755)
// Matcher only targets "danger" — the "safe" tool is untouched.
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ matcher: 'danger', hooks: [{ type: 'command', command: deny }] }] } }))
const adapter = new MockAdapter([toolCallResponse('c1', 'safe', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'use safe' }])
await waitForIdle(ctx, agent)
expect(ran).toBe(true)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(false)
})
})
describe('hooks-claude bridge — PostToolUse', () => {
it('a PostToolUse hook that blocks (exit 2) turns the result into an isError with feedback', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const block = join(dir, 'block.sh')
writeFileSync(block, '#!/usr/bin/env bash\necho "output rejected, retry" >&2\nexit 2\n')
chmodSync(block, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: block }] }] } }))
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
// PostToolUse blocks AFTER the tool ran: the result is rewritten to isError + feedback.
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('output rejected, retry'))).toBe(true)
})
it('a PostToolUse hook printing additionalContext attaches it after the tool result', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const s = join(dir, 'ctx.sh')
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"note: tool was slow"}}\'\n')
chmodSync(s, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const log = events(agent)
const resultIdx = log.findIndex(e => e.type === 'tool/result')
const ctxIdx = log.findIndex(e => e.type === 'context/message')
expect(ctxIdx).toBeGreaterThan(resultIdx) // context appended AFTER the tool result
const ctxMsg = log[ctxIdx]
expect(ctxMsg?.type === 'context/message' && ctxMsg.data.content.some(b => b.type === 'text' && b.text.includes('tool was slow'))).toBe(true)
})
it('a PreToolUse permissionDecision:ask degrades to ask (the tool is gated, not run)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const s = join(dir, 'ask.sh')
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"needs approval"}}\'\n')
chmodSync(s, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] } }))
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(dir, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// `ask` degrades to deny today (FIXME permissions): the tool does not run and the result is isError.
expect(ran).toBe(false)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('needs approval'))).toBe(true)
})
})
describe('hooks-claude bridge — SessionStart', () => {
it('a SessionStart hook injects additionalContext the first request sees', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
const s = join(dir, 'start.sh')
writeFileSync(s, '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"project uses tabs"}}\'\n')
chmodSync(s, 0o755)
// matcher 'startup' selects the startup source.
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: { SessionStart: [{ matcher: 'startup', hooks: [{ type: 'command', command: s }] }] } }))
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(dir, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// session-start fires async (detached .then → agent.inject); wait for the
// injected context/message to actually land before sending, rather than a
// fixed sleep that flakes under load.
await waitFor(() => events(agent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes('project uses tabs'))))
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('project uses tabs')
})
})
describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => {
it('runs SubagentStart and SubagentStop hooks when the subagent lifecycle events fire', async () => {
const dir = mkdtempSync(join(tmpdir(), 'dsh-hooks-claude-'))
dirs.push(dir)
// Each hook touches a marker file so we can assert it ran (these events are
// observe-only — there is no decision to assert, only the side effect).
const startMarker = join(dir, 'start-ran')
const stopMarker = join(dir, 'stop-ran')
const startHook = join(dir, 'start.sh')
const stopHook = join(dir, 'stop.sh')
writeFileSync(startHook, `#!/usr/bin/env bash\ntouch "${startMarker}"\n`)
writeFileSync(stopHook, `#!/usr/bin/env bash\ntouch "${stopMarker}"\n`)
chmodSync(startHook, 0o755)
chmodSync(stopHook, 0o755)
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks: {
SubagentStart: [{ hooks: [{ type: 'command', command: startHook }] }],
SubagentStop: [{ hooks: [{ type: 'command', command: stopHook }] }],
} }))
const adapter = new MockAdapter([])
const ctx = await harness(dir, adapter)
// Drive the observe-only lifecycle events directly (no real child needed — the
// bridge just listens). The agents registry is absent here, so SubagentStart's
// child lookup yields undefined and it simply runs the hook.
ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') })
ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] })
// Both hooks run async (detached .then); poll for their marker files rather
// than a fixed sleep that flakes under load.
const { existsSync } = await import('node:fs')
await waitFor(() => existsSync(startMarker) && existsSync(stopMarker))
expect(existsSync(startMarker)).toBe(true)
expect(existsSync(stopMarker)).toBe(true)
})
})
describe('hooks-claude bridge — load resilience', () => {
it('a missing config file registers no hooks and does not crash the loop', async () => {
const adapter = new MockAdapter([textResponse('fine')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The turn ran normally — no hooks, no crash.
expect(adapter.requests).toHaveLength(1)
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
// A BLOCKING UserPromptSubmit hook: if the listener leaked past dispose it
// would veto the prompt (0 model requests) and log a hook/invoked. Build the
// ctx WITHOUT the harness's own bridge mount so this is the ONLY mount, then
// dispose it — a leaked listener fails the test (a no-op `true` hook would
// pass even leaked, so it proved nothing).
const dir = writeConfig({ UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') })
await fiber.dispose()
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone
expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) // no hook ran
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/apply', () => {
// Postmortem 0001 guard: this plugin HAS `inject = ['bash']`, so a stray
// `export default apply` would collapse the module via `unwrapExports`
// (`exports.default ?? exports`), DROP `inject`, and crash at load with
// "cannot get property … without inject". Guard the shape directly.
expect('default' in HooksClaude).toBe(false)
expect(HooksClaude.name).toBe('hooks-claude')
expect(HooksClaude.inject).toEqual(['bash'])
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(HooksClaude) as Record<string, unknown>
expect(unwrapped).toBe(HooksClaude)
expect(unwrapped.name).toBe('hooks-claude')
expect(unwrapped.inject).toEqual(['bash'])
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { parseClaudeConfig, substituteCommand } from '@deepseek-ai/dsh-hooks-claude/src/config.ts'
describe('substituteCommand', () => {
it('replaces CLAUDE_PLUGIN_ROOT and CLAUDE_PROJECT_DIR (all occurrences)', () => {
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x.sh', { pluginRoot: '/p' })).toBe('/p/x.sh')
expect(substituteCommand('${CLAUDE_PROJECT_DIR}/a ${CLAUDE_PROJECT_DIR}/b', { projectDir: '/proj' })).toBe('/proj/a /proj/b')
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}-${CLAUDE_PROJECT_DIR}', { pluginRoot: '/p', projectDir: '/d' })).toBe('/p-/d')
})
it('leaves the command untouched when no vars are supplied', () => {
expect(substituteCommand('${CLAUDE_PLUGIN_ROOT}/x', {})).toBe('${CLAUDE_PLUGIN_ROOT}/x')
})
})
describe('parseClaudeConfig', () => {
it('parses a bare event map and a settings-style { hooks: … } wrapper identically', () => {
const groups = { PreToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'x.sh' }] }] }
const bare = parseClaudeConfig(groups)
const wrapped = parseClaudeConfig({ hooks: groups })
expect(bare.config).toEqual(wrapped.config)
expect(bare.config.PreToolUse).toEqual([{ matcher: 'Bash', hooks: [{ command: 'x.sh' }] }])
})
it('carries timeout → timeoutSec and substitutes the command', () => {
const { config } = parseClaudeConfig(
{ Stop: [{ hooks: [{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/s.sh', timeout: 30 }] }] },
{ pluginRoot: '/p' },
)
expect(config.Stop).toEqual([{ hooks: [{ command: '/p/s.sh', timeoutSec: 30 }] }])
})
it('skips non-command hooks (recorded) and keeps the command ones in the same group', () => {
const { config, skipped } = parseClaudeConfig({
PreToolUse: [{ hooks: [
{ type: 'prompt', prompt: 'hi' },
{ type: 'command', command: 'ok.sh' },
{ type: 'http', url: 'http://x' },
] }],
})
expect(config.PreToolUse).toEqual([{ hooks: [{ command: 'ok.sh' }] }])
expect(skipped).toEqual([{ event: 'PreToolUse', type: 'prompt' }, { event: 'PreToolUse', type: 'http' }])
})
it('treats a hook with no `type` as a command (CC default)', () => {
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ command: 'd.sh' }] }] })
expect(config.Stop).toEqual([{ hooks: [{ command: 'd.sh' }] }])
})
it('drops malformed entries without throwing: non-array groups, non-object group/hook, missing command, empty groups', () => {
expect(parseClaudeConfig({ PreToolUse: 'nope' }).config).toEqual({})
expect(parseClaudeConfig({ PreToolUse: [42, { hooks: 'no' }, { hooks: [7, { type: 'command' }] }] }).config).toEqual({})
// a group whose only hook lacks a command string drops the whole (empty) group
expect(parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 5 }] }] }).config).toEqual({})
})
it('returns empty for a non-object / null / array top level', () => {
expect(parseClaudeConfig(null).config).toEqual({})
expect(parseClaudeConfig(42).config).toEqual({})
expect(parseClaudeConfig([1, 2]).config).toEqual({})
})
it('omits the matcher key when the group has none (match-all)', () => {
const { config } = parseClaudeConfig({ Stop: [{ hooks: [{ type: 'command', command: 's.sh' }] }] })
expect('matcher' in config.Stop![0]!).toBe(false)
})
})

View File

@@ -0,0 +1,700 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/** Targeted branch coverage for the CC bridge: option arms, warn paths, no-agent
* fallbacks, contextFrom-empty, and the detached-listener catch handlers. */
const dirs: string[] = []
afterEach(() => { for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }) })
function dir(): string { const d = mkdtempSync(join(tmpdir(), 'dsh-hc-cov-')); dirs.push(d); return d }
function sh(d: string, name: string, body: string): string {
const p = join(d, name); writeFileSync(p, body); chmodSync(p, 0o755); return p
}
function hooks(d: string, h: unknown): string {
writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json')
}
type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number }
async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
await ctx.plugin(HooksClaude, { configPath, ...opts })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] }
/** Poll until `predicate` holds or the deadline passes — robust to detached
* emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */
async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise<void> {
const deadline = Date.now() + timeout
while (!predicate()) {
if (Date.now() > deadline) throw new Error('waitFor: condition not met before deadline')
await new Promise(r => setTimeout(r, interval))
}
}
describe('hooks-claude coverage — config option arms + substitution + skip warning', () => {
it('honors pluginRoot + projectDir substitution and warns on a skipped non-command hook', async () => {
const d = dir()
// ${CLAUDE_PLUGIN_ROOT} resolves to d; the script writes its own cwd-independent marker.
const marker = join(d, 'ran')
sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
const path = hooks(d, {
PreToolUse: [{ hooks: [
{ type: 'prompt', prompt: 'skipme' }, // skipped → warn loop
{ type: 'command', command: '${CLAUDE_PLUGIN_ROOT}/h.sh' }, // substituted
] }],
})
const warn = vi.fn()
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d })
ctx.logger.warn = warn as never
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true) // substituted command ran
})
it('warns and honors updatedInput as a no-op (input rewrite deferred)', async () => {
const d = dir()
const s = sh(d, 'u.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow","updatedInput":{"command":"rewritten"}}}\'\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const warn = vi.fn()
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', { command: 'original' }), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.logger.warn = warn as never
let sawArgs: unknown
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// updatedInput is NOT honored — the tool ran with the ORIGINAL args.
expect((sawArgs as { command?: string }).command).toBe('original')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('updatedInput'))
})
})
describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () => {
it('a clean exit-0 hook with no output is a no-op (contextFrom empty → next())', async () => {
const d = dir()
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ran')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// The prompt proceeded unchanged; no context/message injected.
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(e => e.type === 'context/message')).toBe(false)
})
it('a PreToolUse hook fires for a no-agent direct tool call (no session/turn to record into)', async () => {
const d = dir()
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\necho "no" >&2\nexit 2\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
// Call execute() directly with NO agent — the bridge's no-agent/no-turn path.
const { CallId } = await import('@deepseek-ai/dsh-llm')
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: {} })
expect(ran).toBe(false)
expect(result.isError).toBe(true)
})
it('a long stderr is truncated in the hook/result summary', async () => {
const d = dir()
// Emit >500 chars of stderr then exit 2.
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true)
expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis
})
it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => {
const d = dir()
const path = hooks(d, {})
for (const bad of [0, -5, 1.5, Number.NaN]) {
const adapter = new MockAdapter([])
await expect(harness(path, adapter, { stderrSummaryMaxChars: bad }))
.rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/)
}
})
it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => {
const d = dir()
const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 })
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…')
})
})
describe('hooks-claude coverage — Stop continuation + subagent inject/catch', () => {
it('a Stop hook that blocks (exit 2) forces the turn to continue (CC dialect)', async () => {
const d = dir()
const marker = join(d, 'fired')
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "continue please" >&2\nexit 2\n`)
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(2)
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('continue please')
})
it('a Stop hook that blocks with EMPTY stderr still forces continuation (no reason required)', async () => {
// Regression: a blocking Stop hook (exit 2) with no stderr yields decision
// 'deny' + reason undefined; the turn must STILL force-continue (the block is
// what matters), not silently stop. Self-limit to one block so it can't loop.
const d = dir()
const marker = join(d, 'fired')
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`)
const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// A second model request ran → the empty-reason block forced continuation.
expect(adapter.requests).toHaveLength(2)
// The steering carried the fallback reason (no stderr to use).
expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook')
})
it('SubagentStart additionalContext is injected into a REGISTERED live child', async () => {
const d = dir()
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"child guidance"}}\'\n')
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
// Register a fake child agent under the id the event carries.
const injected: string[] = []
const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child)
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') })
await waitFor(() => injected.includes('child guidance'))
expect(injected).toContain('child guidance')
})
it('a throwing SubagentStart/SubagentStop hook run is contained (logged)', async () => {
const d = dir()
// A hook command that does not exist makes runHook resolve a non-blocking
// error (not a throw), so to hit the .catch we make the .then throw: register
// a child whose inject throws for SubagentStart.
const s = sh(d, 'sa.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SubagentStart","additionalContext":"x"}}\'\n')
const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
const warn = vi.fn(); ctx.logger.warn = warn as never
const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters<typeof ctx.agents.register>[0]
ctx.agents.register(child)
ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') })
await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed')))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed'))
})
})
describe('hooks-claude coverage — default reasons + sparse payloads', () => {
it('PreToolUse deny with EMPTY stderr uses the default reason', async () => {
const d = dir()
const s = sh(d, 'deny.sh', '#!/usr/bin/env bash\nexit 2\n') // exit 2, no stderr
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true)
})
it('PostToolUse deny with EMPTY stderr + no context uses the default feedback', async () => {
const d = dir()
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PostToolUse hook'))).toBe(true)
})
it('SubagentStop with no registered child runs the hook cleanly (fire-and-forget)', async () => {
const d = dir()
// The agents registry has no entry for the id, so the child lookup yields
// undefined and the payload falls back to base(undefined) — assert the
// observe-only SubagentStop run still executes the hook without crashing.
const marker = join(d, 'stopran')
const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] })
const ctx = await harness(path, new MockAdapter([]))
ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' })
await waitFor(() => existsSync(marker))
expect(existsSync(marker)).toBe(true)
})
})
describe('hooks-claude coverage — more default/sparse arms', () => {
it('UserPromptSubmit deny with EMPTY stderr uses the default block reason', async () => {
const d = dir()
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('no')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'rejected' && turnEnd.data.reason.reason).toContain('blocked by UserPromptSubmit hook')
})
it('a PreToolUse ask with NO reason omits the reason (false arm)', async () => {
const d = dir()
const s = sh(d, 'ask.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask"}}\'\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// ask (no reason) → degrades to deny with the registry's generic message.
expect(ran).toBe(false)
expect(events(agent).some(e => e.type === 'tool/result' && e.data.isError)).toBe(true)
})
it('a recorded clean exit-0 hook with no stderr omits exitCode-extra/stderrSummary fields', async () => {
const d = dir()
const s = sh(d, 'noop.sh', '#!/usr/bin/env bash\nexit 0\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0)
expect(res?.type === 'hook/result' && 'stderrSummary' in res.data).toBe(false)
})
})
describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => {
it('a direct apply() (schema bypass) with only configPath runs', async () => {
const d = dir()
const marker = join(d, 'ran')
const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`)
hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
// Direct apply with only configPath — bypasses schemastery's defaults, so
// the bridge must run on the raw minimal config (the per-hook timeout is
// the protocol lib's reference default, not a config knob).
HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(existsSync(marker)).toBe(true)
})
it('a non-zero non-2 hook exit (e.g. a command-not-found 127) is a non-blocking error; the tool still runs', async () => {
const d = dir()
// `bash -c` of a missing program exits 127 — a non-blocking error (not 0, not
// 2 → no decision), so the tool proceeds; the hook/result records exit 127.
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: '/nonexistent/definitely/not/a/command' }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(ran).toBe(true)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.exitCode).toBe(127)
})
it('a PostToolUse deny with empty stderr + no context uses the default feedback (no context arm)', async () => {
const d = dir()
const s = sh(d, 'block.sh', '#!/usr/bin/env bash\nexit 2\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
})
})
describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => {
it('a {"continue":false} hook is RECORDED as decision "stop" but does not halt the run (TODO(hook-continue-false))', async () => {
// Honoring `continue:false` (hard-halt the whole run) is deferred — there is
// no such primitive on the interception seams yet. So this asserts the LOG
// faithfully records the halt request (decision "stop"), AND that the run is
// NOT actually halted: the tool still runs and the turn completes normally.
const d = dir()
const s = sh(d, 'stop.sh', '#!/usr/bin/env bash\necho \'{"continue":false,"stopReason":"halt"}\'\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded
expect(ran).toBe(true) // NOT honored: the tool still ran (halt is deferred)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('completed') // ran to completion
})
it('a PostToolUse hook that BOTH blocks AND attaches additionalContext', async () => {
const d = dir()
const s = sh(d, 'b.sh', '#!/usr/bin/env bash\necho \'{"decision":"block","reason":"bad","hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"context too"}}\'\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('bad'))).toBe(true)
// additionalContext also injected (the block + context arm).
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('context too')))).toBe(true)
})
it('a PreToolUse hook whose hookSpecificOutput names a DIFFERENT event does NOT deny the tool', async () => {
// The block's hookEventName (UserPromptSubmit) mismatches the firing event
// (PreToolUse), so its permissionDecision:"deny" is discarded — the tool runs.
const d = dir()
const s = sh(d, 'x.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","permissionDecision":"deny"}}\'\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
let ran = false
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran
})
it('defaults CLAUDE_PROJECT_DIR to the session workspace when no projectDir is configured', async () => {
// The default ACP wiring sets no projectDir. A stock CC hook that references
// $CLAUDE_PROJECT_DIR (shell expansion) must still get the session workspace,
// not an empty string. The hook echoes the var as additionalContext.
const d = dir()
const workspace = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\nprintf \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"dir=%s"}}\' "$CLAUDE_PROJECT_DIR"\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ran')])
const ctx = await harness(path, adapter) // NB: no projectDir
// The factory create() path honors meta.cwd (the plain agentLoop.create() does not).
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message'
&& e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true)
await handle.dispose()
})
it('a context-only UserPromptSubmit hook DELEGATES so a later listener can still block', async () => {
// A hook that only adds context must NOT short-circuit the waterfall: a
// downstream agent/prompt-submit listener (a policy plugin) must still get to
// block the prompt. The bridge delegates via next() and folds its context.
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"bridge ctx"}}\'\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(path, adapter)
// A later listener that blocks every prompt (registered AFTER the bridge).
const { AgentId: AId } = await import('@deepseek-ai/dsh-agent')
ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' }))
const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
// the downstream block won: the model was never called, no user/message was
// recorded, and the (sole, fully-blocked) prompt closed the turn `rejected`
expect(adapter.requests).toHaveLength(0)
expect(events(agent).some(e => e.type === 'user/message')).toBe(false)
const turnEnd = events(agent).findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'rejected', reason: 'policy veto' })
})
it('folds the bridge additionalContext WITH a downstream listener that also adds context', async () => {
// Both the bridge hook and a later prompt-submit listener attach context; the
// request must see BOTH (concatContext keeps the downstream one too).
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"from-bridge"}}\'\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
ctx.on('agent/prompt-submit', async () => ({
kind: 'allow' as const,
content: [{ type: 'text' as const, text: 'rewritten-prompt' }],
additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const req = JSON.stringify(adapter.requests[0]!.messages)
expect(req).toContain('from-bridge')
expect(req).toContain('from-downstream')
expect(req).toContain('rewritten-prompt') // downstream content rewrite preserved
// the original prompt was replaced by the downstream rewrite
const userMsg = events(agent).find(e => e.type === 'user/message')
expect(userMsg?.type === 'user/message' && userMsg.data.content.some(b => b.type === 'text' && b.text === 'rewritten-prompt')).toBe(true)
})
it('folds the bridge PostToolUse context onto a downstream ACCEPT that replaces content', async () => {
// The bridge hook adds context; a later post-execute listener accepts with a
// content rewrite. Both the rewrite and the bridge context survive.
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true)
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
it('folds the bridge PostToolUse context onto a downstream listener BLOCK', async () => {
// The bridge hook only adds context; a later post-execute listener blocks the
// result. The block wins AND carries the bridge context (concatContext on the
// block arm).
const d = dir()
const s = sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"PostToolUse","additionalContext":"bridge-note"}}\'\n')
const path = hooks(d, { PostToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const result = events(agent).find(e => e.type === 'tool/result')
expect(result?.type === 'tool/result' && result.data.isError).toBe(true)
expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text.includes('downstream-block'))).toBe(true)
// the bridge's context still landed (folded onto the block)
expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('bridge-note')))).toBe(true)
})
})
describe('hooks-claude coverage — executor reject + no-open-turn', () => {
it('when the bash executor REJECTS a hook run, the hook/result omits exitCode (non-blocking)', async () => {
const d = dir()
const s = sh(d, 'h.sh', '#!/usr/bin/env bash\nexit 0\n')
const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = await harness(path, adapter)
// Force the executor to reject (an infrastructure fault) so runHook's catch
// yields a HookOutput with exitCode undefined → the `exitCode` spread false arm.
const bash = ctx.bash
bash.run = (() => Promise.reject(new Error('executor down')))
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const res = events(agent).find(e => e.type === 'hook/result')
expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false)
})
})
describe('hooks-claude coverage — detached-listener catch handlers', () => {
it('a throwing SessionStart inject is contained (logged, agent still runs)', async () => {
const d = dir()
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"x"}}\'\n')
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Make inject throw, forcing the SessionStart .catch path.
const original = agent.inject.bind(agent)
let threw = false
agent.inject = (() => { threw = true; throw new Error('inject boom') })
await waitFor(() => threw)
expect(threw).toBe(true)
agent.inject = original
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // loop survived the thrown inject
})
})
describe('hooks-claude coverage — hook runs in the session cwd, not the server cwd', () => {
it('runs an agent-scoped hook in the session workspace even when the executor default differs', async () => {
// The bug: the bridge passed no workdir, so hooks ran in the executor default
// (the server launch dir), not session/new.cwd. Here the executor default and
// the session cwd are DIFFERENT temp dirs; a PreToolUse hook writes `pwd` to a
// marker and we assert it ran in the SESSION cwd.
const serverDir = dir()
const sessionDir = dir()
const marker = join(sessionDir, 'where')
// The hook is invoked with cwd = session dir, so a relative marker path lands there.
hooks(serverDir, { PreToolUse: [{ hooks: [{ type: 'command', command: 'pwd > where' }] }] })
const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')])
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the session cwd).
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], adapter)
ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
const { SessionId } = await import('@deepseek-ai/dsh-session')
const handle = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } })
handle.agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, handle.agent as ReactLoopAgent)
expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir
const { readFileSync } = await import('node:fs')
const where = readFileSync(marker, 'utf8').trim()
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
expect(where.endsWith(sessionDir.split('/').pop()!)).toBe(true)
await handle.dispose()
})
it('runs a SubagentStop hook in the CHILD session workspace, not the server cwd', async () => {
// SubagentStop looks the child up (recoverable at subagent/end) and runs the
// hook in the CHILD's session cwd, not the executor default. Here the executor
// default and the child session cwd are DIFFERENT dirs; a SubagentStop hook
// writes `pwd` to a relative marker and we assert it landed in the CHILD dir —
// which only holds if the listener threaded the child agent into runPoint.
const serverDir = dir()
const childDir = dir()
const marker = join(childDir, 'stopwhere')
hooks(serverDir, { SubagentStop: [{ hooks: [{ type: 'command', command: 'pwd > stopwhere' }] }] })
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
// Executor default cwd = serverDir (deliberately NOT the child session cwd).
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, cwd: serverDir })
await ctx.plugin(HooksClaude, { configPath: join(serverDir, 'hooks.json') })
ctx.llm.registerAdapter(['mock'], new MockAdapter([]))
// Register a live child on its own session cwd; emit subagent/end with its id.
const { SessionId } = await import('@deepseek-ai/dsh-session')
const childHandle = ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } })
ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' })
await waitFor(() => existsSync(marker))
expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir
const { readFileSync } = await import('node:fs')
const where = readFileSync(marker, 'utf8').trim()
// `pwd` may resolve symlinks (/var → /private/var etc.), so compare basenames.
expect(where.endsWith(childDir.split('/').pop()!)).toBe(true)
await childHandle.dispose()
})
})
describe('hooks-claude coverage — systemMessage is warned, not surfaced', () => {
it('a hook emitting a systemMessage is logged as not-yet-surfaced', async () => {
const d = dir()
const s = sh(d, 'sm.sh', '#!/usr/bin/env bash\necho \'{"systemMessage":"heads up"}\'\n')
const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
const warn = vi.fn(); ctx.logger.warn = warn as never
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage'))
// Not surfaced: the systemMessage text never reaches the model request.
expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up')
})
})
describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait)', () => {
it('does NOT crash or block when the prompt is sent immediately (context is best-effort, may miss the first request)', async () => {
// Regression for the documented downgrade: session-start injection is
// detached, so a prompt sent immediately need not observe it. This asserts
// the SAFE properties (no crash, the turn still runs) WITHOUT waiting for the
// inject first — it documents the best-effort timing rather than masking it
// by pre-waiting for context/message (which the guaranteed-timing tests do).
const d = dir()
const s = sh(d, 'start.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"late ctx"}}\'\n')
const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] })
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(path, adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Send immediately — do NOT wait for the session-start inject.
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1) // the turn ran regardless of hook timing
})
})

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../hook-protocol"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/session"
},
{
"path": "../../subagent/subagent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../bash/bash"
}
]
}

View File

@@ -0,0 +1,59 @@
# @deepseek-ai/dsh-hooks-codex
A cordis plugin that runs a user's existing **Codex** `hooks.json` on the harness's canonical interception seams. The **Codex dialect** half of the hooks subsystem. The dialect-agnostic primitives come from [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md); this bridge owns the Codex-specific payloads, matcher mode, and decision mapping.
Codex's hook protocol is a deliberate **subset** of Claude Code's (same `hooks.json` shape):
- **Five hook points only:** `PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop` — no subagent / notification / compaction hooks.
- **Regex-only matchers** (no literal fast path; the matcher is always an unanchored regex).
- **snake_case stdin payloads** with `turn_id`/`model` extras, written **without** a trailing newline.
- **No env vars and no command substitution** (a literal `${…}` in a command survives verbatim).
- **A block-only decision model** — `allow`/`ask` are not honored; a hook can only block, never pre-approve.
A native cordis plugin could do everything this bridge does, more powerfully; the bridge exists only to run UNMODIFIED external Codex hooks faithfully (see [the interception-seams RFC](../../../docs/rfc/implemented/feature/2026-06-30-interception-seams.md)).
## Config
```ts
import type { Config } from '@deepseek-ai/dsh-hooks-codex'
const config: Config = {
configPath: '/path/to/.codex/hooks.json', // required
model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`)
defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none
stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary
}
```
In a `cordis.yml`:
```yaml
- dsh-hooks-codex:
configPath: ./.codex/hooks.json
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse.
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.
## Hook points → seam Decisions
| Codex hook | Harness seam | Mapping |
|---|---|---|
| `SessionStart` | `agent/session-start` (emit) | a plain-stdout hook's output → additionalContext → `agent.inject()` |
| `UserPromptSubmit` | `agent/prompt-submit` (waterfall) | `block` (exit 2) → `PromptDecision.block`; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `PreToolUse` | `tools/pre-execute` (waterfall) | `block``PreToolDecision.deny` (no `allow`/`ask`) |
| `PostToolUse` | `tools/post-execute` (waterfall) | `block``block` with feedback; additionalContext-only → delegate via `next()` then fold context onto the downstream decision |
| `Stop` | `agent/turn-continuation` (waterfall) | a blocking Stop hook forces `continue` with the reason as next-step steering |
A tool call's payload carries the real `tool_name` (the same value the matcher tests) and Codex's `tool_input: { command }` shape (the `command` arg when present, else `''`). The matcher subject is the tool name (`PreToolUse`/`PostToolUse`) or the session source (`SessionStart`); `UserPromptSubmit`/`Stop` ignore matchers.
## Context source
Injected context carries an explicit `{ kind: 'plugin', plugin: 'hooks-codex' }` source (`agent.inject()` would otherwise default it to `{ kind: 'user' }`).
## Deferred
**Stop loop-guard** (`TODO(stop-loop-guard)`): as in CC, a Stop hook that unconditionally blocks would force-continue every step (`stop_hook_active` is always `false` here); the loop-guard is deferred. A hook author must self-limit until it lands.
**`systemMessage`**: a hook's user-facing warning is logged + warned, not surfaced — there is no user-message channel on these seams yet (only model-facing `additionalContext`).

View File

@@ -0,0 +1,47 @@
{
"name": "@deepseek-ai/dsh-hooks-codex",
"description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-hook-protocol": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^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": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-hook-protocol": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

Some files were not shown because too many files have changed in this diff Show More