Merge remote-tracking branch 'origin/master' into session-surface

Reconcile the session-surface feature with master's package reorg and
simplifications:

- Adopt master's folded usage (assistant/message.usage; standalone `usage`
  event dropped) and re-attach surface metadata (surfaceOp/sourceEventSeqs).
- Add surface opts to master's new max-tokens assistant/message append.
- Port surface columns onto the coordinator-refactored SQLite backend at its
  new path; drop the dead v1->v2 migration (bump-and-reject, no migration per
  pre-release policy).
- Move the session-surface RFC into implemented/architecture/ and refresh its
  stale body (no migration, SESSION_FORMAT_VERSION=0, renamed package paths).
- Update the core-data-structures catalog SessionEvent blocks for the two new
  surface fields; regenerate the cordis catalog.
- Re-harvest ACP snapshot fixtures (keyless replay) to carry surface metadata.
This commit is contained in:
Hypatia May
2026-06-22 10:35:59 +08:00
388 changed files with 22901 additions and 6944 deletions

View File

@@ -5,12 +5,14 @@ This directory contains all `@deepseek-ai/dsh-*` harness packages. When editing
- **Effect-based registrations**: every contribution (tool, section, adapter, agent, event listener) goes through `ctx.effect()` / `ctx.on()`, and `register()` methods return disposers. Never use bare arrays or manual cleanup.
- **Declaration merging**: services declare their ctx key in `declare module 'cordis' { interface Context { } }` and their events in `interface Events`. Merge-extensible maps (`ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, `SessionEventMap`) are how plugins add new variants.
- **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)`; call `next()` to delegate, or return without it to short-circuit (veto). Never call `next()` after returning.
- **Tests**: vitest in `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test (register a plugin, dispose its fiber, assert cleanup). Err on the side of more tests — edge cases, error paths, event ordering, races.
- **Plugin export shape — namespace OR default, never both.** A *service* package exports the service class as `export default` (the Loader instantiates it). A *function/namespace* plugin exports `name` / `inject` / `Config` / `apply` as separate named exports and **must NOT add `export default`** — the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default export collapses the module to the bare `apply` function and silently discards the `inject`/`name`/`Config` namespace, leaving the plugin with no injected services (it then throws `cannot get property … without inject` at load). See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Read an optional (non-injected) service via `ctx.get(name)`, not `ctx.<name>`.** For a service a plugin reads opportunistically but deliberately leaves out of `static inject` (e.g. `AgentLoop` reading `sessionPersistence`), the `ctx.<name>` property proxy resolves by an ancestor-only fiber walk that throws when the call arrives through a foreign traceable shadow (the service lives on a sibling fiber). `ctx.get(name)` is the topology-independent global-store lookup, strict by default (an inactive/absent backend reads as `undefined` — prefer it over the `ctx.get(name, false)` overload, which also skips the active-state check). Services that ARE in `static inject` resolve fine via `ctx.<name>`. See [docs/postmortem/0001](../docs/postmortem/0001-acp-default-export-drops-inject.md).
- **Tests**: vitest in `packages/<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.
Naming notes:
- Files `src/index.ts` export the service default + all public types
- A *service* `src/index.ts` exports the service class as `export default` + all public types; a *function/namespace plugin* `src/index.ts` exports `name`/`inject`/`Config`/`apply` as named exports and NO default (see the plugin-export-shape rule above)
- `src/types.ts` contain only types — no runtime code
- Tests live at package level under `tests/`, not `src/__tests__/`
- A package's README and module/JSDoc comments are part of the change: when you alter behavior (config keys, defaults, error codes, wire fields), update them in the same commit. CI runs `pnpm run doc-sync`, which typechecks fenced `ts` blocks in `packages/*/README.md` and verifies the event-taxonomy table — but it does NOT cover this file or prose drift (config keys, defaults, error codes), so those stay on the author.
- 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).
Read the per-package README.md for package-specific details: service API, events, extension points, TODOs.

View File

@@ -1,15 +1,32 @@
# Packages
Harness packages, all under the `@deepseek-ai/dsh-*` scope. Each package is a Cordis service (microkernel plugin-style): it exports a default `Service` class that gets registered via `ctx.plugin()`, declares its ctx key and events 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 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()`.
## 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.
| Group | Role | Release expectation |
|---|---|---|
| [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface |
| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface |
| [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | 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 |
| [`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).
## Dependency graph
```
dsh-llm (no harness deps — pure vocabulary)
dsh-bash (no harness deps — abstract executor seam)
dsh-session ← dsh-llm
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-agent ← dsh-llm, dsh-session, dsh-brand
dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent
dsh-bash-local ← dsh-bash (BashExecutor impl)
dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas)
@@ -17,26 +34,42 @@ 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-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 (ACP JSON-RPC bridge)
dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin)
dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests)
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-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin)
dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin)
```
The rule: plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/2026-06-13-capability-seams.md)).
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 | Role | ctx key |
|---|---|---|
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `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` |
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
| `agent-loop/` | THE concrete plugin: `LoopAgent` + the loop driver | `ctx.agentLoop` |
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
| 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` |
| `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`) |
| `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`) |
| `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`) |
| `brand/` | `util` | Type-only `Branded<B>` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) |
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).
@@ -47,4 +80,4 @@ Each package has its own `README.md` with purpose, service API, events, extensio
- **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, `.ts` extensions within a package.
- **Tests**: vitest, colocated under `packages/<name>/tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests.
- **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

@@ -1,19 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../../vendor/cosmokit" },
{ "path": "../../vendor/cordis" },
{ "path": "../../vendor/schemastery" },
{ "path": "../llm" },
{ "path": "../session" },
{ "path": "../session-persistence" },
{ "path": "../system-prompt" },
{ "path": "../tools" },
{ "path": "../agent" }
]
}

View File

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

View File

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

View File

@@ -1,30 +1,11 @@
# @deepseek-ai/dsh-bash
# bash/ — bash capability family
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
The canonical three-package capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract executor interface, a concrete local implementation, and the model-facing tool that consumes it. All **product** packages.
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
| Package | Role | ctx key |
|---|---|---|
| `bash/` | Abstract bash executor seam (interface + vocabulary) | `ctx.bash` |
| `bash-local/` | Local-subprocess `BashExecutor` implementation | (registers `ctx.bash`) |
| `tool-bash/` | Model-facing `bash`/`bash_output`/`bash_kill` tool schemas | (registers on `ctx.tools`) |
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
## Service API (`ctx.bash`)
| Member | Semantics |
|---|---|
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
| `get(id)` / `list()` | Task lookup. |
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. |
Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests.
## Vocabulary
`BashExecSpec` (command, workdir?, timeoutMs?, signal?) → `BashRunResult` (exitCode, signal, timedOut, aborted, timeoutMs, stdout/stderr as `CollectedOutput`) and `BashTask`/`BashTaskRead` for the background side. See `src/types.ts` for the full contracts.
The interface lives at `bash/bash/`. A sandboxed executor would replace `bash-local` without touching the interface or the tool — the split is what makes that possible.

View File

@@ -18,11 +18,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. `TODO(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.
- **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.
- **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. The model can `grep`/`tail` the spill file with bash itself.
- **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.
- **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.
- **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

View File

@@ -15,8 +15,8 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
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 type { RunInternals, RunningBash } from './run.ts'
@@ -38,11 +38,19 @@ export interface Config {
/** The shape after schemastery applied the defaults (cwd has none). */
type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'>
function assertPositiveFinite(name: string, value: number): void {
if (!Number.isFinite(value) || value <= 0) {
throw new Error(`bash-local: ${name} must be a positive finite number`)
}
}
interface TrackedTask extends BashTask {
running: RunningBash
/** Whole-stream byte offsets already delivered via {@link LocalBashExecutor.readOutput}. */
stdoutOffset: number
stderrOffset: number
/** Opaque owner token from the {@link BashExecSpec} (the consumer's isolation key). */
owner: OwnerToken | undefined
}
/**
@@ -59,7 +67,7 @@ export class LocalBashExecutor extends BashExecutor {
maxOutputBytes: z.number().default(64_000),
})
private tasks = new Map<string, TrackedTask>()
private tasks = new Map<BashTaskId, TrackedTask>()
private nextTaskId = 1
/** Test seam: timer/spill knobs forwarded to runBash. */
internals: RunInternals = {}
@@ -72,6 +80,9 @@ export class LocalBashExecutor extends BashExecutor {
// schemastery (static Config) has already filled the defaulted fields;
// the cast records that runtime fact for exactOptionalPropertyTypes.
this.config = config as ResolvedConfig
assertPositiveFinite('timeoutMs', this.config.timeoutMs)
assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs)
assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes)
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
@@ -98,12 +109,16 @@ export class LocalBashExecutor extends BashExecutor {
* values and never re-default.
*/
resolve(request: BashExecRequest): BashExecSpec {
if (request.timeoutMs !== undefined) assertPositiveFinite('request.timeoutMs', request.timeoutMs)
const timeoutMs = Math.min(request.timeoutMs ?? this.config.timeoutMs, this.config.maxTimeoutMs)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
timeoutMs,
...request.signal ? { signal: request.signal } : {},
// 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,
}
}
@@ -132,13 +147,14 @@ export class LocalBashExecutor extends BashExecutor {
signal: spec.signal,
}, this.internals)
const id = `bash-${this.nextTaskId++}`
const id = BashTaskId(`bash-${this.nextTaskId++}`)
const task: TrackedTask = {
id,
command: spec.command,
status: 'running',
exitCode: null,
signal: null,
owner: spec.owner,
running,
stdoutOffset: 0,
stderrOffset: 0,
@@ -160,15 +176,21 @@ export class LocalBashExecutor extends BashExecutor {
return task
}
get(id: string): BashTask | undefined {
get(id: BashTaskId): BashTask | undefined {
return this.tasks.get(id)
}
ownerOf(id: BashTaskId): OwnerToken | undefined {
// Unknown id and known-but-ownerless both read as undefined — the consumer
// treats undefined as "open" and a truly unknown id fails at readOutput/kill.
return this.tasks.get(id)?.owner
}
list(): BashTask[] {
return [...this.tasks.values()]
}
readOutput(id: string): BashTaskRead {
readOutput(id: BashTaskId): BashTaskRead {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
@@ -191,7 +213,7 @@ export class LocalBashExecutor extends BashExecutor {
}
}
kill(id: string): boolean {
kill(id: BashTaskId): boolean {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
if (task.status !== 'running') return false

View File

@@ -159,7 +159,11 @@ export class OutputCollector {
writeSync(this.spillFd, chunk)
}
/** Read the collected tail without finalizing (used by background polling). */
// TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
// the bottom of this file) and `totalBytes` is read only by a test. The live
// background-poll path goes through `readFrom()`, so inline snapshot() into
// finalize() and drop or privatize the totalBytes getter.
/** Read the collected tail without finalizing (the final-result snapshot). */
snapshot(): CollectedOutput {
return {
text: Buffer.concat(this.chunks).toString('utf8'),
@@ -195,7 +199,15 @@ export class OutputCollector {
/** Close the spill file (if any) and return the final output. */
finalize(): CollectedOutput {
if (this.spillFd !== undefined) {
closeSync(this.spillFd)
try {
closeSync(this.spillFd)
} catch {
// close can surface delayed writeback failures (for example EIO/ENOSPC)
// after writeSync appeared to succeed. Keep finalize total so runBash's
// close handler still resolves, but stop advertising a spill file that
// may be missing its tail.
this.spillFile = undefined
}
this.spillFd = undefined
}
return this.snapshot()
@@ -245,7 +257,7 @@ export interface RunningBash {
* RESOLVES with a {@link SpawnOutcome} describing what happened, so callers
* shape one consistent report for the model.
*
* TODO(stateful-shell): per the agent-tool survey there are two proven
* XXX(stateful-shell): per the agent-tool survey there are two proven
* stateful designs worth revisiting Claude Code persists ONLY cwd between
* calls (captures `pwd -P` after each command), and Codex keeps whole PTY
* exec sessions addressable via session ids + stdin writes. We deliberately

View File

@@ -4,7 +4,7 @@ import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import type {} from '@deepseek-ai/dsh-bash'
import { BashTaskId } from '@deepseek-ai/dsh-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-'))
@@ -59,6 +59,16 @@ describe('LocalBashExecutor.run', () => {
expect(result.timeoutMs).toBe(2_000)
})
it('rejects invalid numeric config and timeout overrides', async () => {
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/)
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('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 }))
@@ -145,7 +155,7 @@ describe('LocalBashExecutor background tasks', () => {
it('readOutput throws for unknown ids', async () => {
const { bash } = await setup()
expect(() => bash.readOutput('nope')).toThrow(/unknown bash task "nope"/)
expect(() => bash.readOutput(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
})
it('kill terminates the process group and reports status killed', async () => {
@@ -162,7 +172,7 @@ describe('LocalBashExecutor background tasks', () => {
const task = bash.start(bash.resolve({ command: 'true' }))
await task.done
expect(bash.kill(task.id)).toBe(false)
expect(() => bash.kill('nope')).toThrow(/unknown bash task "nope"/)
expect(() => bash.kill(BashTaskId('nope'))).toThrow(/unknown bash task "nope"/)
})
it('notifies onTaskDone listeners on completion', async () => {

View File

@@ -4,6 +4,21 @@ import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>()
return {
...actual,
closeSync(fd: number): void {
if (failNextClose.value) {
failNextClose.value = false
throw Object.assign(new Error('simulated EIO on close'), { code: 'EIO' })
}
actual.closeSync(fd)
},
}
})
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-spec-'))
function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> = {}) {
@@ -160,6 +175,19 @@ describe('output truncation and spill', () => {
expect(result.stdout.text.length).toBe(500)
expect(result.stdout.spillPath).toBeUndefined()
})
it('settles with the tail and no spill path when final spill close fails', async () => {
failNextClose.value = true
const result = await runBash(
spec('for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', { maxOutputBytes: 500 }),
{ spillDir },
).done
expect(failNextClose.value).toBe(false)
expect(result.exitCode).toBe(0)
expect(result.stdout.truncated).toBe(true)
expect(result.stdout.text).toContain('line-0200')
expect(result.stdout.spillPath).toBeUndefined()
})
})
describe('OutputCollector', () => {
@@ -200,6 +228,22 @@ describe('OutputCollector', () => {
expect(collector.totalBytes).toBe(8)
expect(collector.finalize().text).toBe('bbbb')
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 'closefail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.snapshot().spillPath).toBeDefined()
failNextClose.value = true
let out: ReturnType<typeof collector.finalize>
expect(() => { out = collector.finalize() }).not.toThrow()
expect(failNextClose.value).toBe(false)
expect(out!.text).toBe('bbbb')
expect(out!.truncated).toBe(true)
expect(out!.spillPath).toBeUndefined()
})
})
describe('killGroup', () => {

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../util/brand"
},
{
"path": "../../bash/bash"
}
]
}

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-bash
The **bash executor seam**: an abstract `BashExecutor` service (`ctx.bash`) defining WHAT a bash backend does — run commands, manage background tasks — without saying HOW.
This package is one third of the bash capability, split so each concern can evolve (and be swapped) independently:
| Package | Role |
|---|---|
| `@deepseek-ai/dsh-bash` (this) | the interface: abstract service + vocabulary types |
| `@deepseek-ai/dsh-bash-local` | an implementation: local subprocesses |
| `@deepseek-ai/dsh-tool-bash` | the model-facing tool schemas over `ctx.bash` |
The split mirrors the LLM seam (`LlmService`/`LlmAdapter`) and the agent-tool survey: pi hides execution behind a `BashOperations` interface (local shell / SSH / VM backends), Codex behind an exec-server protocol. A future sandboxed, containerized, or remote executor implements this interface and the tool schemas don't change.
## Service API (`ctx.bash`)
| Member | Semantics |
|---|---|
| `run(spec)` | Foreground execution. Resolves when the command finishes. **Rejects only for infrastructure failures** (unusable workdir, missing shell, pre-aborted signal); nonzero exits, timeout kills, and abort kills resolve with a descriptive `BashRunResult`. |
| `start(spec)` | Background execution. Returns a `BashTask` handle immediately; **no timeout applies** (stop tasks via `kill`). |
| `get(id)` / `list()` | Task lookup. |
| `ownerOf(id)` | The opaque OWNER token recorded for a background task at `start` (from the spec's `owner`), or `undefined` for an unknown id OR a known-but-ownerless task. The executor stores/returns it verbatim and NEVER interprets it — the access POLICY lives in the consumer (`dsh-tool-bash`), which compares `ownerOf(id)` to the caller's token. Storing ownership here (disposed with the executor's fiber) is what makes it survive a consumer HMR reload. |
| `readOutput(id)` | **Incremental** output read — consecutive reads never re-deliver. Reads that lost data to buffer bounds flag `lossy` and point at full-stream spill files. Throws for unknown ids. |
| `kill(id)` | Kill a running task. Returns `false` when it already finished; throws for unknown ids. |
| `onTaskDone(listener)` | Completion listener (effect-based, disposer returned). Fires exactly once per task; never after the service is disposed. |
Implementations subclass `BashExecutor`, implement the abstract methods, and call `notifyTaskDone(task)` on background completion. Disposal must kill every running task (no orphan processes) — see the HMR-safety tests.
## 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.

View File

@@ -20,9 +20,11 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -15,8 +15,9 @@
*/
import { Context, Service } from 'cordis'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskListener, BashTaskRead } from './types.ts'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskId, BashTaskListener, BashTaskRead, OwnerToken } from './types.ts'
export { BashTaskId, OwnerToken } from './types.ts'
export type {
BashExecRequest,
BashExecSpec,
@@ -86,19 +87,34 @@ export abstract class BashExecutor extends Service {
abstract start(spec: BashExecSpec): BashTask
/** Look up a background task by id. */
abstract get(id: string): BashTask | undefined
abstract get(id: BashTaskId): BashTask | undefined
/**
* The opaque OWNER token recorded for a background task at {@link start}
* (from the {@link BashExecSpec}'s `owner`), or `undefined` for an unknown id
* OR a known-but-ownerless task. The executor stores and returns the token
* verbatim it never interprets it; the access POLICY (who may read/kill a
* task) lives in the consumer (`@deepseek-ai/dsh-tool-bash`), which compares
* `ownerOf(id)` to the caller's token. Collapsing unknown-id and
* known-but-unowned into the same `undefined` is fine: the consumer's access
* gate treats `undefined` as "open", and a genuinely unknown id then fails
* 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.
*/
abstract ownerOf(id: BashTaskId): OwnerToken | undefined
/** All tracked background tasks (insertion order). */
abstract list(): BashTask[]
/** Read output produced since the previous read. Throws for unknown ids. */
abstract readOutput(id: string): BashTaskRead
abstract readOutput(id: BashTaskId): BashTaskRead
/**
* Kill a running background task. Returns false when it had already
* finished (no-op). Throws for unknown ids.
*/
abstract kill(id: string): boolean
abstract kill(id: BashTaskId): boolean
/**
* Register a background-task completion listener (disposed with the

View File

@@ -6,6 +6,31 @@
* @module dsh-bash/types
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/** Identifies one background task within an executor (generated `bash-N`). */
export type BashTaskId = Branded<'BashTaskId'>
/** Brand a string as a {@link BashTaskId}. */
export function BashTaskId(id: string): BashTaskId {
return id as BashTaskId
}
/**
* A background task's opaque isolation key — the CONSUMER's owner identity, not
* the bash seam's. The executor stores and returns it verbatim and never
* interprets it; the access policy lives in the consumer (`dsh-tool-bash`),
* which is the single boundary that casts its own id vocabulary into one. A
* DISTINCT brand (not a `SessionId` alias) keeps the seam decoupled a
* sandboxed/remote executor inherits no session dependency.
*/
export type OwnerToken = Branded<'OwnerToken'>
/** Brand a string as an {@link OwnerToken}. */
export function OwnerToken(id: string): OwnerToken {
return id as OwnerToken
}
/**
* A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and
* filled by {@link BashExecutor.resolve} from the implementation's config.
@@ -20,6 +45,15 @@ export interface BashExecRequest {
timeoutMs?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | 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
* executor stores it on the task and exposes it via {@link BashExecutor.ownerOf};
* the executor itself NEVER interprets it (no access policy lives in the
* seam that is the consumer's job). Absent for foreground runs and for an
* ownerless background start (a non-agent caller).
*/
owner?: OwnerToken | undefined
}
/**
@@ -36,6 +70,15 @@ export interface BashExecSpec {
timeoutMs: number
/** Abort signal — implementations kill the command when it fires. */
signal?: AbortSignal | undefined
/**
* Opaque owner token, REQUIRED-but-nullable (mirrors `workdir`/`timeoutMs`
* being required on the resolved spec): {@link BashExecutor.resolve} carries
* the request's `owner` through, defaulting a missing one to `undefined`. A
* required field makes a forgotten owner a VISIBLE `undefined` rather than a
* silently-absent property that yields an unowned (cross-session-readable)
* task. `start()` stores it; `run()` (foreground) ignores it.
*/
owner: OwnerToken | undefined
}
/** One captured stream: the (possibly truncated) text plus recovery info. */
@@ -44,7 +87,7 @@ export interface CollectedOutput {
text: string
/** True when bytes were dropped from `text`. */
truncated: boolean
/** Path to a file holding the COMPLETE stream, when truncated. */
/** Path to a file holding the COMPLETE stream, when truncated and available. */
spillPath?: string
}
@@ -69,7 +112,7 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed'
/** A tracked background task handle. */
export interface BashTask {
readonly id: string
readonly id: BashTaskId
readonly command: string
status: BashTaskStatus
/** Exit code once finished (null = killed by signal / still running). */
@@ -87,9 +130,9 @@ export interface BashTaskRead {
delta: string
/** True when truncation dropped unread bytes the delta cannot include. */
lossy: boolean
/** Full stdout spill file, when stdout truncation occurred. */
/** Full stdout spill file, when stdout truncation occurred and a safe path is available. */
stdoutSpillPath?: string
/** Full stderr spill file, when stderr truncation occurred. */
/** Full stderr spill file, when stderr truncation occurred and a safe path is available. */
stderrSpillPath?: string
}

View File

@@ -1,11 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { BashExecutor } from '@deepseek-ai/dsh-bash'
import { BashExecutor, BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead } from '@deepseek-ai/dsh-bash'
/** Minimal concrete executor: records calls, lets tests drive completions. */
class StubExecutor extends BashExecutor {
tasks = new Map<string, BashTask>()
tasks = new Map<BashTaskId, BashTask>()
private owners = new Map<BashTaskId, OwnerToken | undefined>()
resolve(request: BashExecRequest): BashExecSpec {
return {
@@ -13,6 +14,7 @@ class StubExecutor extends BashExecutor {
workdir: request.workdir ?? '/stub',
timeoutMs: request.timeoutMs ?? 1000,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
}
}
@@ -30,7 +32,7 @@ class StubExecutor extends BashExecutor {
start(spec: BashExecSpec): BashTask {
const task: BashTask = {
id: `stub-${this.tasks.size + 1}`,
id: BashTaskId(`stub-${this.tasks.size + 1}`),
command: spec.command,
status: 'running',
exitCode: null,
@@ -38,24 +40,29 @@ class StubExecutor extends BashExecutor {
done: Promise.resolve(),
}
this.tasks.set(task.id, task)
this.owners.set(task.id, spec.owner)
return task
}
get(id: string): BashTask | undefined {
get(id: BashTaskId): BashTask | undefined {
return this.tasks.get(id)
}
ownerOf(id: BashTaskId): OwnerToken | undefined {
return this.owners.get(id)
}
list(): BashTask[] {
return [...this.tasks.values()]
}
readOutput(id: string): BashTaskRead {
readOutput(id: BashTaskId): BashTaskRead {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
return { task, delta: '', lossy: false }
}
kill(id: string): boolean {
kill(id: BashTaskId): boolean {
const task = this.tasks.get(id)
if (!task) throw new Error(`unknown bash task "${id}"`)
if (task.status !== 'running') return false

View File

@@ -0,0 +1,21 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
}
]
}

View File

@@ -0,0 +1,45 @@
# @deepseek-ai/dsh-tool-bash
The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registered over the `ctx.bash` executor seam (`@deepseek-ai/dsh-bash`). Pure schema + text shaping; every process concern lives behind the seam, so sandboxed or remote executor implementations swap in without changing what the model sees.
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash']`).
## Tools
### `bash`
| Arg | Type | Notes |
|---|---|---|
| `command` | string (required) | Run via `bash -c`. No state persists between calls — use `workdir`, not `cd`. |
| `description` | string (required) | One-line, active-voice summary of the command (5-10 words), for UI/log display only — no effect on execution. |
| `timeoutMs` | number | Timeout override in milliseconds. The executor applies its configured default and cap. |
| `workdir` | string | Working directory for this call. Defaults to the calling agent's session cwd (`session.header.cwd`) so each session runs in its own workspace; a relative `workdir` is resolved against that session cwd. |
| `run_in_background` | boolean | Return a task id immediately; no timeout applies. |
`command`, `workdir`, and `timeoutMs` are resolved against the executor's config defaults via `ctx.bash.resolve()` before execution, so the executor seam (`BashExecSpec`) receives explicit `workdir`/`timeoutMs` values. The workdir default is applied in the tool layer (from the calling agent's `session.header.cwd`) BEFORE `resolve()` — the per-session cwd must come from `exec.agent`, since N sessions share one executor; only when no session cwd is available does the executor fall back to its own config / `process.cwd()`.
Result text: stdout, then a `[stderr]` section, then status markers — `[timed out after Nms]` whenever the executor's timer fired (reported independently of how the process ended, so a command that traps SIGTERM and exits 0 still shows it), `[killed by signal: …]` for a signal death, `[exit code: N]` for a non-zero exit (reported, **not** `isError`: the model decides how to react), and `[output truncated; full output: <path>]` when the tail was kept and a safe spill file is available. If the executor knows output was dropped but cannot safely advertise a complete spill file, the path is reported as `(unavailable)`. Only infrastructure failures (spawn errors, aborts) surface as `isError` results.
### `bash_output`
`task_id` → output produced **since the previous `bash_output` call** plus a status line (`running` / `completed, exit code: N` / `killed`). Reads that lost data to buffer bounds say so and point at the full-output spill file when one is safely available, otherwise `(unavailable)`.
### `bash_kill`
`task_id` → ask the executor to kill the background task. The concrete executor decides how to signal or stop the process; killing an already-finished task is a reported no-op, and unknown ids are errors.
### Task ownership (cross-session isolation)
The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task <id> belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.)
## 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").
## 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`.
## 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.

View File

@@ -0,0 +1,427 @@
/**
* The model-facing bash tools: `bash`, `bash_output`, `bash_kill`. Pure
* schema + text shaping — every process concern lives behind the `ctx.bash`
* executor seam (`@deepseek-ai/dsh-bash`), so sandbox/permission/remote
* executor implementations swap in without touching what the model sees.
*
* Background notifications: when a background task completes, a short notice
* is injected into the owning agent's session (`agent.inject()` — the
* documented context seam). Injection is durable context for the NEXT model
* request, not a wake-up: an idle agent stays idle until something sends a
* message, which is why the tool descriptions tell the model to poll with
* `bash_output`.
*
* Task ownership: a background task's OWNER is an opaque token — the owning
* agent's `session.header.id` — passed to the executor at spawn
* (`resolve({ …, owner })`) and stored ON THE TASK inside the executor
* (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map.
* `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token
* and reject a task owned by a DIFFERENT session (`owner !== undefined && owner
* !== caller`); an unowned task (no token — started by a non-agent caller) is
* open to anyone. Task ids are global and predictable (`bash-1`, …); under
* multi-session ACP (RFC 011) this token check is the fence that stops one
* session's agent from reading or killing another session's background task.
*
* Storing the token on the task in the EXECUTOR (disposed with the `dsh-bash`
* fiber), rather than in this plugin, is what makes ownership survive a
* `tool-bash` HMR reload — a reload that reset a plugin-local map would orphan
* a task spawned before it. (The `onTaskDone` listener is still effect-scoped
* to this plugin's `apply`, so a
* completion landing during the reload gap still drops its one notice — the
* 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
* sandboxing `BashExecutor` implementations — see docs/architecture.md
* § plugin checklist.
*
* @module @deepseek-ai/dsh-tool-bash
*/
import type { Context } from 'cordis'
import { isAbsolute, resolve as resolvePath } from 'node:path'
import { defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolCallPresentation, ToolResult, ToolResultPresentation } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { BashTaskId, OwnerToken } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
export const name = 'tool-bash'
export const inject = ['tools', 'bash']
/**
* Validate the constraints the SchemaSpec can't express. `defineTool` now
* validates parsed args against the SchemaSpec before `execute` runs (the
* arg-validation RFC), so type/required/enum checks are already done and `args`
* is the validated `InferArgs` shape here. What remains are value constraints
* the DSL has no vocabulary for: non-empty strings and a positive, finite
* timeout.
*/
function validateBashArgs(args: {
command: string
description: string
timeoutMs?: number
workdir?: string
run_in_background?: boolean
}): void {
if (args.command.trim().length === 0) {
throw new Error('invalid command: expected a non-empty string')
}
if (args.description.trim().length === 0) {
throw new Error('invalid description: expected a non-empty string')
}
if (args.timeoutMs !== undefined && (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0)) {
throw new Error(`invalid timeoutMs: expected a positive number, got ${JSON.stringify(args.timeoutMs)}`)
}
}
/**
* Reject an empty `task_id`. Type and presence are guaranteed by the
* SchemaSpec validation (the arg-validation RFC); only the non-empty constraint, which the
* DSL can't express, is left to check here.
*/
function validateTaskId(value: string): BashTaskId {
if (value.length === 0) {
throw new Error(`invalid task_id: expected a string, got ${JSON.stringify(value)}`)
}
return BashTaskId(value)
}
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
*/
export function renderResult(result: BashRunResult): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
let body = out
if (err.length > 0) {
// Single newline between sections (stdout usually ends with one already).
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
body += `[stderr]\n${err}`
}
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
// signal:null — the model must still see that the command was cut short.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
if (markers.length === 0) return body
if (!body.endsWith('\n')) body += '\n'
return body + markers.join('\n')
}
// ---------------------------------------------------------------------------
// UI presentation (tool-owned). These shape how a UI (e.g. the ACP bridge)
// renders a bash call's pending and completed states. They are display-only and
// pure — a UI may call them during live streaming AND a session-log replay.
// ---------------------------------------------------------------------------
/**
* Pending-state presentation for a `bash` call. The TITLE is the exact `command`
* — a `kind: 'execute'` card is rendered as a terminal whose header label IS the
* title, and an execute-kind card HIDES `rawInput` (Zed: `should_show_raw_input
* = !is_terminal_tool`), so the command must BE the title to be seen. This
* mirrors the reference ACP adapters (claude-agent-acp, codex-acp), which both
* use the bare command as an execute tool's title. The model-written
* `description` (a readable summary) rides as a `content` text block shown ABOVE
* the card. (Note: claude-agent-acp DROPS the description in terminal mode and
* shows only the card; surfacing it as a content block is a deliberate
* divergence here — we keep the human summary visible alongside the card.)
* `rawInput` still carries the bare command for non-execute UIs that DO render it.
*
* `terminal` marks the call so a capable UI renders a TERMINAL card — but ONLY a
* FOREGROUND run is a terminal: a `run_in_background` call returns a task id
* immediately (it never streams a terminal; its output is polled via
* `bash_output`), so it is NOT marked terminal and renders as an ordinary
* execute card. For a foreground run the `terminal.cwd` (header) is the model
* `workdir` when given — ABSOLUTE as-is, RELATIVE for the UI bridge to resolve
* against the session cwd; when omitted the bridge fills the session workspace
* cwd (this PURE presenter, args only, can't see it).
*/
type BashCallArgs = { command: string; description: string; workdir?: string; run_in_background?: boolean }
function presentBashCall(args: BashCallArgs): ToolCallPresentation {
const base = {
title: args.command,
kind: 'execute' as const,
rawInput: args.command,
content: [{ type: 'text' as const, text: args.description }],
}
// A background start is not an interactive terminal — no terminal card.
if (args.run_in_background === true) return base
return { ...base, terminal: args.workdir !== undefined ? { cwd: args.workdir } : {} }
}
/**
* Completed-state presentation for a `bash` call. Two parallel renderings of the
* same output: `terminal.output` for a UI that shows a terminal card (the run's
* stdout/stderr + status markers, exactly as the model sees them — the RAW text,
* newlines preserved, since a terminal renderer relies on exact bytes), and a
* fenced ```console `content` block as the fallback for a UI without terminal
* support (the fences are a UI-only affordance, so they live here, not in the
* model-facing result; the fenced body is trimmed of trailing blank lines for a
* tidy block). A capable UI also gets an exit-status pill from `terminal.exitCode`
* / `terminal.signal`, parsed from the status markers `renderResult` appended.
*
* Terminal output/exit is suppressed for results that are NOT a finished
* foreground run: a `run_in_background` start (`isBackground` — the text is a
* task-id ack, not a streamed run) and an `isError` result (a spawn failure or
* abort — there is no real process exit to pill, and the body is an error
* message, not `renderResult` output, so parsing it would be meaningless). Those
* fall back to the fenced `content` block with no terminal metadata. The bridge's
* orphan guard also drops a result terminal when the call wasn't terminal, so a
* background call (not marked terminal in `presentBashCall`) is doubly safe.
* A non-text result (unexpected for bash) falls through to `undefined`.
*/
function presentBashResult(args: unknown, result: ToolResult): ToolResultPresentation | undefined {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
const raw = block.text
const fenced = raw.replace(/\n+$/, '')
const content = [{ type: 'text' as const, text: `\`\`\`console\n${fenced}\n\`\`\`` }]
const isBackground = typeof args === 'object' && args !== null && (args as { run_in_background?: unknown }).run_in_background === true
// No exit pill / terminal output for a background ack or an errored run.
if (isBackground || result.isError) return { content }
return { content, terminal: { output: raw, ...parseExitStatus(raw) } }
}
/**
* Recover the structured exit status from a rendered `renderResult` string — the
* inverse of the status markers it appends. A `[killed by signal: SIG]` marker
* yields `{signal}`; otherwise an `[exit code: N]` marker yields `{exitCode:N}`;
* absent both we report `{exitCode:0}` (a clean run appends no marker — and a
* trapped-timeout run that exits 0 also has none and is accurately exit 0).
*
* Why parse rendered text at all: `presentResult` is replay-safe and on a
* `session/load` the ONLY thing persisted is this content text — the structured
* `BashRunResult` is long gone — so unless the exit were added to the persisted
* event schema (deliberately NOT done; see the terminal-rendering RFC), parsing
* is the only channel. The match is anchored to a LEADING newline + end-of-string
* because `renderResult` always inserts a `\n` before the marker (line ~124) onto
* a non-empty body: a real marker is therefore always its own final line. That
* defeats the common spoof (program output that simply ENDS in `[exit code: 5]`
* with no trailing newline — a clean exit 0 — no longer reads as a failure).
*
* KNOWN RESIDUAL (inherent to the replay-only-sees-text design): a clean exit 0
* whose body's FINAL line is itself exactly the marker text — `[exit code: N]`
* or `[killed by signal: SIG]`, printed by the program with nothing after — is
* still indistinguishable from a real marker and would show a wrong pill. This is
* display-only (execution and the model-facing text are unaffected) and narrow;
* the complete fix is to persist a structured exit on the result event, which the
* RFC names as the escape hatch.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
function presentTaskCall(verb: string, args: { task_id: string }): ToolCallPresentation {
return { title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }
}
/**
* Resolve the working directory for a bash call. Precedence: an explicit model
* `workdir` wins; otherwise default to the calling agent's session cwd
* (`session.header.cwd`) so each ACP session's commands run in ITS workspace,
* not the server's launch dir. A RELATIVE model `workdir` is resolved against
* the session cwd (the tool tells the model to pass `workdir` instead of `cd`,
* so a relative one should be relative to the session's root, not `process.cwd()`).
* Returns `undefined` when neither is available (no agent / headerless session /
* no session cwd) — the executor then applies its own config/`process.cwd()`
* default, preserving today's non-ACP behavior.
*/
function resolveWorkdir(modelWorkdir: string | undefined, exec: { agent?: Agent }): string | undefined {
const sessionCwd = exec.agent?.session.header.cwd
if (modelWorkdir === undefined) return sessionCwd
if (sessionCwd !== undefined && !isAbsolute(modelWorkdir)) {
return resolvePath(sessionCwd, modelWorkdir)
}
return modelWorkdir
}
/** Status line for background task reads. */
function statusLine(task: BashTask): string {
switch (task.status) {
case 'running': return '[status: running]'
case 'killed': return `[status: killed${task.signal !== null ? ` by ${task.signal}` : ''}]`
case 'completed': return `[status: completed, exit code: ${task.exitCode ?? 0}]`
}
}
export function apply(ctx: Context): void {
/**
* The caller's owner TOKEN — the owning agent's `session.header.id`, or
* `undefined` for a non-agent caller. Read `session.header.id` (NOT
* `session.id`): every other subsystem keys off the header id (the ACP bridge,
* both persistence backends), and the sibling `resolveWorkdir` already reads
* `session.header.cwd`, so using `session.id` here would be the asymmetry smell
* the conventions flag. The two are equal in production, but the header is the
* canonical identity.
*/
const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined =>
exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined
/**
* Authorize a `bash_output`/`bash_kill` call against the task's stored owner
* token. Rejects when the task HAS an owner and it differs from the caller's
* token — using `!== undefined` semantics, NOT truthiness, so an empty-string
* token is still a real owner (never treated as unowned). An unowned task
* (`ownerOf` returns `undefined`) is allowed; a truly unknown id is also
* `undefined` here and then fails loudly at the subsequent
* `readOutput`/`kill` ("unknown bash task"). The conservative no-agent caller
* (`callerToken` undefined) cannot match an owned task and is rejected.
*/
const assertTaskAccess = (taskId: BashTaskId, exec: { agent?: Agent }): void => {
const owner = ctx.bash.ownerOf(taskId)
if (owner !== undefined && owner !== callerToken(exec)) {
throw new Error(`task ${taskId} belongs to another session`)
}
}
// Background completion → inject a notice into the owning agent's session.
// Find the live agent by its session id token via the agent registry, read
// opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject):
// this listener runs from `task.done.then` on the bash fiber — a foreign
// fiber — where the `ctx.agents` property proxy would throw through the
// traceable shadow; `ctx.get(name)` is the topology-independent lookup. No
// registry mounted (`undefined`) → drop the notice. Match on
// `agent.session.header.id`, NOT the registry key: a config agent's id differs
// from its session id, and the owner token IS the session id.
ctx.bash.onTaskDone((task) => {
const ownerToken = ctx.bash.ownerOf(task.id)
if (ownerToken === undefined) return
const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken)
if (!agent) return
try {
agent.inject(
[{ type: 'text', text: `background bash task ${task.id} finished ${statusLine(task)}. Read its output with bash_output.` }],
{ source: { kind: 'plugin', plugin: 'tool-bash' } },
)
} catch (error: unknown) {
// The ONE expected failure: the agent was disposed between task
// completion and this injection (ReactLoopAgent.inject throws
// `agent "<id>" is disposed`). That race is benign — drop the notice.
// Anything else is a real bug and must surface, not be swallowed.
if (error instanceof Error && error.message.includes('is disposed')) return
throw error
}
})
ctx.tools.register(defineTool({
name: 'bash',
description: 'Execute a bash command (`bash -c`) and return its stdout/stderr. '
+ 'Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — '
+ 'pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. '
+ 'Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. '
+ 'Set `run_in_background: true` for long-running commands: the call returns a task id immediately; '
+ 'poll it with `bash_output` and stop it with `bash_kill`.',
parameters: {
command: { type: 'string', required: true, description: 'The bash command to execute.' },
description: {
type: 'string',
required: true,
description: 'Clear, concise description of what this command does in active voice, '
+ '5-10 words (shown in the UI). Examples: "ls" → "List files in current directory"; '
+ '"git status" → "Show working tree status"; "npm install" → "Install package dependencies".',
},
timeoutMs: { type: 'number', description: 'Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry.' },
workdir: { type: 'string', description: 'Working directory for this command. Defaults to the session workspace; a relative path is resolved against it.' },
run_in_background: { type: 'boolean', description: 'Run in the background and return a task id immediately. No timeout applies.' },
},
async execute(args, exec) {
validateBashArgs(args)
// `description` is display/logging metadata only (surfaced to UIs via
// the tool/call session event); it is intentionally NOT forwarded to
// ctx.bash and has no effect on execution.
// Default the workdir to the calling agent's session cwd so each ACP
// session runs in its own workspace (see resolveWorkdir); an explicit
// model workdir still wins.
const workdir = resolveWorkdir(args.workdir, exec)
const request = {
command: args.command,
...workdir !== undefined ? { workdir } : {},
...args.timeoutMs !== undefined ? { timeoutMs: args.timeoutMs } : {},
...exec.signal ? { signal: exec.signal } : {},
}
if (args.run_in_background === true) {
// Stamp the owner token (the agent's session id) onto the spec so the
// executor stores it on the task — the isolation fence for bash_output/
// bash_kill. Foreground runs pass no owner (they finish inline; nothing
// to fence).
const task = ctx.bash.start(ctx.bash.resolve({ ...request, owner: callerToken(exec) }))
return [{ type: 'text', text: `started background task ${task.id}` }]
}
const result = await ctx.bash.run(ctx.bash.resolve(request))
if (result.aborted) throw new Error('command aborted')
return [{ type: 'text', text: renderResult(result) }]
},
presentCall: presentBashCall,
presentResult: presentBashResult,
}))
ctx.tools.register(defineTool({
name: 'bash_output',
description: 'Read new output from a background bash task started with `bash` + `run_in_background`. '
+ 'Returns only output produced since the previous bash_output call, plus the task status. '
+ 'Tasks keep running while you do other work; poll again later for more output.',
parameters: {
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
},
// execute is synchronous (registry reads + string shaping) but the
// ToolDefinition contract wants a Promise — hence resolve(), not async.
execute(args, exec) {
const id = validateTaskId(args.task_id)
assertTaskAccess(id, exec)
const read = ctx.bash.readOutput(id)
let text = read.delta.length > 0 ? read.delta : '(no new output)'
if (read.lossy) {
const paths = [read.stdoutSpillPath, read.stderrSpillPath].filter((p): p is string => p !== undefined)
const fullOutput = paths.length > 0 ? paths.join(', ') : '(unavailable)'
text += `\n[some output was dropped from memory; full output: ${fullOutput}]`
}
text += `\n${statusLine(read.task)}`
return Promise.resolve([{ type: 'text', text }])
},
presentCall: args => presentTaskCall('Read output from', args),
}))
ctx.tools.register(defineTool({
name: 'bash_kill',
description: 'Ask the executor to kill a running background bash task by task id.',
parameters: {
task_id: { type: 'string', required: true, description: 'Task id returned by the bash tool.' },
},
execute(args, exec) {
const id = validateTaskId(args.task_id)
assertTaskAccess(id, exec)
const killed = ctx.bash.kill(id)
return Promise.resolve([{
type: 'text',
text: killed ? `killed background task ${id}` : `task ${id} had already finished`,
}])
},
presentCall: args => presentTaskCall('Kill', args),
}))
}

View File

@@ -5,11 +5,12 @@ import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import { BashTaskId } from '@deepseek-ai/dsh-bash'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { MockAdapter, textResponse, toolCallResponse } from '../../agent-loop/tests/mock-adapter.ts'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Full-loop integration: a scripted mock model drives the REAL bash tool
@@ -30,7 +31,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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') {
@@ -41,7 +42,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function events(agent: LoopAgent): SessionEvent[] {
function events(agent: ReactLoopAgent): SessionEvent[] {
return [...agent.session.events]
}
@@ -73,7 +74,7 @@ describe('bash tool through the agent loop', () => {
textResponse('The command printed integration-ok.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('it-fg', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' })
agent.send([{ type: 'text', text: 'run echo integration-ok' }])
await waitForIdle(ctx, agent)
@@ -105,7 +106,7 @@ describe('bash tool through the agent loop', () => {
textResponse('It failed with code 9.'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('it-exit', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' })
agent.send([{ type: 'text', text: 'run exit 9' }])
await waitForIdle(ctx, agent)
@@ -126,7 +127,7 @@ describe('bash tool through the agent loop', () => {
let taskId = ''
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('it-bg', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' })
// Intercept the first tool result to capture the generated task id, then
// rewrite the second scripted call's arguments to use it.
@@ -147,7 +148,7 @@ describe('bash tool through the agent loop', () => {
await waitForIdle(ctx, agent)
// Wait for the background task itself (completion may race turn end).
const task = ctx.bash.get(taskId)
const task = ctx.bash.get(BashTaskId(taskId))
if (!task) throw new Error(`task ${taskId} not registered`)
await task.done

View File

@@ -0,0 +1,849 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
async function setup() {
const ctx = new Context()
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(ToolBash)
return ctx
}
/**
* Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in
* `ctx.agents` (the completion-notice path finds the owning agent by scanning
* the registry for a matching `session.header.id`), and return it. The returned
* agent is also passed to `execute` as `exec.agent` so it owns the spawned task.
* The registration disposer is tracked so {@link unregisterFakeAgents} can drop
* it (simulating the owning session disconnecting before a task completes).
*/
const fakeAgentDisposers = new Map<Context, (() => void)[]>()
function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent {
// The registry KEY (agent.id) is deliberately DIFFERENT from the session
// token (session.header.id) — a config agent has `agentId !== sessionId`. The
// owner token IS the session id, so the notice path must find the agent by
// `session.header.id`, NOT the registry key. Using distinct values here makes
// the test fail if a regression matched on the wrong field (a same-value fake
// would pass either way — the "hits the line but not the scenario" trap).
const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent
const dispose = ctx.agents.register(agent)
const list = fakeAgentDisposers.get(ctx) ?? []
list.push(dispose)
fakeAgentDisposers.set(ctx, list)
return agent
}
/** Unregister every fake agent in this ctx (simulate the owning session disconnecting). */
function unregisterFakeAgents(ctx: Context): void {
for (const dispose of fakeAgentDisposers.get(ctx) ?? []) dispose()
fakeAgentDisposers.delete(ctx)
}
let callCounter = 0
function call(ctx: Context, name: string, args: unknown) {
return ctx.tools.execute({ callId: CallId(`call-${++callCounter}`), name, arguments: args })
}
function text(result: { content: { type: string; text?: string }[] }): string {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('')
}
class LossyReadBashExecutor extends BashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running',
exitCode: null,
signal: null,
done: Promise.resolve(),
}
resolve(request: BashExecRequest): BashExecSpec {
return {
command: request.command,
workdir: request.workdir ?? process.cwd(),
timeoutMs: request.timeoutMs ?? 0,
...request.signal ? { signal: request.signal } : {},
owner: request.owner,
}
}
run(): Promise<BashRunResult> {
return Promise.reject(new Error('not used'))
}
start(): BashTask {
return this.task
}
get(id: BashTaskId): BashTask | undefined {
return id === this.task.id ? this.task : undefined
}
ownerOf(): OwnerToken | undefined {
return undefined
}
list(): BashTask[] {
return [this.task]
}
readOutput(id: BashTaskId): BashTaskRead {
if (id !== this.task.id) throw new Error(`unknown bash task "${id}"`)
return { task: this.task, delta: 'tail', lossy: true }
}
kill(): boolean {
return false
}
}
describe('bash tool', () => {
it('returns stdout for a successful command', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'echo hello', description: 'test command' })
expect(result.isError).toBe(false)
expect(text(result)).toBe('hello\n')
})
it('reports (no output) for silent commands', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'true', description: 'test command' })
expect(text(result)).toBe('(no output)')
})
it('marks stderr sections', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'echo out; echo err >&2', description: 'test command' })
expect(text(result)).toBe('out\n[stderr]\nerr\n')
expect(result.isError).toBe(false)
})
it('reports non-zero exits without isError', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'echo failing; exit 3', description: 'test command' })
expect(result.isError).toBe(false)
expect(text(result)).toBe('failing\n[exit code: 3]')
})
it('reports timeout kills with both markers (timeout first)', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', timeoutMs: 100 })
expect(result.isError).toBe(false)
expect(text(result)).toBe('(no output)\n[timed out after 100ms]\n[killed by signal: SIGTERM]')
})
it('reports a timeout even when the command traps the signal and exits 0', async () => {
// The signal-independent timeout marker: a trapped SIGTERM that exits 0
// after our timer fired must NOT look like a clean success. (bash may
// print "Terminated" to stderr for the killed sleep — environment
// dependent — so assert the marker, not the exact body.)
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'trap "exit 0" TERM; sleep 60', description: 'test command', timeoutMs: 100 })
expect(result.isError).toBe(false)
expect(text(result)).toContain('[timed out after 100ms]')
expect(text(result)).not.toContain('[exit code:')
})
it('reports truncation with the spill path', async () => {
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(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: ')
expect(text(result)).toContain('line-0100')
})
it('honors workdir', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'pwd', description: 'test command', workdir: '/tmp' })
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('surfaces spawn failures as isError', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'true', description: 'test command', workdir: '/nonexistent-dsh' })
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/ENOENT/)
})
it('surfaces aborts as isError', async () => {
const ctx = await setup()
const controller = new AbortController()
const pending = ctx.tools.execute({
callId: CallId('call-abort'),
name: 'bash',
arguments: { command: 'sleep 60', description: 'test command' },
signal: controller.signal,
})
setTimeout(() => { controller.abort() }, 50)
const result = await pending
expect(result.isError).toBe(true)
expect(text(result)).toMatch(/aborted/)
})
// Type and required-key violations are now rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation RFC) before execute.
it.each([
[{}, /missing required property "command"/],
[{ command: 42, description: 'd' }, /"command" must be a string/],
[{ command: 'x' }, /missing required property "description"/],
[{ command: 'x', description: 7 }, /"description" must be a string/],
[{ command: 'x', description: 'd', timeoutMs: 'soon' }, /"timeoutMs" must be a number/],
[{ command: 'x', description: 'd', workdir: 7 }, /"workdir" must be a string/],
[{ command: 'x', description: 'd', run_in_background: 'yes' }, /"run_in_background" must be a boolean/],
])('rejects schema-invalid args %j', async (args, pattern) => {
const ctx = await setup()
const result = await call(ctx, 'bash', args)
expect(result.isError).toBe(true)
expect(text(result)).toMatch(pattern)
})
// Value constraints the SchemaSpec can't express stay in the tool body.
it.each([
[{ command: ' ', description: 'd' }, /invalid command/],
[{ command: 'x', description: ' ' }, /invalid description/],
[{ command: 'x', description: 'd', timeoutMs: -1 }, /invalid timeoutMs/],
[{ command: 'x', description: 'd', timeoutMs: Number.NaN }, /invalid timeoutMs/],
])('rejects value-invalid args %j', async (args, pattern) => {
const ctx = await setup()
const result = await call(ctx, 'bash', args)
expect(result.isError).toBe(true)
expect(text(result)).toMatch(pattern)
})
it('registers all three schemas in the system prompt assembly', async () => {
const ctx = await setup()
const names = ctx.tools.schemas().map(schema => schema.name)
expect(names).toEqual(['bash', 'bash_output', 'bash_kill'])
const bashSchema = ctx.tools.schemas()[0]!
expect(bashSchema.parameters).toMatchObject({
type: 'object',
required: ['command', 'description'],
})
})
it('unregisters everything when the plugin fiber is disposed (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LocalBashExecutor, {})
const fiber = await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(3)
await fiber.dispose()
expect(ctx.tools.schemas()).toHaveLength(0)
})
it('tools depend on the executor: no registration without ctx.bash', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
// inject: ['tools', 'bash'] keeps the plugin pending until bash exists.
await ctx.plugin(ToolBash)
expect(ctx.tools.schemas()).toHaveLength(0)
await ctx.plugin(LocalBashExecutor, {})
await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.tools.schemas()).toHaveLength(3)
})
})
describe('background tools', () => {
it('bash with run_in_background returns a task id immediately', async () => {
const ctx = await setup()
const result = await call(ctx, 'bash', { command: 'sleep 0.2; echo bg-done', description: 'test command', run_in_background: true })
expect(result.isError).toBe(false)
expect(text(result)).toMatch(/^started background task bash-\d+$/)
})
it('bash_output polls incrementally and reports status', async () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'echo first; sleep 0.3; echo second', description: 'test command', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await new Promise(resolve => setTimeout(resolve, 150))
const first = await call(ctx, 'bash_output', { task_id: id })
expect(text(first)).toContain('first')
expect(text(first)).toContain('[status: running]')
await ctx.bash.get(id)!.done
const second = await call(ctx, 'bash_output', { task_id: id })
expect(text(second)).toContain('second')
expect(text(second)).not.toContain('first')
expect(text(second)).toContain('[status: completed, exit code: 0]')
const third = await call(ctx, 'bash_output', { task_id: id })
expect(text(third)).toContain('(no new output)')
})
it('bash_output flags lossy reads with spill paths', async () => {
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(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 })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await ctx.bash.get(id)!.done
const read = await call(ctx, 'bash_output', { task_id: id })
expect(text(read)).toContain('[some output was dropped from memory; full output: ')
})
it('bash_output reports unavailable when a lossy read has no safe spill path', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(LossyReadBashExecutor)
await ctx.plugin(ToolBash)
const read = await call(ctx, 'bash_output', { task_id: 'bash-lossy' })
expect(text(read)).toBe('tail\n[some output was dropped from memory; full output: (unavailable)]\n[status: running]')
})
it('bash_kill stops a running task; repeat reports already-finished', async () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
const killed = await call(ctx, 'bash_kill', { task_id: id })
expect(text(killed)).toBe(`killed background task ${id}`)
await ctx.bash.get(id)!.done
const again = await call(ctx, 'bash_kill', { task_id: id })
expect(text(again)).toBe(`task ${id} had already finished`)
const status = await call(ctx, 'bash_output', { task_id: id })
expect(text(status)).toContain('[status: killed by SIGTERM]')
})
it('unknown task ids are isError for both tools', async () => {
const ctx = await setup()
const read = await call(ctx, 'bash_output', { task_id: 'bash-999' })
expect(read.isError).toBe(true)
expect(text(read)).toMatch(/unknown bash task/)
const kill = await call(ctx, 'bash_kill', { task_id: 'bash-999' })
expect(kill.isError).toBe(true)
})
it.each([
['bash_output', {}, /missing required property "task_id"/],
['bash_output', { task_id: 9 }, /"task_id" must be a string/],
['bash_kill', { task_id: '' }, /invalid task_id/],
])('%s rejects invalid task_id %j', async (tool, args, pattern) => {
const ctx = await setup()
const result = await call(ctx, tool, args)
expect(result.isError).toBe(true)
expect(text(result)).toMatch(pattern)
})
it('injects a completion notice into the owning agent (found via the registry by session token)', async () => {
const ctx = await setup()
const inject = vi.fn()
// The notice path looks the agent up in ctx.agents by its session token, so
// the agent must be REGISTERED (not merely passed to execute). Mount a
// registry and register a fake whose session.header.id IS the owner token.
const agent = registerFakeAgent(ctx, 'bg', inject)
const started = await ctx.tools.execute({
callId: CallId('call-bg'),
name: 'bash',
arguments: { command: 'true', description: 'test command', run_in_background: true },
agent,
})
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await ctx.bash.get(id)!.done
expect(inject).toHaveBeenCalledTimes(1)
const [content, options] = inject.mock.calls[0] as [
{ type: string; text: string }[],
{ source: { kind: string; plugin: string } },
]
expect(content[0]!.text).toContain(`background bash task ${id} finished`)
expect(content[0]!.text).toContain('bash_output')
expect(options.source).toEqual({ kind: 'plugin', plugin: 'tool-bash' })
})
it('swallows ONLY the disposed-agent inject error', async () => {
const ctx = await setup()
const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('agent "x" is disposed') })
const started = await ctx.tools.execute({
callId: CallId('call-bg2'),
name: 'bash',
arguments: { command: 'true', description: 'test command', run_in_background: true },
agent,
})
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
})
it('rethrows a non-disposed inject failure (not blindly swallowed)', async () => {
const ctx = await setup()
// A real bug in inject (not the benign disposed race) must surface — the
// base-class notifier contains it (logs, does not reject task.done), but
// the listener itself must have thrown rather than silently eaten it.
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
const agent = registerFakeAgent(ctx, 'bg', () => { throw new Error('unexpected inject bug') })
const started = await ctx.tools.execute({
callId: CallId('call-bg3'),
name: 'bash',
arguments: { command: 'true', description: 'test command', run_in_background: true },
agent,
})
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await ctx.bash.get(id)!.done
// notifyTaskDone caught and logged the rethrown error.
expect(errorSpy).toHaveBeenCalled()
const logged = errorSpy.mock.calls.flat().some(arg => arg instanceof Error && arg.message === 'unexpected inject bug')
expect(logged).toBe(true)
} finally {
errorSpy.mockRestore()
}
})
it('drops the notice cleanly when the owning agent is gone from the registry by completion', async () => {
// A bash task (owned by the host-scoped bash-local fiber) can OUTLIVE its
// per-session agent — e.g. the ACP session disconnects and its AgentHandle
// disposes while the background task is still running. The owner token is
// still on the task, but no live agent carries it anymore, so the registry
// lookup finds nothing and the notice is dropped (no throw).
const ctx = await setup()
const inject = vi.fn()
const agent = registerFakeAgent(ctx, 'bg', inject)
const started = await ctx.tools.execute({
callId: CallId('call-bg4'),
name: 'bash',
arguments: { command: 'true', description: 'test command', run_in_background: true },
agent,
})
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
// Unregister the agent BEFORE the task completes (simulate disconnect).
unregisterFakeAgents(ctx)
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
expect(inject).not.toHaveBeenCalled()
})
it('does not notify when no agent owned the task', async () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await expect(ctx.bash.get(id)!.done).resolves.toBeUndefined()
})
})
describe('background task ownership (cross-session isolation)', () => {
/** Run a tool on behalf of a specific agent (sets exec.agent). */
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) {
return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} })
}
// Ownership is by TOKEN (session.header.id), NOT agent object identity — so
// each agent needs a DISTINCT session id, else every fake yields the same
// token and the isolation tests pass for the wrong reason (all tasks owned by
// the same token). The impl reads `session.header.id`, so the fakes MUST carry
// it.
const fakeAgent = (sessionId: string) =>
({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => {
const ctx = await setup()
const a = fakeAgent('sess-a')
const b = fakeAgent('sess-b')
// Agent A starts a long-running background task.
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
// Agent B (a different session token) cannot read or kill A's task.
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
expect(readByB.isError).toBe(true)
expect(text(readByB)).toMatch(/belongs to another session/)
const killByB = await callAs(ctx, b, 'bash_kill', { task_id: id })
expect(killByB.isError).toBe(true)
expect(text(killByB)).toMatch(/belongs to another session/)
// The task is still running (B's kill did nothing) — A can still kill it.
const killByA = await callAs(ctx, a, 'bash_kill', { task_id: id })
expect(killByA.isError).toBe(false)
expect(text(killByA)).toBe(`killed background task ${id}`)
})
it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => {
// Ownership fences by session.header.id, NOT Agent object identity. Two
// distinct Agent objects sharing one session token (e.g. an agent re-created
// on the same session) are the SAME owner.
const ctx = await setup()
const a1 = fakeAgent('sess-shared')
const a2 = fakeAgent('sess-shared') // distinct object, same token
const started = await callAs(ctx, a1, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
const readByA2 = await callAs(ctx, a2, 'bash_output', { task_id: id })
expect(readByA2.isError).toBe(false)
await callAs(ctx, a1, 'bash_kill', { task_id: id }) // cleanup
})
it('the no-agent (non-loop) caller cannot access an owned task', async () => {
const ctx = await setup()
const a = fakeAgent('sess-a')
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
// A call with no exec.agent has no token → cannot prove ownership of an owned task.
const read = await callAs(ctx, undefined, 'bash_output', { task_id: id })
expect(read.isError).toBe(true)
expect(text(read)).toMatch(/belongs to another session/)
await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
})
it('an UNOWNED task (started with no agent) is accessible to anyone', async () => {
const ctx = await setup()
// Started by a non-loop caller (no exec.agent) → no owner token recorded.
const started = await callAs(ctx, undefined, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
// Any agent (and the no-agent caller) may read/kill it.
const read = await callAs(ctx, fakeAgent('sess-x'), 'bash_output', { task_id: id })
expect(read.isError).toBe(false)
const killed = await callAs(ctx, undefined, 'bash_kill', { task_id: id })
expect(killed.isError).toBe(false)
})
it('the owner can still access its task AFTER it completes (owner token persists on the task)', async () => {
const ctx = await setup()
const a = fakeAgent('sess-a')
const b = fakeAgent('sess-b')
const started = await callAs(ctx, a, 'bash', { command: 'echo done', description: 'bg', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
await ctx.bash.get(id)!.done
// Completion does NOT clear ownership: B is still rejected, A still allowed.
const readByB = await callAs(ctx, b, 'bash_output', { task_id: id })
expect(readByB.isError).toBe(true)
expect(text(readByB)).toMatch(/belongs to another session/)
const readByA = await callAs(ctx, a, 'bash_output', { task_id: id })
expect(readByA.isError).toBe(false)
})
it('ownership SURVIVES an independent tool-bash HMR reload (token lives on the executor)', async () => {
// The owner token lives on the TASK inside the executor (dsh-bash fiber), NOT
// in a tool-bash plugin-local map. So reloading ONLY tool-bash (executor +
// task survive) preserves ownership. This is the regression guard: a
// plugin-local map would make B accessible after reload, and this test would
// catch it.
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 }
const fiber = await ctx.plugin(ToolBash)
const a = fakeAgent('sess-a')
const b = fakeAgent('sess-b')
const started = await callAs(ctx, a, 'bash', { command: 'sleep 60', description: 'bg', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
// Before reload: B is rejected (A owns it).
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
// Reload ONLY tool-bash; the executor and its running task (with its owner
// token) survive.
await fiber.dispose()
await ctx.plugin(ToolBash)
expect(ctx.bash.get(id)?.status).toBe('running')
expect(ctx.bash.ownerOf(id)).toBe('sess-a')
// After reload, ownership is INTACT → B is STILL rejected.
expect((await callAs(ctx, b, 'bash_output', { task_id: id })).isError).toBe(true)
await callAs(ctx, a, 'bash_kill', { task_id: id }) // cleanup
})
})
describe('session-cwd routing (per-session workdir)', () => {
function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, args: unknown) {
return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} })
}
// An agent whose session header carries a cwd (what session/new records).
const agentInCwd = (cwd: string) =>
({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent
it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => {
const ctx = await setup()
const result = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('an explicit absolute workdir overrides the session cwd', async () => {
const ctx = await setup()
const result = await callAs(ctx, agentInCwd('/'), { command: 'pwd', description: 'pwd', workdir: '/tmp' })
expect(text(result).trim()).toMatch(/\/tmp$/)
})
it('a relative workdir is resolved against the session cwd', async () => {
const ctx = await setup()
// session cwd /usr + relative 'bin' → /usr/bin
const result = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd', workdir: 'bin' })
expect(text(result).trim()).toMatch(/\/usr\/bin$/)
})
it('two sessions with different cwds each run bash in their own dir', async () => {
const ctx = await setup()
const inUsr = await callAs(ctx, agentInCwd('/usr'), { command: 'pwd', description: 'pwd' })
const inTmp = await callAs(ctx, agentInCwd('/tmp'), { command: 'pwd', description: 'pwd' })
expect(text(inUsr).trim()).toMatch(/\/usr$/)
expect(text(inTmp).trim()).toMatch(/\/tmp$/)
})
it('falls back to the executor default when the agent has no session cwd', async () => {
const ctx = await setup()
// No exec.agent at all → executor uses its config/process.cwd() default.
const result = await ctx.tools.execute({ callId: CallId('cwd-noagent'), name: 'bash', arguments: { command: 'pwd', description: 'pwd' } })
expect(result.isError).toBe(false)
expect(text(result).trim().length).toBeGreaterThan(0)
})
})
describe('renderResult', () => {
const base = {
exitCode: 0 as number | null,
signal: null as NodeJS.Signals | null,
timedOut: false,
aborted: false,
timeoutMs: 1000,
stdout: { text: '', truncated: false },
stderr: { text: '', truncated: false },
}
it('renders stderr-only output without a stdout prefix', () => {
expect(renderResult({ ...base, stderr: { text: 'err\n', truncated: false } }))
.toBe('[stderr]\nerr\n')
})
it('adds a separator when stdout does not end with a newline', () => {
expect(renderResult({
...base,
stdout: { text: 'out', truncated: false },
stderr: { text: 'err', truncated: false },
})).toBe('out\n[stderr]\nerr')
})
it('appends exit-code markers after a newline for unterminated output', () => {
expect(renderResult({ ...base, exitCode: 7, stdout: { text: 'x', truncated: false } }))
.toBe('x\n[exit code: 7]')
})
it('renders signal kills without the timeout marker when not timed out', () => {
expect(renderResult({ ...base, exitCode: null, signal: 'SIGKILL' }))
.toBe('(no output)\n[killed by signal: SIGKILL]')
})
it('reports a timeout that exited 0 (trapped signal) without a kill marker', () => {
expect(renderResult({ ...base, exitCode: 0, signal: null, timedOut: true }))
.toBe('(no output)\n[timed out after 1000ms]')
})
it('orders the timeout marker before a kill marker', () => {
expect(renderResult({ ...base, exitCode: null, signal: 'SIGTERM', timedOut: true }))
.toBe('(no output)\n[timed out after 1000ms]\n[killed by signal: SIGTERM]')
})
it('notes truncation with a fallback when the spill path is missing', () => {
expect(renderResult({ ...base, stdout: { text: 'tail', truncated: true } }))
.toBe('tail\n[output truncated; full output: (unavailable)]')
})
})
describe('status lines', () => {
it('reports kills without a recorded signal (executor raced process exit)', async () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'sleep 60', description: 'test command', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
const task = ctx.bash.get(id)!
await call(ctx, 'bash_kill', { task_id: id })
await task.done
// Simulate the variant where the close event carried no signal.
task.signal = null
const read = await call(ctx, 'bash_output', { task_id: id })
expect(text(read)).toContain('[status: killed]')
})
it('reports completed tasks with a null exit code as exit 0', async () => {
const ctx = await setup()
const started = await call(ctx, 'bash', { command: 'true', description: 'test command', run_in_background: true })
const id = BashTaskId(/task (bash-\d+)/.exec(text(started))![1]!)
const task = ctx.bash.get(id)!
await task.done
// Defensive: completed tasks always carry an exit code in practice; the
// ?? 0 fallback covers task shapes from other executor implementations.
task.exitCode = null
const read = await call(ctx, 'bash_output', { task_id: id })
expect(text(read)).toContain('[status: completed, exit code: 0]')
})
})
describe('tool-owned UI presentation (presentCall / presentResult)', () => {
it('bash presentCall: title is the command, description as a content block, marks a terminal; workdir → cwd (absolute or relative, bridge resolves)', async () => {
const ctx = await setup()
// No explicit workdir → the call still flags a terminal, but with no cwd (the
// UI bridge fills the session cwd it owns; the pure presenter can't see it).
// The command is the title (an execute card hides rawInput); the description
// rides as a content text block (shown above the terminal card).
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls -la src', description: 'List files in src' }))
.toEqual({ title: 'ls -la src', kind: 'execute', rawInput: 'ls -la src', content: [{ type: 'text', text: 'List files in src' }], terminal: {} })
// An ABSOLUTE workdir is surfaced verbatim as the terminal cwd header.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: '/tmp/x' }))
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: '/tmp/x' } })
// A RELATIVE workdir is passed through AS-IS (the bridge resolves it against
// the session cwd, matching where execution runs) — not dropped.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'pwd', description: 'Print dir', workdir: 'sub' }))
.toEqual({ title: 'pwd', kind: 'execute', rawInput: 'pwd', content: [{ type: 'text', text: 'Print dir' }], terminal: { cwd: 'sub' } })
})
it('bash presentResult: console-block content AND terminal.output (RAW newlines) + parsed exit code', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!.presentResult!(
{ command: 'echo hi', description: 'echo' },
{ content: [{ type: 'text', text: 'hi\n[exit code: 0]\n\n' }], isError: false },
)
// The fenced ```console content trims trailing blank lines for a tidy block;
// terminal.output keeps the RAW bytes (newlines intact) a terminal renderer
// needs; exitCode is parsed back from the [exit code: N] marker.
expect(present).toEqual({
content: [{ type: 'text', text: '```console\nhi\n[exit code: 0]\n```' }],
terminal: { output: 'hi\n[exit code: 0]\n\n', exitCode: 0 },
})
})
it('bash presentResult: a non-zero exit and a signal kill parse into exitCode / signal', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
const nonzero = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'oops\n[exit code: 3]' }], isError: false })
expect(nonzero?.terminal).toEqual({ output: 'oops\n[exit code: 3]', exitCode: 3 })
const killed = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: 'gone\n[killed by signal: SIGKILL]' }], isError: false })
expect(killed?.terminal).toEqual({ output: 'gone\n[killed by signal: SIGKILL]', signal: 'SIGKILL' })
})
it('bash presentResult exit parse is the inverse of renderResult markers (round-trip)', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!
// For each renderResult outcome, the rendered text fed back through
// presentResult recovers the matching structured exit — the parse and the
// marker emission co-evolve in one file, so this pins the pair.
const base = {
aborted: false,
timeoutMs: 1000,
stdout: { text: 'out', truncated: false },
stderr: { text: '', truncated: false },
}
const cases = [
{ result: { ...base, exitCode: 0, signal: null, timedOut: false }, expect: { exitCode: 0 } },
{ result: { ...base, exitCode: 7, signal: null, timedOut: false }, expect: { exitCode: 7 } },
{ result: { ...base, exitCode: null, signal: 'SIGTERM' as const, timedOut: false }, expect: { signal: 'SIGTERM' } },
// A trapped-timeout run that exits 0 has no signal/exit marker → reads as exit 0 (it did exit 0).
{ result: { ...base, exitCode: 0, signal: null, timedOut: true }, expect: { exitCode: 0 } },
]
for (const c of cases) {
const rendered = renderResult(c.result)
const out = present.presentResult!({ command: 'x', description: 'x' }, { content: [{ type: 'text', text: rendered }], isError: false })
const { output: _o, ...exit } = out?.terminal ?? {}
expect(exit).toEqual(c.expect)
}
})
it('bash presentResult: a clean exit-0 whose output ENDS in marker-like text is NOT read as a failure', async () => {
const ctx = await setup()
const args = { command: 'printf "[exit code: 5]"', description: 'print' }
// A successful command can print text that looks like a marker. renderResult
// for a clean exit 0 appends NOTHING (and no trailing newline), so the body's
// own tail is `[exit code: 5]`. The parse requires a LEADING newline before
// the marker (renderResult always inserts one before a REAL marker), so this
// no-trailing-newline body is NOT mistaken for a failure → exitCode 0.
const out = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[exit code: 5]' }], isError: false })
expect(out?.terminal).toEqual({ output: '[exit code: 5]', exitCode: 0 })
// Same for a fake signal marker with no leading newline.
const sig = ctx.tools.get('bash')!.presentResult!(args, { content: [{ type: 'text', text: '[killed by signal: SIGKILL]' }], isError: false })
expect(sig?.terminal).toEqual({ output: '[killed by signal: SIGKILL]', exitCode: 0 })
})
it('bash presentCall/presentResult: a run_in_background call is NOT a terminal and its ack carries no exit pill', async () => {
const ctx = await setup()
// The background start returns a task-id ack, not a streamed run — no terminal.
const call = ctx.tools.get('bash')!.presentCall!({ command: 'sleep 100', description: 'wait', run_in_background: true })
expect(call).toEqual({ title: 'sleep 100', kind: 'execute', rawInput: 'sleep 100', content: [{ type: 'text', text: 'wait' }] })
expect((call as { terminal?: unknown }).terminal).toBeUndefined()
// The ack result is fenced text only — no terminal output / exit pill.
const result = ctx.tools.get('bash')!.presentResult!(
{ command: 'sleep 100', description: 'wait', run_in_background: true },
{ content: [{ type: 'text', text: 'started background task bash-1' }], isError: false },
)
expect(result?.terminal).toBeUndefined()
expect(result?.content).toEqual([{ type: 'text', text: '```console\nstarted background task bash-1\n```' }])
})
it('bash presentResult: an isError result carries no exit pill (no real process exit to report)', async () => {
const ctx = await setup()
// A spawn failure / abort has no process exit — the body is an error message,
// not renderResult output, so no terminal output/exit is emitted.
const out = ctx.tools.get('bash')!.presentResult!(
{ command: 'x', description: 'x' },
{ content: [{ type: 'text', text: 'command aborted' }], isError: true },
)
expect(out?.terminal).toBeUndefined()
expect(out?.content).toEqual([{ type: 'text', text: '```console\ncommand aborted\n```' }])
})
it('bash presentResult: leaves a non-text (unexpected) result untouched → undefined (UI keeps raw content)', async () => {
const ctx = await setup()
const present = ctx.tools.get('bash')!.presentResult!(
{ command: 'x', description: 'x' },
{ content: [{ type: 'image', url: 'https://x/y.png' }], isError: false },
)
expect(present).toBeUndefined()
})
it('bash presentResult: a result that is not exactly one block → undefined (no single text to fence)', async () => {
const ctx = await setup()
const args = { command: 'x', description: 'x' }
// Empty content (no block) and multi-block content both fall through.
expect(ctx.tools.get('bash')!.presentResult!(args, { content: [], isError: false })).toBeUndefined()
expect(ctx.tools.get('bash')!.presentResult!(args, {
content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }],
isError: false,
})).toBeUndefined()
})
it('bash_output / bash_kill presentCall: a readable task-scoped title, task id as rawInput', async () => {
const ctx = await setup()
expect(ctx.tools.get('bash_output')!.presentCall!({ task_id: 'bash-3' }))
.toEqual({ title: 'Read output from background task bash-3', kind: 'execute', rawInput: 'bash-3' })
expect(ctx.tools.get('bash_kill')!.presentCall!({ task_id: 'bash-3' }))
.toEqual({ title: 'Kill background task bash-3', kind: 'execute', rawInput: 'bash-3' })
})
it('presentCall validates softly: malformed args (missing required description) return undefined, never throw', async () => {
const ctx = await setup()
// defineTool wraps presentCall to soft-validate against the schema and fall
// back to undefined (a generic UI presentation) rather than throwing on the
// display path — it may run on replay of arbitrary logged args. The
// ToolDefinition.presentCall takes `unknown`, so a malformed shape needs no cast.
expect(ctx.tools.get('bash')?.presentCall?.({ command: 'ls' })).toBeUndefined()
})
})

View File

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

View File

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

16
packages/core/README.md Normal file
View File

@@ -0,0 +1,16 @@
# core/ — product API spine
The packages every harness build is assembled from: the session log, the system-prompt assembly, the tool registry, the agent vocabulary, and the one concrete loop that drives them. These are **product** packages — the stable surface plugins and consumers build against.
| Package | Role | ctx key |
|---|---|---|
| `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` |
| `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) |
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop; everything else in `core/` is interface/vocabulary. Plugins depend on the `agent` vocabulary, never on `agent-loop` directly, so the loop stays swappable.
`agent-core` is the composition counterpart: one bundle plugin that loads the whole providerless spine (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. App packages (`ui/stdio-agent`, `ui/acp-agent`) consume it and add only a front door; a leaf adds only the swappable backends. It lives in `core/` because it composes exclusively `core/` + interface packages and ships no provider, executor, or UI of its own.

View File

@@ -0,0 +1,44 @@
# @deepseek-ai/dsh-agent-core
The **providerless, executor-less, UI-less agent spine** as ONE Cordis bundle plugin. It loads the fixed set of services every harness agent needs and forwards the loop's `agents` list as its own config — so an app package composes a working agent by adding only a front door and the swappable backends.
This is the package to read to see **the whole plugin tree at once** — the teaching role the inlined `echo-agent` `cordis.yml` used to play before the spine moved behind this bundle.
## The tree it loads
`apply(ctx, config)` mounts each of these as a child of the bundle fiber:
```
@cordisjs/plugin-timer timer service (writes nothing to stdout)
@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-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
@deepseek-ai/dsh-agent-loop THE concrete loop (gets the forwarded `agents`)
```
## What it deliberately leaves OUTSIDE the bundle
The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle:
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-agent`](../../ui/stdio-agent/README.md), [`dsh-acp-agent`](../../ui/acp-agent/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC).
This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door.
## Config
```ts
import type { Config } from '@deepseek-ai/dsh-agent-core'
// Config === AgentLoop.Config — the `agents` list, default [].
```
The bundle FORWARDS `agent-loop`'s `agents` list as its own (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`). Forwarding the list is exactly why the loop can live in the shared spine even though the apps disagree on which agents to pre-create.
## Why a code bundle, not a shared YAML include
A YAML include can dedupe the config, but it cannot OWN a `bin`, and it can only *describe* the front-door coupling in a comment and trust each leaf to obey. Moving the spine into a package, and the front-door cluster into the app packages, means the default leaf for an ACP server has no logger entry to copy wrong — "the ACP app never logs to stdout" stops being a prose warning a leaf must remember and becomes the app package's default shape (a leaf can still add a sibling logger, so the rule stays documented — but it has nothing to get wrong by default). Services register in the root store keyed by their isolate symbol, so a child loaded here is visible to the bundle's siblings (the leaf's adapter and executor) exactly as a nested `plugin-include` subtree's services were — cordis gates every read on `inject`, never on load order.

View File

@@ -0,0 +1,46 @@
{
"name": "@deepseek-ai/dsh-agent-core",
"description": "The providerless/executor-less/UI-less agent spine as one Cordis bundle plugin (timer + llm + sessions + system-prompt + tools + agents + invariants + tool-bash + agent-loop)",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/index.d.ts",
"exports": {
".": {
"types": "./lib/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@cordisjs/plugin-timer": "^1.1.2",
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^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-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@cordisjs/plugin-timer": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,88 @@
/**
* The providerless, executor-less, UI-less agent spine as ONE bundle plugin.
*
* Loads the fixed set of services every harness agent needs — `timer`, the LLM
* service, the session store, system-prompt assembly, the tool registry, the
* agent registry, the dev-mode invariants, the model-facing `bash` tool
* schemas, and the concrete `agent-loop` — and forwards the loop's `agents`
* list as its OWN config (default `[]`), so each app supplies its own
* pre-created agents.
*
* It is deliberately NOT the whole app: the swappable choices stay OUTSIDE the
* bundle, picked by whatever loads it.
* - the LLM ADAPTER (`llm-deepseek`/`llm-pi-ai`/`llm-replay`) — the bundle
* ships the abstract `llm` service + `tool-bash` consumer schema; the leaf
* registers a concrete adapter on `ctx.llm`.
* - the bash EXECUTOR (`bash-local` or a sandboxed impl) — the bundle ships
* the `bash` tool consumer; the leaf provides `ctx.bash`.
* - the PRESENTATION (stdio UI / ACP bridge / a logger) and the per-app infra
* (a console logger, `hmr`) — these are the coupled "front-door cluster" the
* app packages ({@link @deepseek-ai/dsh-stdio-agent},
* {@link @deepseek-ai/dsh-acp-agent}) bake in, NOT the shared spine.
*
* This is the interface/implementation/consumer seam at the composition level:
* the bundle owns the shared spine, the leaf owns the backends, the app package
* owns the front door. `timer` is in the spine (common to every front door — it
* writes nothing to stdout); the console logger is NOT (it writes to stdout,
* which the ACP bridge reserves for its JSON-RPC channel).
*
* Services register in the root store keyed by their isolate symbol, so a child
* loaded here via `ctx.plugin(...)` is visible to the bundle's SIBLINGS (the
* leaf's adapter and executor) exactly as a nested `plugin-include` subtree's
* services were before this bundle existed — cordis gates every read on
* `inject`, never on load order, so the fixed child set resolves regardless of
* which entry loads first.
*
* Plugin export shape: named `name`/`Config`/`apply`, NO default export — the
* cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray
* default would collapse the module to the bare `apply` function and drop the
* `Config` schema (see docs/postmortem/0001). The keyless Loader-path smokes in
* the app packages guard this end-to-end.
*
* @module @deepseek-ai/dsh-agent-core
*/
import type { Context } from 'cordis'
import Timer from '@cordisjs/plugin-timer'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import * as invariants from '@deepseek-ai/dsh-invariants'
import * as toolBash from '@deepseek-ai/dsh-tool-bash'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
export const name = 'agent-core'
/**
* Bundle config: the agent-loop `agents` list, forwarded verbatim. Default `[]`
* — an app that pre-creates no agents (the ACP bridge creates them on demand at
* `session/new`) simply omits it; an app that needs a pre-created `main` (the
* stdio chat) supplies one. This IS {@link AgentLoopConfig}, so the schema and
* the forwarded shape can never drift.
*/
export type Config = AgentLoopConfig
/** Forward the loop's own schema so validation + defaulting stay identical. */
export const Config = AgentLoop.Config
/**
* Load the spine. Each `ctx.plugin(...)` mounts one child of the bundle fiber;
* `agent-loop` receives the forwarded `agents` list. Load order is irrelevant
* (cordis pends each fiber on its `inject` until the services it needs exist),
* but the listing mirrors the dependency layering for readability: the LLM
* vocabulary and core registries first, then the dev tripwire and the bash tool
* consumer, then the loop that drives them.
*/
export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
ctx.plugin(SystemPrompt)
ctx.plugin(ToolRegistry)
ctx.plugin(AgentRegistry)
ctx.plugin(invariants)
ctx.plugin(toolBash)
ctx.plugin(AgentLoop, { agents: config.agents })
}

View File

@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import * as agentCore from '../src/index.ts'
import { AgentId } from '@deepseek-ai/dsh-agent'
/**
* Unit coverage for the @deepseek-ai/dsh-agent-core bundle: mounting it brings
* up the whole providerless spine in one `ctx.plugin`, and the forwarded
* `agents` config reaches the loop (default `[]`, or a pre-created agent).
*
* The bundle is exercised through `ctx.plugin(agentCore, …)` — the NAMESPACE
* import, the same shape the Loader builds from `unwrapExports`. The real
* Loader-path guard (export shape, `unwrapExports`) is the app packages' keyless
* bin smokes; here we assert the composition + config forwarding.
*/
async function mount(config?: agentCore.Config): Promise<Context> {
const ctx = new Context()
await ctx.plugin(agentCore, config)
// The bundle mounts its children inside apply() (not awaited there); let their
// fibers settle so the spine services and any pre-created agent are ready.
await new Promise(resolve => setTimeout(resolve, 50))
return ctx
}
describe('dsh-agent-core bundle', () => {
it('brings up the full providerless spine', async () => {
const ctx = await mount()
// One service from each layer of the spine proves the children loaded.
expect(ctx.get('timer')).toBeDefined()
expect(ctx.get('llm')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('systemPrompt')).toBeDefined()
expect(ctx.get('tools')).toBeDefined()
expect(ctx.get('agents')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
await ctx.fiber.dispose()
})
it('defaults the agents list to empty (no pre-created agents)', async () => {
const ctx = await mount()
expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('forwards a pre-created agent to the loop', async () => {
const ctx = await mount({
agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: 'hi' }],
})
expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined()
await ctx.fiber.dispose()
})
it('re-exports the loop config schema as its own', () => {
expect(agentCore.Config).toBeDefined()
expect(agentCore.name).toBe('agent-core')
})
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/Config/apply', () => {
// Postmortem 0001 guard: a stray `export default apply` makes the Loader's
// `unwrapExports` (`exports.default ?? exports`) collapse the module to the
// bare `apply` function, DROPPING the named `name`/`Config`. This package has
// no `inject` export (it mounts children that carry their own), so that
// collapse would NOT crash at load — the plugin would boot but silently lose
// its config schema. This bundle is also never Loader-unwrapped by any smoke
// (the apps import it directly; the mount test namespace-mounts it), so this
// is its ONLY export-shape guard. Assert directly AND through the real
// `unwrapExports` so adding `export default` to src/index.ts fails here.
expect('default' in agentCore).toBe(false)
expect(typeof agentCore.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(agentCore) as Record<string, unknown>
expect(unwrapped).toBe(agentCore)
expect(unwrapped.name).toBe('agent-core')
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
})

View File

@@ -0,0 +1,42 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/timer"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/agent-loop"
},
{
"path": "../../support/invariants"
},
{
"path": "../../bash/tool-bash"
}
]
}

View File

@@ -1,6 +1,6 @@
# dsh-agent-loop
THE concrete agent plugin: `LoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle.
This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here.
@@ -8,12 +8,14 @@ This is the only package in the harness that contains concrete loop logic. Every
### Public API
- `ctx.agentLoop.create(id: string, options?: AgentOptions): LoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
- `ctx.agentLoop.create(id: string, options?: AgentOptions): ReactLoopAgent` — config-driven create: an agent on a fresh per-run session id `${id}-session-<uuid>` (no cwd). Used for `cordis.yml`-configured agents. The per-run uuid avoids colliding with the on-disk log a prior run materialized once a durable persistence backend is loaded; each run is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber.
`AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface):
- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? })` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`.
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? })` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent).
- `ctx.agents.create({ agentId, sessionId, meta?, agentOptions? }): AgentHandle` — programmatic create on a caller-supplied `sessionId` (e.g. an ACP-generated id), NOT `${id}-session`. Returns an [`AgentHandle`](../agent/README.md) — the owner disposes it to tear down exactly this agent (stop loop + await quiescence + unregister + remove session).
- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions? }): Promise<AgentHandle>` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). Returns an `AgentHandle`.
The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle) — only the programmatic factory callers (the ACP bridge) hold a handle and own per-agent teardown.
### Injected services
@@ -35,7 +37,7 @@ Agents listed in config are auto-created at startup.
### Classes
- `LoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
- `ReactLoopAgent` — the concrete `Agent` implementation. Owns the inbox (`Inbox`), the per-step `AbortController`, and the loop driver. Everything observable happens through session events and the `agent/*` event taxonomy.
- `Inbox` — per-agent queued + steering FIFOs (`enqueue`, `steer`, `drainQueued`, `drainSteering`, `waitForQueued`).
### Loop lifecycle (`loop.ts`)
@@ -66,6 +68,8 @@ forever:
Error containment: a throwing plugin ends the **turn**, never the loop. Dispose mid-turn emits `agent/status('disposed')` and ends with reason `disposed`. A step that hits the model's output-token ceiling makes the turn end `max-tokens` (the rule: any `max-tokens` step in the turn surfaces as `max-tokens`; `disposed`/`aborted`/`error` still take precedence) — distinct from a clean `completed` stop.
Cancellation: `agent.cancel()` is the single public stop primitive — it clears the queued + steering FIFOs, aborts the in-flight step, and drives a turn-scoped marker the driver checks at every point a turn could start or continue (right after the idle wait, after the `running` flip, before each step, and at the continuation gate) so a turn about to start is dropped. A cancelled turn ends `aborted`; a queued-but-not-started prompt never runs and cannot be batched into the cancelled turn. The marker is reset once per loop iteration, so a cancel governs exactly one turn and never leaks onto a later prompt. (The loop still aborts its own per-step `AbortController` directly on disposal and from `cancel()`; that controller is loop-internal, not a public verb.)
### What is NOT here
Everything that goes beyond "call the model, run the tools, repeat" belongs to plugins listening on the event taxonomy:

View File

@@ -1,5 +1,5 @@
/**
* The concrete Agent implementation: LoopAgent plus its inbox. Everything
* The concrete Agent implementation: ReactLoopAgent plus its inbox. Everything
* observable happens through session events and the agent/* event taxonomy
* plugins never need this class.
*
@@ -21,15 +21,42 @@ import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts'
* the loop driver. Everything observable happens through session events and
* the agent/* event taxonomy plugins never need this class.
*/
export class LoopAgent implements Agent {
export class ReactLoopAgent implements Agent {
readonly inbox = new Inbox()
private _status: AgentStatus = 'idle'
private currentAbort: AbortController | undefined
/**
* Turn-scoped cancel marker, set by {@link cancel} and read/cleared by the
* driver loop (via the LoopHandle) at every point a turn could start or
* continue. Armed ONLY when there is something to cancel (a running turn, an
* in-flight step, or queued/steering work), so an idle no-op cancel cannot
* leave it set to wrongly drop a later prompt.
*/
private cancelRequested = false
/**
* The resolved reason for the pending {@link cancel} (`reason ?? 'cancelled'`),
* read by the driver loop's marker branches so a turn dropped in a
* marker-only window (pre-step / continuation, where no `AbortController`
* carries the reason) ends with the SAME `{kind:'aborted', reason}` the
* mid-step abort path produces from `abort.signal.reason`. Without this the
* caller's `cancel(reason)` would be silently replaced by the literal
* 'cancelled' whenever the cancel landed outside a running step making the
* logged reason race-dependent and the public `reason?` param half-effective.
*/
private cancelReason = 'cancelled'
private disposed: Promise<void>
private resolveDisposed!: () => void
/** Resolves when the driver loop has fully exited (tests/disposal). */
done: Promise<void> = Promise.resolve()
/**
* Pending {@link whenIdle} waiters, resolved by {@link settleIdleWaiters} when
* the agent next settles out of `running`. Kept as internal agent state (NOT
* an effect-scoped `ctx.on` listener) so a concurrent fiber disposal which
* runs the agent's own listeners' disposers cannot drop the waiter before
* the `disposed` transition fires and leave the promise hanging.
*/
private idleWaiters: (() => void)[] = []
constructor(
private ctx: Context,
@@ -49,7 +76,28 @@ export class LoopAgent implements Agent {
private setStatus(status: AgentStatus): void {
if (this._status === status || this._status === 'disposed') return
this._status = status
this.ctx.emit('agent/status', this, status)
// 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
// not hang on one bad listener).
if (status !== 'running') this.settleIdleWaiters()
try {
this.ctx.emit('agent/status', this, status)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${this.id}": agent/status listener threw on ${status}: ${String(error)}`)
}
}
/**
* Resolve and clear all pending {@link whenIdle} waiters. Called on a
* runningidle transition (from {@link setStatus}) and on disposal (from the
* {@link start} disposer, which chains `done` for true loop-exit quiescence).
*/
private settleIdleWaiters(): void {
const waiters = this.idleWaiters
this.idleWaiters = []
for (const resolve of waiters) resolve()
}
private resolveSource(options?: SendOptions): MessageSource {
@@ -143,15 +191,71 @@ export class LoopAgent implements Agent {
}
}
abort(reason?: string): void {
this.currentAbort?.abort(reason ?? 'aborted')
cancel(reason?: string): void {
// Arm-gate: only mark a cancellation when there is actually work to cancel —
// a running turn, an in-flight step, or queued/steering work. An idle cancel
// with nothing pending is a true no-op; arming the marker then would wrongly
// drop the NEXT legitimate prompt (the marker is consumed only at the loop's
// turn-decision points, which an idle parked loop does not reach until woken
// by a real send()). Note the gate canNOT be `status === 'running'` alone:
// the pre-step window (a send() queued but the loop not yet flipped to
// running) has status `idle` with `hasQueued` true, and the marker exists
// precisely to cover it.
if (this._status === 'running' || this.currentAbort !== undefined || this.inbox.hasQueued || this.inbox.hasSteering) {
this.cancelRequested = true
// Capture the resolved reason for the marker-only windows (pre-step /
// continuation). The mid-step path reads it from abort.signal.reason
// below; the marker path reads it via the LoopHandle's cancelReason().
this.cancelReason = reason ?? 'cancelled'
}
// Drop all pending queued + steering work (un-started prompts never run; the
// cancelled turn's steering is not re-enqueued). Cleared directly even when
// the loop is parked in waitForQueued — there is no turn to stop and nothing
// left for the parked loop to run, so no wake is needed.
this.inbox.clear()
// Interrupt an in-flight step immediately (the running turn observes the
// abort and ends `aborted`). The marker covers the windows where no step is
// running (pre-step, continuation).
this.currentAbort?.abort(reason ?? 'cancelled')
}
/**
* Resolve once the agent has reached quiescence after settling out of
* `running`. If it is already disposed, awaits {@link done} (the loop-exit
* promise) `agent/status('disposed')` fires in the disposer BEFORE the
* driver loop has unwound, so it is NOT itself a quiescence signal. If it is
* idle AND has no queued work, resolves immediately. Otherwise queues an
* internal waiter (see {@link idleWaiters}) released on the next
* runningidle/disposed transition, resolving on `idle` directly (the turn
* fully ended) or chaining {@link done} on `disposed` (wait for the loop to
* actually exit). Implements the {@link Agent.whenIdle} contract: a non-owner
* quiescence-observation hook, distinct from teardown (a lifecycle owner stops
* and unregisters via `AgentHandle.dispose()`, which awaits {@link done}
* directly, not through this).
*/
whenIdle(): Promise<void> {
if (this._status === 'disposed') return this.done
if (this._status !== 'running' && !this.inbox.hasQueued) return Promise.resolve()
// Register an internal waiter (resolved by settleIdleWaiters on the next
// running→idle/disposed transition), NOT an effect-scoped `ctx.on` listener:
// a concurrent fiber disposal runs this agent's listener disposers, which
// could remove a `ctx.on` waiter before the `disposed` transition fires and
// hang the promise. On disposal the disposer settles the waiter AND we chain
// `done` here for true loop-exit quiescence (status flips to disposed before
// the loop unwinds); a plain idle transition resolves directly.
return new Promise<void>((resolve) => {
this.idleWaiters.push(() => {
resolve(this._status === 'disposed' ? this.done : undefined)
})
})
}
/**
* Start the driver loop. Returns a disposer: calling it sets status to
* `disposed`, emits `agent/status('disposed')`, resolves the disposed
* promise (unblocking the idle wait), and aborts the current request if
* any. The returned `agent.done` promise resolves once the loop exits.
* promise (unblocking the idle wait), releases any `whenIdle` waiters, and
* aborts the current request if any. The returned `agent.done` promise
* resolves once the loop exits.
*/
start(): () => void {
this.done = runLoop(this.ctx, this, {
@@ -159,6 +263,16 @@ export class LoopAgent implements Agent {
setAbort: controller => void (this.currentAbort = controller),
disposed: this.disposed,
isDisposed: () => this._status === 'disposed',
isCancelled: () => this.cancelRequested,
cancelReason: () => this.cancelReason,
clearCancel: () => { this.cancelRequested = false },
// Settle whenIdle() waiters WITHOUT a status transition — the pre-step
// cancel-skip path drops the about-to-run turn and re-parks without ever
// flipping running→idle, so a waiter registered in the pre-step window
// (status idle, hasQueued was true) would otherwise hang. This emits no
// agent/status, so an ACP agent/status listener never sees a spurious idle
// that would resolve a freshly-queued prompt as cancelled.
settleIdle: () => { this.settleIdleWaiters() },
})
// The disposer must be infallible: it runs inside the fiber's LIFO
// disposal chain, where a throw would skip later disposers (e.g. the
@@ -167,6 +281,10 @@ export class LoopAgent implements Agent {
if (this._status === 'disposed') return
this._status = 'disposed'
this.resolveDisposed()
// Release whenIdle waiters BEFORE the (guarded) event emit — they are
// internal state that must settle even if a listener throws below. Each
// waiter chains `done`, so it resolves only once the loop actually exits.
this.settleIdleWaiters()
this.currentAbort?.abort('disposed')
// setStatus refuses transitions out of 'disposed', so emit directly —
// 'disposed' is part of the agent/status contract. Guarded: a throwing

View File

@@ -52,6 +52,16 @@ export class Inbox {
return this.steeringMessages.splice(0)
}
/**
* Discard all pending messages (queued + steering) without delivering them
* used by `cancel()`, which drops un-started work rather than draining it into
* a turn. Unlike `drainQueued`/`drainSteering`, the messages are thrown away.
*/
clear(): void {
this.queuedMessages.length = 0
this.steeringMessages.length = 0
}
/** Wait until a queued message arrives or `cancel` resolves. */
waitForQueued(cancel: Promise<void>): Promise<void> {
if (this.hasQueued) return Promise.resolve()

View File

@@ -1,5 +1,5 @@
/**
* THE concrete agent plugin: creates LoopAgents, runs their loops, and
* THE concrete agent plugin: creates ReactLoopAgents, runs their loops, and
* registers them in ctx.agents. Deliberately thin every behavior beyond
* "call the model, run the tools, repeat" belongs to plugins on the event
* taxonomy.
@@ -10,17 +10,16 @@
import { Context, Service } from 'cordis'
import { randomUUID } from 'node:crypto'
import z from 'schemastery'
import { AgentId } from '@deepseek-ai/dsh-agent'
import type { Agent, AgentFactory, AgentOptions, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent'
import type { AgentFactory, AgentHandle, AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions } 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'
import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { LoopAgent } from './agent.ts'
import { ReactLoopAgent } from './agent.ts'
export { LoopAgent } from './agent.ts'
export { ReactLoopAgent } from './agent.ts'
export { Inbox, type InboxMessage } from './inbox.ts'
export { runLoop } from './loop.ts'
@@ -33,7 +32,7 @@ declare module 'cordis' {
export interface Config {
/** Agents created from configuration at startup. */
agents: (AgentOptions & {
id: string
id: AgentId
/**
* If set, the config agent RESUMES this persisted session id instead of
* starting a fresh `${id}-session-<uuid>`. Sourced from an env var in
@@ -42,13 +41,17 @@ export interface Config {
* `dsh-session-persistence` backend; the resume is deferred until that
* service is available (via `ctx.inject`) and the loaded session's events
* seed the live session so history continues.
*
* The schema accepts a plain string at runtime (cordis.yml values are
* untyped); the brand is compile-time only the config format is the
* boundary where an id enters, so the TYPE declares the brand here.
*/
resumeSessionId?: string
resumeSessionId?: SessionId
})[]
}
/**
* The agent-loop plugin (`ctx.agentLoop`): creates {@link LoopAgent}s, runs
* The agent-loop plugin (`ctx.agentLoop`): creates {@link ReactLoopAgent}s, runs
* their loops, and registers them in `ctx.agents`. Also implements the
* {@link AgentFactory} seam, so plugins create/resume agents through
* `ctx.agents` (the interface) without depending on this concrete package.
@@ -60,14 +63,19 @@ export interface Config {
export class AgentLoop extends Service implements AgentFactory {
static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt']
static Config: z<Config> = z.object({
// The schema validates plain strings (cordis.yml config values are untyped at
// runtime); the {@link Config} TYPE declares the branded `id`/`resumeSessionId`
// because the config format is the boundary where an id enters. The brand is a
// zero-cost compile-time cast, so the runtime schema stays string-based and we
// assert the branded view once here — the single schema boundary.
static Config = z.object({
agents: z.array(z.object({
id: z.string().required(),
model: z.string(),
systemPrompt: z.string(),
resumeSessionId: z.string(),
})).default([]),
})
}) as unknown as z<Config>
constructor(ctx: Context, public config: Config) {
super(ctx, 'agentLoop')
@@ -118,25 +126,31 @@ export class AgentLoop extends Service implements AgentFactory {
* fork seeds the new Session with the parent's event log, spawn starts
* fresh; the child is returned as a regular Agent handle.
*/
create(id: string, options: AgentOptions = {}): LoopAgent {
create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent {
this.assertAgentIdFree(id)
const session = this.ctx.sessions.create(`${id}-session-${randomUUID()}`, { meta: {} })
return this.start(AgentId(id), options, session)
// Config/programmatic path: prepare the session and let start() fold its
// lifecycle into the agent's composite effect (so a fiber unload tears the
// 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)
return agent
}
/**
* Programmatic factory create ({@link AgentFactory}): an agent on a
* caller-supplied `sessionId` (NOT `${id}-session`), with optional session
* metadata (validated `cwd`, lineage). The ACP bridge uses this so the
* client-generated session id becomes the live/persisted session id.
* client-generated session id becomes the live/persisted session id. Returns
* an {@link AgentHandle} the owner disposes to tear down exactly this agent.
*/
createAgent(options: CreateAgentOptions): Agent {
// Check the agent id BEFORE creating the session: register() would reject a
// duplicate id only AFTER sessions.create(), leaving an orphaned live
// session (and lazy persistence state) that blocks reuse of that id.
createAgent(options: CreateAgentOptions): AgentHandle {
// Check the agent id BEFORE preparing the session: register() would reject a
// duplicate id only AFTER the session enters the store, leaving an orphaned
// live session (and lazy persistence state) that blocks reuse of that id.
this.assertAgentIdFree(options.agentId)
const session = this.ctx.sessions.create(options.sessionId, { meta: options.meta ?? {} })
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} })
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
}
/**
@@ -151,13 +165,23 @@ export class AgentLoop extends Service implements AgentFactory {
* forever) callers that need resume (ACP) inject `sessionPersistence`, so
* by the time this runs the service exists.
*/
async resume(options: ResumeAgentOptions): Promise<Agent> {
const persistence = this.ctx.sessionPersistence
// `sessionPersistence` is declaration-merged onto Context as non-optional,
// but the service is only present when a backend plugin is loaded — and
// AgentLoop deliberately does NOT inject it (that would pend non-persistent
// demos forever). So the runtime value can be undefined; the type cannot.
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
// Read the service through `ctx.get('sessionPersistence')` — a direct
// global-store lookup keyed by the isolate symbol — NOT
// `this.ctx.sessionPersistence`. AgentLoop deliberately does NOT inject
// `sessionPersistence` (injecting it would pend non-persistent demos
// forever). The `ctx.<name>` property proxy resolves a service by an
// ancestor-only walk of the current fiber's parent chain; from AgentLoop's
// own fiber (which lacks the inject) that walk never reaches the sibling
// backend fiber and throws "cannot get property … without inject". Worse,
// when the call arrives via a traceable shadow (e.g. the ACP bridge child
// fiber → `ctx.agents.resume()` → `this.factory.resume()`), the walk starts
// at the shadow's origin fiber and fails the same way. `ctx.get(name)`
// sidesteps the fiber walk entirely (a store lookup by the global isolate
// key), so resume works from any caller fiber. It is strict by default: a
// backend that is not ACTIVE (absent, or mid-teardown) reads as undefined
// and we reject below, rather than handing back an unusable handle.
const persistence = this.ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new Error('cannot resume: session persistence is not configured (load a dsh-session-persistence backend)')
}
@@ -173,20 +197,21 @@ export class AgentLoop extends Service implements AgentFactory {
* sessions store + registry are still read through `this.ctx` (both are in
* AgentLoop's static inject, so they resolve fine).
*/
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<Agent> {
private async resumeWith(persistence: SessionPersistence, options: ResumeAgentOptions): Promise<AgentHandle> {
this.assertAgentIdFree(options.agentId)
const { meta, events } = await persistence.load(SessionId(options.resumeSessionId))
const { meta, events } = await persistence.load(options.resumeSessionId)
// Re-check the agent id AFTER the await: the pre-load check above can go
// stale while load() is pending (a concurrent resume/create may register the
// same id). Re-checking immediately before sessions.create() keeps the
// same id). Re-checking immediately before prepare()/start keeps the
// "no orphaned session on a duplicate id" guarantee under concurrency.
this.assertAgentIdFree(options.agentId)
// Reconstruct the live session with the FULL persisted header (createdAt,
// cwd, lineage) so resume preserves identity, not just the cwd. The seed
// events make lastTurnNumber/deriveMessages continue; the backend already
// has state (cursor) from the load above, so onCreated is a no-op and the
// seed is not re-persisted.
const session = this.ctx.sessions.create(options.resumeSessionId, {
// seed is not re-persisted. prepare() (not create()) so the session
// lifecycle folds into the agent's composite effect (ordered teardown).
const session = this.ctx.sessions.prepare(options.resumeSessionId, {
seed: events,
meta: {
createdAt: meta.createdAt,
@@ -194,31 +219,81 @@ export class AgentLoop extends Service implements AgentFactory {
...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {},
},
})
return this.start(AgentId(options.agentId), options.agentOptions ?? {}, session)
return this.startOwned(options.agentId, options.agentOptions ?? {}, session)
}
/**
* Reject a duplicate agent id BEFORE any session is created, so a failed
* factory call never leaves an orphaned live session (and lazy persistence
* state) behind. `register()` enforces the same uniqueness, but only after
* `sessions.create()` has already run.
* Reject a duplicate agent id BEFORE the session is entered into the store, so
* a failed factory call never leaves an orphaned live session (and lazy
* persistence state) behind. `register()` enforces the same uniqueness, but
* only after the session has already entered the store.
*/
private assertAgentIdFree(id: string): void {
private assertAgentIdFree(id: AgentId): void {
if (this.ctx.agents.get(id) !== undefined) {
throw new Error(`agent "${id}" is already registered`)
}
}
/** Shared: construct a LoopAgent, register it, and start its loop (LIFO). */
private start(id: AgentId, options: AgentOptions, session: Session): LoopAgent {
const agent = new LoopAgent(this.ctx, id, options, session)
// Generator effect: stop and unregister are independent disposables
// (LIFO), so a throwing stop() cannot leak the registry entry.
this.ctx.effect(function* (this: AgentLoop) {
/**
* Shared: construct a ReactLoopAgent over a PREPARED (not-yet-entered)
* session, then build the ONE composite effect that owns the whole agent
* lifecycle session entry, registry registration, and the loop. Keeping all
* three in a SINGLE effect (not sibling effects) is load-bearing: a fiber
* unload disposes sibling effects CONCURRENTLY (`Promise.all`), which would
* race the session detach against the loop's closing flush and drop the
* closing `turn/end`. Inside one effect the disposers run as an ORDERED LIFO
* chain the runtime awaits each disposer's returned promise before the next:
*
* yield session-detach (disposed LAST detach onAppend + remove entry)
* yield register (disposed 2nd unregister)
* yield stop-and-drain (disposed FIRST request loop stop, await agent.done)
*
* So on teardown: the loop is stopped and AWAITED to exit (its final
* `session/flush` + `turn/end` fire through the still-attached `onAppend`),
* THEN the agent is unregistered, THEN the session is detached capturing the
* closing events before detach, whether the trigger is the handle's `dispose()`
* OR a fiber unload. Rollback safety: each yield runs before the next mutation,
* so a throwing `session/created`/`agent/created` listener unwinds the
* already-yielded disposers instead of leaking.
*
* Returns the agent plus the composite effect's disposer (`disposeAgent`).
*/
private start(id: AgentId, options: AgentOptions, session: Session): { 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)
yield agent.start()
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,
// disposed later) is still attached.
yield async () => { stop(); await agent.done }
}.bind(this), 'agentLoop.start()')
return agent
return { agent, disposeAgent: async () => { await dispose() } }
}
/**
* Build an {@link AgentHandle} for a PREPARED session + a fresh agent. The
* handle's `dispose()` runs the composite effect's disposer (see
* {@link start}) which stops the loop, awaits its exit (final flush
* captured), unregisters the agent, and detaches the session, in that order.
* The same composite effect is what a fiber unload disposes, so both teardown
* triggers honor the ordering identically.
*
* `dispose()` is MEMOIZED: the underlying cordis effect disposer is
* single-shot (a second call returns immediately because the effect's epoch is
* already cleared, NOT awaiting the in-flight teardown), so concurrent/repeated
* `dispose()` calls would otherwise resolve before the first call's
* `await agent.done` + final flush completed. Memoizing the promise makes every
* caller observe the SAME quiescence boundary, honoring the
* `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)
let disposing: Promise<void> | undefined
return { agent, dispose: () => (disposing ??= disposeAgent()) }
}
}

View File

@@ -13,7 +13,7 @@ import { BlockAssembler, HarnessError } from '@deepseek-ai/dsh-llm'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
import type { LoopAgent } from './agent.ts'
import type { ReactLoopAgent } from './agent.ts'
/** An Error with an optional machine-readable code (e.g., from LlmError or a throwing plugin). */
type CodedError = Error & { code?: string }
@@ -38,8 +38,8 @@ function toError(error: unknown): CodedError {
* caller's try/catch), OR end the stream with a finish-error/aborted chunk
* (the only option for adapters that can't throw mid-stream, e.g.
* library-backed ones). This translates the latter into a thrown step error
* so the turn ends error/aborted with a logged `error` event, never as a
* normal `completed` assistant message.
* so the turn ends error/aborted (the failure recorded on `turn/end.reason`),
* never as a normal `completed` assistant message.
*
* `FinishReason` is merge-extensible (plugins/adapters can add `kind`s), so
* the switch handles the known terminal-failure kinds and treats every other
@@ -98,7 +98,7 @@ function stepFinishReason(finish: FinishReason): TurnEndReason | undefined {
/**
* Ambient handles the loop driver receives from the agent. Decouples the
* pure function `runLoop` from the mutable LoopAgent fields, making the
* pure function `runLoop` from the mutable ReactLoopAgent fields, making the
* loop testable without a real agent.
*/
export interface LoopHandle {
@@ -107,6 +107,34 @@ export interface LoopHandle {
/** Resolves when the agent is disposed — unblocks the idle wait. */
disposed: Promise<void>
isDisposed(): boolean
/**
* Whether a `cancel()` is pending for the current turn. The driver checks this
* at every decision point where a turn could start or continue (right after
* the idle wait, after the `running` flip, before each step, and at the
* continuation gate) and drops the about-to-run / continuing turn. Reset once
* per loop iteration via {@link clearCancel} after the turn returns, so the
* marker governs exactly one cancellation and never leaks to a later prompt.
*/
isCancelled(): boolean
/**
* The resolved reason for the pending cancel (`reason ?? 'cancelled'`), read
* by the marker branches (pre-step / continuation) so a turn dropped where no
* `AbortController` carries the reason still records the caller's
* `cancel(reason)` value matching the mid-step abort path. Only meaningful
* when {@link isCancelled} is true.
*/
cancelReason(): string
/** Clear the cancel marker (called once per iteration after the turn returns). */
clearCancel(): void
/**
* Settle pending `whenIdle()` waiters WITHOUT a status transition. Used by the
* pre-step cancel-skip path: it drops the about-to-run turn and re-parks at the
* idle wait, so no `running→idle` transition fires to settle a `whenIdle()`
* waiter that was registered in the pre-step window this settles it directly
* (it emits no `agent/status`, so an ACP `agent/status` listener never sees a
* spurious idle that would resolve a freshly-queued prompt as cancelled).
*/
settleIdle(): void
}
/**
@@ -126,7 +154,7 @@ export interface LoopHandle {
* stream ctx.llm.stream(req) waterfall llm/stream (raw chunks)
* session('assistant/chunk'); emit agent/stream-chunk
* msg = waterfall agent/step-result BEFORE the log append, so the
* session('assistant/message','usage') session records what actually ran
* 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/result')
@@ -141,14 +169,56 @@ export interface LoopHandle {
* idle (emit agent/status) unless more queued
* ```
*/
export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle): Promise<void> {
export async function runLoop(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle): Promise<void> {
const { session } = agent
while (!handle.isDisposed()) {
await agent.inbox.waitForQueued(handle.disposed)
if (handle.isDisposed()) break
// Pre-step cancel (window 1): a `cancel()` landed after a `send()` woke the
// idle wait but before we flip to `running`. The cancelled queued/steering
// work is already cleared by `cancel()`. Clear the marker, then:
// - if NOTHING new is queued, drop the about-to-run turn and re-park,
// settling any `whenIdle()` waiter DIRECTLY (no running→idle transition
// fires here to settle it) and WITHOUT emitting `agent/status` (an ACP
// listener must not see a spurious idle that resolves a freshly-queued
// prompt as cancelled);
// - if a NEW prompt was queued AFTER the cancel (a send() that raced in
// before the loop resumed), the marker was for the cancelled work only —
// fall through and run the new prompt's turn. Do NOT settle waiters here:
// a whenIdle() waiter must wait for that new turn's running→idle, not
// resolve before it runs (the quiescence contract).
if (handle.isCancelled()) {
handle.clearCancel()
if (!agent.inbox.hasQueued) {
handle.settleIdle()
continue
}
}
handle.setStatus('running')
// Pre-step cancel (window 2): `setStatus('running')` emits `agent/status`
// SYNCHRONOUSLY, so a `running` listener can `cancel()` in the gap between the
// check above and `runTurn`. Mirror window 1: clear the marker, then
// - if NOTHING new is queued, drop the about-to-run turn and transition
// back to `idle` (`running` was already emitted, so a real idle
// transition balances the status AND settles `whenIdle()` waiters);
// - if a NEW prompt was queued AFTER the cancel (a `running` listener that
// cancels then sends), the marker was for the cancelled work only — fall
// through and run the new prompt's turn (status is already `running`), so
// a `whenIdle()` waiter resolves on THAT turn's running→idle, not before
// it runs. Settling here would resolve quiescence while the replacement
// is still queued and unrun (the same early-resolve race window 1 fixes).
if (handle.isCancelled()) {
handle.clearCancel()
if (!agent.inbox.hasQueued) {
handle.setStatus('idle')
continue
}
}
// Re-derive the turn number from the log each iteration (do NOT keep a local
// counter): an idle `agent.inject()` can append its own one-shot turn while
// the loop waits above, so the next real turn must continue from whatever
@@ -170,8 +240,18 @@ export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle
} catch { /* contained: a throwing agent/error listener must not kill the driver */ }
}
// Reset the cancel marker UNCONDITIONALLY here, after the turn returns and
// before the next iteration's idle wait. NOT gated on the idle transition
// below: a `send()` that lands during the cancelled turn's flush window makes
// `hasQueued` true at the `setStatus('idle')` guard, so an idle-gated reset
// would never fire and the stale marker would wrongly drop that next prompt's
// turn. Resetting per iteration scopes the marker to exactly the turn that was
// cancelled.
handle.clearCancel()
// Steering that arrived too late to join this turn (turn-end listeners,
// flush) becomes a queued message — it must never be stranded.
// flush) becomes a queued message — it must never be stranded. (A cancelled
// turn already cleared its steering, so there is nothing to re-enqueue.)
for (const message of agent.inbox.drainSteering()) {
agent.inbox.enqueue(message)
}
@@ -180,7 +260,7 @@ export async function runLoop(ctx: Context, agent: LoopAgent, handle: LoopHandle
}
}
async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn: number): Promise<void> {
async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, turn: number): Promise<void> {
const { session } = agent
// --- Pre-turn. A throw here (the invariant guard) is owed NO turn/end —
@@ -203,8 +283,8 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// 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).
const closeStep = (): void => {
if (!stepOpen) return
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
@@ -226,44 +306,38 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// a throwing listener from producing a silent "completed" turn when the step
// itself succeeded, AND keeps finalization going when closeStep runs from
// the outer catch.
if (failure !== undefined) failTurn(toError(failure))
if (failure !== undefined) {
failTurn(toError(failure))
return true
}
return false
}
// Record a step/turn failure exactly once: append the single `error` event
// (only while the turn is still open — see below), set the error reason, and
// emit agent/error (contained — trap: a throwing agent/error listener must not
// re-escape and strand the turn). Disposal and abort set `reason` directly
// without calling this (no `error` event for those — they are not failures).
// Record a step/turn failure exactly once: set the error reason (carrying the
// failing `step` — the durable failure lives entirely on turn/end.reason, there
// is no separate session error event) and emit agent/error (contained — trap: a
// throwing agent/error listener must not re-escape and strand the turn).
// Disposal and abort set `reason` directly without calling this (they are not
// failures).
const failTurn = (err: CodedError): void => {
if (errorReported) return
errorReported = true
// Only append the session `error` INSIDE the turn (before turn/end). If the
// turn has already ended the only way here is a throwing agent/turn-end
// listener after closeTurn(true) already appended turn/end — appending now
// would land the error AFTER the last turn/end, where the persistence
// backend treats it as a crash tail and drops it on resume (the turn-enclosure RFC). In
// that case report via agent/error + the logger only; the turn is balanced.
// 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) {
// Set `reason` BEFORE the append: Session.append pushes the error event
// before notifying session/event listeners, so a throwing listener would
// otherwise leave `reason` unset (and closeTurn would record the wrong
// reason / the outer catch would skip closeTurn). The append is contained
// — the error event is already in the log either way; a throwing listener
// must not abort finalization.
reason = { kind: 'error', ...errorData(err) }
try {
session.append('error', { turn, step, ...errorData(err) })
} catch (appendError: unknown) {
ctx.logger.warn(`agent "${agent.id}": session/event listener threw on the error event at turn ${turn}: ${toError(appendError).message}`)
}
reason = { kind: 'error', step, ...errorData(err) }
} else {
ctx.logger.warn(`agent "${agent.id}": agent/turn-end listener threw after turn ${turn} closed: ${err.message}`)
}
try {
ctx.emit('agent/error', agent, turn, step, err)
} catch {
// contained: the error is already logged; a throwing agent/error
// listener must not prevent the turn from closing.
// 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.
}
}
@@ -319,6 +393,20 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
const abort = new AbortController()
handle.setAbort(abort)
// Cancel landing in the step-start window: a synchronous `agent/turn-start`
// or `agent/step-start` listener (both fire before this point) can have
// called `cancel()`, and `runStep` would otherwise run a full extra step
// with no AbortController having observed it. Check the marker AFTER
// setAbort (so the next-iteration drain sees a clean controller) and before
// `runStep`: drop the step, end the turn `aborted`. closeStep balances the
// already-appended step/start.
if (handle.isCancelled()) {
handle.setAbort(undefined)
reason = { kind: 'aborted', reason: handle.cancelReason() }
closeStep()
break
}
let stepOutcome: { hadToolCalls: boolean; finish: FinishReason } | { error: Error }
try {
stepOutcome = await runStep(ctx, agent, turn, step, abort.signal)
@@ -337,7 +425,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
if (handle.isDisposed()) {
reason = { kind: 'disposed' }
} else if (abort.signal.aborted) {
/* v8 ignore next -- abort.signal.reason always set by agent.abort() which provides a default */
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
reason = { kind: 'aborted', reason: String(abort.signal.reason ?? 'aborted') }
} else {
failTurn(error)
@@ -358,7 +446,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// Steering that arrived during streaming/tool execution.
const steered = drainSteering(ctx, agent, turn)
closeStep()
if (closeStep()) break
const defaultDecision = stepOutcome.hadToolCalls || steered
let shouldContinue: boolean
@@ -378,6 +466,16 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
// 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
// AbortController was cleared (setAbort(undefined)) but before the next
// step starts — has no controller to observe it, so the turn-scoped marker
// ends the turn here. cancel() also cleared the steering FIFO, so the
// override above did not re-arm continuation.
if (handle.isCancelled()) {
reason = { kind: 'aborted', reason: handle.cancelReason() }
break
}
if (!shouldContinue || handle.isDisposed()) {
/* v8 ignore next -- disposal during continuation-decision window is a narrow race; error-path disposal is covered elsewhere */
if (handle.isDisposed()) reason = { kind: 'disposed' }
@@ -442,7 +540,7 @@ async function runTurn(ctx: Context, agent: LoopAgent, handle: LoopHandle, turn:
}
/** Drain the steering queue into the session. Returns whether any arrived. */
function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
function drainSteering(ctx: Context, 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' })
@@ -454,7 +552,7 @@ function drainSteering(ctx: Context, agent: LoopAgent, turn: number): boolean {
/** One step: assemble request → stream model → record → execute tools. */
async function runStep(
ctx: Context,
agent: LoopAgent,
agent: ReactLoopAgent,
turn: number,
step: number,
signal: AbortSignal,
@@ -483,7 +581,7 @@ async function runStep(
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
for await (const chunk of ctx.llm.stream(request)) {
/* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const chunkEvent = session.append('assistant/chunk', { turn, step, chunk })
chunkSeqs.push(chunkEvent.seq)
@@ -499,15 +597,47 @@ async function runStep(
const stepError = finishError(assembler.finish)
if (stepError) throw stepError
if (assembler.finish.kind === 'max-tokens') {
let message: Message = withoutToolCalls(assembler.message())
message = withoutToolCalls(await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message)))
// Fire the assistant/message when there is content OR usage: a max-tokens
// step can be cut off with empty content but still carry token accounting,
// and assistant/message is the only host for usage (there is no standalone
// usage event). An empty-content assistant/message is skipped by
// deriveMessages(), so hosting usage on it never injects a spurious assistant
// turn into derived history.
if (message.content.length > 0 || assembler.usage) {
// A max-tokens finish is itself a streamed `finish` chunk, so chunkSeqs is
// never empty here — pass the provenance unconditionally.
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', sourceEventSeqs: chunkSeqs },
)
}
return { hadToolCalls: false, finish: assembler.finish }
}
// The step-result waterfall runs BEFORE the session append so the log (the
// source of truth for derived history and replay) records the message that
// tool dispatch actually uses.
let message: Message = assembler.message()
message = await ctx.waterfall('agent/step-result', agent, turn, step, message, () => Promise.resolve(message))
session.append('assistant/message', { turn, step, content: message.content }, { surfaceOp: 'append', sourceEventSeqs: chunkSeqs })
if (assembler.usage) {
session.append('usage', { turn, step, usage: assembler.usage })
// Same content-or-usage guard as the max-tokens branch: a step that finishes
// with neither assembled content nor usage (e.g. a bare `stop` finish that
// streamed nothing) records no assistant/message — an empty-content message
// exists only to host usage, and deriveMessages() skips it either way, so
// appending one with no usage would be a pure trace-only row.
//
// sourceEventSeqs records the assistant/chunk provenance, but is omitted when
// no chunks streamed (the surface invariant rejects an empty sourceEventSeqs).
if (message.content.length > 0 || assembler.usage) {
session.append(
'assistant/message',
{ turn, step, content: message.content, ...(assembler.usage ? { usage: assembler.usage } : {}) },
{ surfaceOp: 'append', ...(chunkSeqs.length > 0 ? { sourceEventSeqs: chunkSeqs } : {}) },
)
}
// --- Tool execution (sequential; parallel execution is a TODO) ---
@@ -515,7 +645,7 @@ async function runStep(
// isError results, so abort is re-checked around every call here.
const toolCalls = message.content.filter(block => block.type === 'tool-call')
for (const call of toolCalls) {
/* v8 ignore next -- signal.reason always set by agent.abort() which provides a default */
/* v8 ignore next -- signal.reason always set: cancel()/disposal provide a default */
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
const callEvent = session.append('tool/call', { turn, step, callId: call.id, name: call.name, arguments: call.arguments })
let parsedArguments: unknown
@@ -546,9 +676,7 @@ async function runStep(
}, { surfaceOp: 'append', sourceEventSeqs: [callEvent.seq] })
// signal CAN flip during the await above (abort() inside a tool);
// the analyzer can't see through the await boundary.
// 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 via agent.abort() */
/* v8 ignore start -- signal.reason default unreachable: cancel()/disposal always set it */
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (signal.aborted) throw new Error(String(signal.reason ?? 'aborted'))
/* v8 ignore stop */
@@ -557,6 +685,10 @@ async function runStep(
return { hadToolCalls: toolCalls.length > 0, finish: assembler.finish }
}
function withoutToolCalls(message: Message): Message {
return { ...message, content: message.content.filter(block => block.type !== 'tool-call') }
}
/** The last turn number in a (possibly seeded) session log, or 0. */
export function lastTurnNumber(session: Session): number {
const lastStart = session.events.findLast(event => event.type === 'turn/start')

View File

@@ -2,11 +2,11 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { AgentId } from '@deepseek-ai/dsh-agent'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -21,7 +21,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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') {
@@ -32,17 +32,28 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === expected) {
dispose()
resolve()
}
})
})
}
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
describe('LoopAgent', () => {
describe('ReactLoopAgent', () => {
it('send() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -55,9 +66,9 @@ describe('LoopAgent', () => {
it('steer() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -70,9 +81,9 @@ describe('LoopAgent', () => {
it('inject() throws after disposal', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
@@ -85,7 +96,7 @@ describe('LoopAgent', () => {
it('inject() decides enclosure from the LOG (open turn), not agent status', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Simulate an OPEN turn in the log while the agent is idle (status is not a
// reliable open-turn signal). inject must append into that open turn, NOT
@@ -111,7 +122,7 @@ describe('LoopAgent', () => {
// A persistence-like listener whose flush rejects.
ctx.on('session/flush', () => { throw new Error('disk gone') })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// inject() is synchronous and fires a fire-and-forget flush; a rejecting
// flush must be contained (logged), never thrown into the caller.
@@ -124,7 +135,7 @@ describe('LoopAgent', () => {
it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
@@ -144,7 +155,7 @@ describe('LoopAgent', () => {
it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
// A session/event listener that throws on the synthetic turn/end. Append
@@ -169,7 +180,7 @@ describe('LoopAgent', () => {
// A non-Error rejection exercises the String() normalization branch.
ctx.on('session/flush', () => { throw 'disk gone' })
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
@@ -188,7 +199,7 @@ describe('LoopAgent', () => {
it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A non-serializable source makes the turn/start append throw BEFORE the
// event is pushed (Session.append validates before push), so NO turn opens.
@@ -203,7 +214,7 @@ describe('LoopAgent', () => {
it('steer() when idle falls through to send() and starts a turn', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// steer while idle delegates to send
agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } })
@@ -215,12 +226,12 @@ describe('LoopAgent', () => {
})
it('disposer is idempotent (double-stop)', async () => {
// Create a bare LoopAgent and call start() directly to get the disposer.
// Create a bare ReactLoopAgent and call start() directly to get the disposer.
// Then call it twice — the second call hits the early-return branch.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('test')
const agent = new LoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const session = ctx.sessions.create(SessionId('test'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
// Start the loop to get the disposer; the agent waits for messages
// (idle, never-resolving cancel), so it will stay idle.
@@ -238,7 +249,7 @@ describe('LoopAgent', () => {
it('setting the same status does not emit agent/status again', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const statuses: string[] = []
ctx.on('agent/status', (subject, status) => {
@@ -254,19 +265,169 @@ describe('LoopAgent', () => {
expect(idleTransitionCount).toBe(1) // only the final transition from running
})
it('abort() resolves reason to "aborted" when no reason provided', async () => {
it('whenIdle() resolves immediately when the agent is not running', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Fresh agent is idle — whenIdle() takes the not-running fast path and
// resolves without subscribing. await must not hang.
await agent.whenIdle()
expect(agent.status).not.toBe('running')
})
it('whenIdle() waits for queued work that has not flipped status yet', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: { kind: string; reason?: string }[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'queued')
let settled = false
const idle = agent.whenIdle().then(() => { settled = true })
await Promise.resolve()
expect(settled).toBe(false)
await waitForStatus(ctx, agent, 'running')
agent.cancel('done')
await idle
expect(settled).toBe(true)
expect(agent.status).toBe('idle')
})
it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => {
const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' })
// Drive `agent` into `running`, then await whenIdle() — it subscribes to
// agent/status and resolves on the first transition out of running.
const running = new Promise<void>((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') { dispose(); resolve() }
})
})
send(agent, 'go')
await running
expect(agent.status).toBe('running')
// While `agent`'s whenIdle is pending, churn `other` through running→idle:
// every status event it emits hits whenIdle's guard with `subject !== this`,
// so the wait must ignore them and only resolve on `agent`'s own idle.
send(other, 'go')
await agent.whenIdle()
expect(agent.status).toBe('idle')
})
it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => {
// Covers the waiter's disposed arm: whenIdle() queues an internal waiter
// while running (not the fast path), then the disposer settles it and chains
// `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct
// start() disposer keeps the emit synchronous.
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
const adapter = new MockAdapter(['hang'])
ctx.llm.registerAdapter(['mock'], adapter)
const session = ctx.sessions.create(SessionId('bare'))
const agent = new ReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session)
const dispose = agent.start()
agent.send([{ type: 'text', text: 'go' }])
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
const idle = agent.whenIdle() // queues an internal waiter (running)
dispose() // settles the waiter synchronously; whenIdle chains done
await idle
expect(agent.status).toBe('disposed')
await agent.done
})
it('whenIdle() subscribed while running survives a FIBER dispose (no hung promise)', async () => {
// The waiter is internal agent state, NOT an effect-scoped ctx.on listener:
// disposing the OWNING fiber runs the agent's listener disposers, which would
// have dropped a ctx.on-based waiter before the 'disposed' transition and
// hung the promise. With internal waiters, the fiber disposer still settles
// it. Regression for the round-3 whenIdle finding.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.abort() // no reason string
await waitForIdle(ctx, agent)
expect(agent.status).toBe('running')
expect(reasons[0]).toMatchObject({ kind: 'aborted', reason: 'aborted' })
const idle = agent.whenIdle() // queued while running
await fiber.dispose() // tears the fiber down (drops agent listeners)
await idle // must resolve, not hang
expect(agent.status).toBe('disposed')
})
it('whenIdle() on a disposed agent awaits the loop exit (done), not just the status flip', async () => {
// The disposer emits agent/status('disposed') BEFORE the driver loop
// unwinds, so whenIdle() must chain `done` (true quiescence) on the
// disposed path. Dispose a running agent, then assert whenIdle() resolves
// only after `done` — i.e. the loop has actually exited.
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
let doneResolved = false
void agent.done.then(() => { doneResolved = true })
await fiber.dispose() // sets status disposed, aborts, drains the loop
expect(agent.status).toBe('disposed')
// whenIdle() must not resolve before `done` has — chaining `done` is the
// quiescence guarantee. By here dispose() awaited the loop, so done is
// settled; whenIdle resolves and done is observed resolved.
await agent.whenIdle()
expect(doneResolved).toBe(true)
})
it('contains a throwing agent/status listener on the running transition', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'running') throw new Error('bad running listener')
})
send(agent, 'go')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on running'))
warn.mockRestore()
})
it('contains a throwing agent/status listener on the idle transition', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/status', (_subject, status) => {
if (status === 'idle') throw new Error('bad idle listener')
})
send(agent, 'go')
await agent.whenIdle()
expect(adapter.requests).toHaveLength(1)
expect(agent.status).toBe('idle')
expect(warn).toHaveBeenCalledWith(expect.stringContaining('agent/status listener threw on idle'))
warn.mockRestore()
})
})

View File

@@ -0,0 +1,338 @@
/**
* Tests for the queue-aware `Agent.cancel()` primitive. `cancel()` is the
* broad verb — it clears queued + steering work, aborts an in-flight step, and
* drops a turn about to start — whereas a bare step abort (the loop's private
* `AbortController`) kills only the current step and leaves the queue intact.
* These tests exercise every window where a cancel can land (idle, pre-step,
* mid-step, continuation) and the marker's arm/reset rules that keep a cancel
* from leaking to a later prompt or hanging `whenIdle()`.
*
* @module dsh-agent-loop/tests/cancel
*/
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
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 send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
/** Resolve on the agent's next idle transition (event-based, not status poll). */
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() }
})
})
}
/** All user-message texts recorded in the log (to assert what actually ran). */
function userTexts(agent: ReactLoopAgent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.flatMap(e => e.type === 'user/message' ? e.data.content : [])
.flatMap(b => b.type === 'text' ? [b.text] : [])
}
describe('Agent.cancel()', () => {
it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => {
const adapter = new MockAdapter([textResponse('reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// The loop is parked at the idle wait with nothing queued. A cancel here must
// NOT arm the marker — otherwise the next legitimate prompt would be dropped.
agent.cancel('nothing to cancel')
send(agent, 'real prompt')
await waitForIdle(ctx, agent)
// The prompt ran: its user message is in the log and one turn completed.
expect(userTexts(agent)).toEqual(['real prompt'])
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
})
it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// send() queues synchronously (status still idle, loop microtask not yet
// resumed). Cancel in that pre-step window: the queued turn must not run.
send(agent, 'drop me')
agent.cancel('pre-step')
// Give the loop a chance to wake and process the cancel.
await new Promise(r => setTimeout(r, 30))
// No turn was opened — the queued prompt was dropped, never recorded.
expect(userTexts(agent)).toEqual([])
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
expect(agent.status).toBe('idle')
})
it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => {
const adapter = new MockAdapter([textResponse('x')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Queue work, then register a whenIdle() waiter while in the pre-step window
// (status idle, hasQueued true) — it does NOT take the fast path. Then cancel.
// The skip path must settle this waiter directly (no running→idle transition
// ever fires), or it would hang forever.
send(agent, 'q')
const idle = agent.whenIdle()
agent.cancel('pre-step')
// Must resolve (not hang). A timeout makes the failure a clear test failure.
await Promise.race([
idle,
new Promise((_r, reject) => setTimeout(() => { reject(new Error('whenIdle hung after pre-step cancel')) }, 1000)),
])
expect(agent.status).toBe('idle')
})
it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
agent.cancel('mid-step')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'mid-step' }])
})
it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.cancel() // no reason → default 'cancelled'
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'cancelled' }])
})
it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => {
const adapter = new MockAdapter(['hang', textResponse('second reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// First turn hangs; cancel it mid-step.
send(agent, 'first')
await new Promise(r => setTimeout(r, 30))
agent.cancel('cancel first')
await waitForIdle(ctx, agent)
// The marker must have been reset after the cancelled turn — a fresh prompt
// runs to completion rather than being dropped by a stale marker.
send(agent, 'second')
await waitForIdle(ctx, agent)
expect(userTexts(agent)).toContain('second')
// The second turn completed (its reply was streamed).
const reasons = agent.session.events.filter(e => e.type === 'turn/end')
expect(reasons.length).toBe(2)
})
it('cancel from a synchronous agent/turn-start 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.
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')
})
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_a, _t, reason) => void reasons.push(reason))
send(agent, 'go')
await waitForIdle(ctx, agent)
dispose()
// No step streamed (the model never ran), and the turn ended aborted with
// the CALLER's reason — the marker carries `cancel(reason)` through even
// though no AbortController observed it in this window.
expect(streamed).toBe(false)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from turn-start' }])
})
it('cancel during the continuation window ends the turn aborted and runs no further step', async () => {
// A continuation-waterfall listener cancels DURING the continuation decision
// (the finished step's AbortController is already cleared), and votes to
// continue — but the turn-scoped marker checked right after must end the turn
// `aborted` and run NO second step.
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
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))
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 next()
})
send(agent, 'go')
await waitForIdle(ctx, agent)
// Only ONE step ran (the second was cancelled in the continuation window),
// and the turn ended aborted with the CALLER's reason (carried by the
// marker, since the finished step's AbortController was already cleared).
expect(steps).toBe(1)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'from continuation' }])
})
it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => {
const adapter = new MockAdapter([textResponse('should not run')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// setStatus('running') emits agent/status SYNCHRONOUSLY, so a running
// listener can cancel in the gap between the loop's pre-step check and
// 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 })
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'running') agent.cancel('from running listener')
})
send(agent, 'go')
await waitForIdle(ctx, agent)
dispose()
// No turn opened, no step streamed, and a later prompt still runs (the marker
// was reset).
expect(streamed).toBe(false)
expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false)
})
it('window 2: whenIdle() does NOT resolve early when a running listener cancels then queues replacement work', async () => {
// The window-1 early-resolve race has a window-2 twin: a synchronous
// agent/status('running') listener cancels the about-to-run turn AND queues a
// replacement. window 2 must NOT settle waiters (via setStatus('idle')) while
// the replacement is still queued-and-unrun — it must fall through and run it,
// so whenIdle() resolves on the replacement turn's running→idle, not before.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let replaced = false
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject !== agent || status !== 'running' || replaced) return
replaced = true
agent.cancel('drop A')
send(agent, 'B')
})
send(agent, 'A')
const idle = agent.whenIdle()
await idle
dispose()
// whenIdle() resolved only AFTER B's turn ran: B's user message + a turn/end
// are in the log, and A was dropped.
expect(userTexts(agent)).toContain('B')
expect(userTexts(agent)).not.toContain('A')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
})
it('whenIdle() does NOT resolve early when a new prompt is queued during a pre-step cancel', async () => {
// The subtle race: a whenIdle() waiter is registered for prompt A; cancel()
// clears A; prompt B is queued BEFORE the loop resumes from the idle wait.
// The window-1 cancel branch must NOT settle the waiter while B is still
// queued-and-unrun — whenIdle() must wait for B's turn to actually run and
// settle (the quiescence contract), not resolve before B's first event.
const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'A') // queues A (status still idle, loop microtask pending)
const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path)
agent.cancel('drop A') // arms marker, clears A
send(agent, 'B') // B races in before the loop resumes
// whenIdle() must resolve only AFTER B's turn fully ran — by which point B's
// user message and a turn/end are in the log. (Before the fix it resolved
// immediately, with zero events, then B ran afterward.)
await idle
expect(userTexts(agent)).toContain('B')
expect(agent.session.events.some(e => e.type === 'turn/end')).toBe(true)
// A was dropped (never ran); only B's turn is recorded.
expect(userTexts(agent)).not.toContain('A')
})
it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
// Steer (joins the running turn's steering FIFO), then cancel: the steering
// must be dropped, NOT re-enqueued as a new queued turn.
agent.steer([{ type: 'text', text: 'steer text' }])
agent.cancel('cancel with steering')
await waitForIdle(ctx, agent)
// After the cancelled turn settles, the agent is idle with NO follow-up turn
// started from the dropped steering.
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('idle')
const turnStarts = agent.session.events.filter(e => e.type === 'turn/start')
expect(turnStarts.length).toBe(1) // only the original (cancelled) turn
// The steering text was dropped — it never reached the log.
const flat = agent.session.events
.filter(e => e.type === 'steering/message')
.flatMap(e => e.type === 'steering/message' ? e.data.content : [])
.flatMap(b => b.type === 'text' ? [b.text] : [])
expect(flat).not.toContain('steer text')
})
})

View File

@@ -4,18 +4,18 @@ import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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() }
@@ -35,10 +35,10 @@ describe('config-driven session id', () => {
await ctx1.plugin(SystemPrompt)
await ctx1.plugin(ToolRegistry)
await ctx1.plugin(AgentRegistry)
await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')]))
const a1 = ctx1.agents.get('cfg') as LoopAgent
const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent
expect(a1.session.id).toMatch(idPattern)
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
@@ -52,10 +52,10 @@ describe('config-driven session id', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock', systemPrompt: '' }] })
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock', systemPrompt: '' }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')]))
const a2 = ctx2.agents.get('cfg') as LoopAgent
const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent
expect(a2.session.id).toMatch(idPattern)
expect(a2.session.id).not.toBe(a1.session.id)
a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } })
@@ -78,7 +78,7 @@ describe('config-driven session id', () => {
await ctx1.plugin(AgentLoop, { agents: [] })
await ctx1.plugin(SessionPersistenceJsonl, { root })
ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')]))
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sticky-1' }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -92,15 +92,15 @@ describe('config-driven session id', () => {
await ctx2.plugin(SystemPrompt)
await ctx2.plugin(ToolRegistry)
await ctx2.plugin(AgentRegistry)
await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'sticky-1' }] })
await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('sticky-1') }] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')]))
// The deferred resume runs on a microtask after the backend is available.
let resumed: LoopAgent | undefined
let resumed: ReactLoopAgent | undefined
for (let i = 0; i < 50 && !resumed; i++) {
await new Promise(r => setTimeout(r, 5))
resumed = ctx2.agents.get('main') as LoopAgent | undefined
resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined
}
expect(resumed).toBeDefined()
// The live session id IS the resumed id (NOT a fresh ${id}-session-<uuid>),
@@ -120,7 +120,7 @@ describe('config-driven session id', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', systemPrompt: '', resumeSessionId: 'does-not-exist' }] })
await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', systemPrompt: '', resumeSessionId: SessionId('does-not-exist') }] })
const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn')
.mockImplementation(() => undefined)
await ctx.plugin(SessionPersistenceJsonl, { root })
@@ -129,7 +129,7 @@ describe('config-driven session id', () => {
// The deferred resume fails (no such session on disk). It must be contained:
// a warning is logged, no 'main' agent is registered, and the app stays up.
await new Promise(r => setTimeout(r, 200))
expect(ctx.agents.get('main')).toBeUndefined()
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed'))
warn.mockRestore()
await ctx.fiber.dispose()

View File

@@ -4,8 +4,8 @@ import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -20,7 +20,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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') {
@@ -31,7 +31,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -43,7 +43,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () =>
// 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('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-start', () => {
@@ -73,7 +73,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () =>
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('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-end', () => {
@@ -107,7 +107,7 @@ describe('turn boundary listener throws (handled in-turn, loop survives)', () =>
// driver survives. This is the ONLY path that reaches the backstop.
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const errors: { turn: number; step: number; message: string }[] = []
ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message }))
@@ -149,7 +149,7 @@ describe('tool JSON parse', () => {
return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'use tool')
await waitForIdle(ctx, agent)
@@ -182,7 +182,7 @@ describe('tool JSON parse', () => {
return [{ type: 'text', text: 'ran with empty args' }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'use tool')
await waitForIdle(ctx, agent)
@@ -195,7 +195,7 @@ describe('toError normalization', () => {
it('normalizes non-Error throws from turn-start listeners via toError', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-start', () => {
@@ -213,15 +213,15 @@ describe('toError normalization', () => {
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('naked string error')
// A non-Error throw is wrapped in a HarnessError with code UNKNOWN, so the
// session error event carries a routable code instead of degrading.
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
// turn-end error reason carries a routable code instead of degrading.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
})
it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => {
const adapter = new MockAdapter([textResponse('irrelevant')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => {
@@ -240,8 +240,8 @@ describe('toError normalization', () => {
expect(errors).toHaveLength(1)
// String() of { code: 500 } is '[object Object]'
expect(errors[0]!.message).toBe('[object Object]')
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent?.type === 'error' && errorEvent.data.code).toBe('UNKNOWN')
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error' && turnEnd.data.reason.code).toBe('UNKNOWN')
})
})
@@ -249,7 +249,7 @@ describe('coded error data emission', () => {
it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => {
const adapter = new MockAdapter([textResponse('turn 1')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => {
@@ -268,11 +268,11 @@ describe('coded error data emission', () => {
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toBe('server overloaded')
// session error event includes the code
const errorEvent = agent.session.events.find(e => e.type === 'error')
expect(errorEvent).toBeDefined()
if (errorEvent!.type === 'error') {
expect(errorEvent!.data.code).toBe('RATE_LIMIT')
// turn-end error reason includes the code
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd).toBeDefined()
if (turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind === 'error') {
expect(turnEnd.data.reason.code).toBe('RATE_LIMIT')
}
})
})
@@ -281,9 +281,9 @@ describe('disposed vs aborted branching', () => {
it('handles dispose during model streaming producing reason "disposed"', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -311,7 +311,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2)
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'boom',
description: 'always fails',

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session'
import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { 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 from '@deepseek-ai/dsh-agent'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts'
async function harness(adapter: MockAdapter) {
@@ -25,7 +25,7 @@ async function harness(adapter: MockAdapter) {
* invoke this right after send(), when the loop hasn't woken yet (status is
* still 'idle' synchronously), so polling the current status would lie.
*/
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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') {
@@ -36,7 +36,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -44,7 +44,7 @@ describe('agent loop', () => {
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const order: string[] = []
for (const name of ['agent/turn-start', 'agent/step-start', 'agent/step-end', 'agent/turn-end'] as const) {
@@ -58,11 +58,13 @@ describe('agent loop', () => {
const types = agent.session.events.map(e => e.type)
// turn/start opens the turn, THEN the queued user message is recorded inside
// it (every event is turn-enclosed), then assembled message + usage.
// it (every event is turn-enclosed), then the assembled message (carrying the
// step's usage).
expect(types[0]).toBe('turn/start')
expect(types[1]).toBe('user/message')
expect(types).toContain('assistant/message')
expect(types).toContain('usage')
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data.usage).toEqual({ inputTokens: 10, outputTokens: 'hello there'.length })
expect(types.at(-1)).toBe('turn/end')
// derived history: user + assistant
@@ -85,7 +87,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: `echo: ${args.text}` }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'use the tool')
await waitForIdle(ctx, agent)
@@ -120,7 +122,7 @@ describe('agent loop', () => {
return []
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock', systemPrompt: 'Agent-specific suffix.' })
send(agent, 'hi')
await waitForIdle(ctx, agent)
@@ -133,7 +135,7 @@ describe('agent loop', () => {
it('records raw chunks for replay and emits agent/stream-chunk', async () => {
const adapter = new MockAdapter([textResponse('abc')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
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))
@@ -161,7 +163,7 @@ describe('agent loop', () => {
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'slow',
description: '',
@@ -193,7 +195,7 @@ describe('agent loop', () => {
it('steering while idle behaves like send (starts a turn)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.steer([{ type: 'text', text: 'hello' }])
await waitForIdle(ctx, agent)
@@ -203,7 +205,7 @@ describe('agent loop', () => {
it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } })
// The idle inject records a self-contained turn (turn/start → context/message
@@ -230,7 +232,7 @@ describe('agent loop', () => {
textResponse('done'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// A tool that injects mid-execution: at this point the agent is running, so
// inject must append the context/message into the ALREADY-open turn rather
// than wrap it in its own one-shot turn.
@@ -264,7 +266,7 @@ describe('agent loop', () => {
textResponse('step 3'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steps = 0
ctx.on('agent/step-end', () => void steps++)
@@ -290,7 +292,7 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/turn-continuation', async () => false as const)
@@ -306,7 +308,7 @@ describe('agent loop', () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
ctx.llm.registerAdapter(['other-model'], adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
options.model = 'other-model'
@@ -318,19 +320,19 @@ describe('agent loop', () => {
expect(adapter.requests[0]!.model).toBe('other-model')
})
it('abort() mid-stream ends the turn with reason aborted', async () => {
it('cancel() mid-stream ends the turn with reason aborted', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'go')
// wait until the stream is hanging, then abort
// wait until the stream is hanging, then cancel
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
agent.abort('user interrupt')
agent.cancel('user interrupt')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'aborted', reason: 'user interrupt' }])
@@ -341,7 +343,7 @@ describe('agent loop', () => {
// turn stops by default and ends max-tokens, not completed.
const adapter = new MockAdapter([maxTokensResponse('truncat')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -366,7 +368,7 @@ describe('agent loop', () => {
textResponse('second half'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steps = 0
ctx.on('agent/step-end', () => void steps++)
@@ -385,6 +387,10 @@ describe('agent loop', () => {
expect(steps).toBe(2)
expect(adapter.requests).toHaveLength(2)
expect(adapter.requests[1]!.messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'first half' }] },
])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
})
@@ -393,7 +399,7 @@ describe('agent loop', () => {
// stop. The per-turn reason must be independent — turn 2 ends completed.
const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
@@ -406,10 +412,162 @@ describe('agent loop', () => {
expect(reasons).toEqual([{ kind: 'max-tokens' }, { kind: 'completed' }])
})
it('does not dispatch tool calls from a max-tokens-truncated step', async () => {
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: callId, name: 'echo', argumentsDelta: '{"text":"x"}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'echo', arguments: '{"text":"x"}' } },
{ type: 'usage', usage: { inputTokens: 10, outputTokens: 5 } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
let executions = 0
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute() {
executions += 1
return [{ type: 'text', text: 'should not run' }]
},
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(executions).toBe(0)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
expect(reasons).toEqual([{ kind: 'max-tokens' }])
// No-data-loss: a max-tokens step whose only content was a dropped tool call
// has EMPTY assistant content, but its usage must still be represented. It
// rides on an (empty-content) assistant/message — there is no standalone
// usage event — and that empty message is skipped by deriveMessages(), so
// the derived history above is NOT corrupted by a spurious assistant turn.
const assistantMessage = agent.session.events.find(e => e.type === 'assistant/message')
expect(assistantMessage?.type === 'assistant/message' && assistantMessage.data).toEqual({
turn: 1, step: 1, content: [], usage: { inputTokens: 10, outputTokens: 5 },
})
})
it('appends no assistant/message for a max-tokens step with empty content and no usage', async () => {
// A max-tokens step truncated to a dropped tool call AND with no usage chunk
// has nothing to record: empty content and no accounting → no assistant/message
// (the empty-content host exists only to carry usage). The turn still ends
// max-tokens.
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: callId, name: 'echo', argumentsDelta: '{"text":"x"}' },
{ type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'echo', arguments: '{"text":"x"}' } },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
ctx.tools.register(defineTool({
name: 'echo',
description: '',
parameters: { text: { type: 'string' } },
async execute() { return [{ type: 'text', text: 'should not run' }] },
}))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'max-tokens' }])
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})
it('appends no assistant/message for a normal stop finish with empty content and no usage', async () => {
// A clean `stop` finish that streamed nothing assembled (no blocks) and
// carried no usage chunk has nothing to record: the content-or-usage guard
// on the normal step path suppresses a pure trace-only empty assistant/message.
const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const reasons: TurnEndReason[] = []
ctx.on('agent/turn-end', (_agent, _turn, reason) => void reasons.push(reason))
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'completed' }])
expect(agent.session.events.some(e => e.type === 'assistant/message')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([{ role: 'user', content: [{ type: 'text', text: 'go' }] }])
})
it('keeps safe max-tokens assistant content while dropping truncated tool calls', async () => {
const callId = CallId('c1')
const adapter = new MockAdapter([[
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'partial text' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'partial text' } },
{ type: 'block-start', index: 1, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 1, id: callId, name: 'echo', argumentsDelta: '{"text"' },
{ type: 'finish', reason: { kind: 'max-tokens' } },
]])
const ctx = await harness(adapter)
let stepResults = 0
ctx.on('agent/step-result', async (_agent, _turn, _step, message, next) => {
stepResults += 1
expect(message.content).toEqual([{ type: 'text', text: 'partial text' }])
return next()
})
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(stepResults).toBe(1)
expect(agent.session.events.some(e => e.type === 'tool/call')).toBe(false)
expect(agent.session.deriveMessages()).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'go' }] },
{ role: 'assistant', content: [{ type: 'text', text: 'partial text' }] },
])
})
it('stops the turn when agent/step-end listener failure has recorded an error', async () => {
const adapter = new MockAdapter([
toolCallResponse('c1', 'echo', { text: 'x' }),
textResponse('should not run'),
])
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 threw = false
ctx.on('agent/step-end', () => {
if (!threw) { threw = true; throw new Error('bad step-end listener') }
})
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
const turnEnd = agent.session.events.findLast(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason.kind).toBe('error')
})
it('chains queued messages into consecutive turns', async () => {
const adapter = new MockAdapter([textResponse('first'), textResponse('second')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const turns: number[] = []
ctx.on('agent/turn-start', (_agent, turn) => void turns.push(turn))
@@ -434,7 +592,7 @@ describe('agent loop', () => {
it('awaits session/flush at turn end (persistence checkpoint)', async () => {
const adapter = new MockAdapter([textResponse('ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let flushed = 0
let flushedBeforeIdle = false
@@ -454,7 +612,7 @@ describe('agent loop', () => {
it('errors from the model surface as agent/error and end the turn', async () => {
const adapter = new MockAdapter([]) // script exhausted → throws
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
const errors: Error[] = []
const reasons: TurnEndReason[] = []
@@ -467,19 +625,22 @@ describe('agent loop', () => {
expect(errors).toHaveLength(1)
expect(errors[0]!.message).toContain('script exhausted')
expect(reasons[0]).toMatchObject({ kind: 'error' })
expect(agent.session.events.some(e => e.type === 'error')).toBe(true)
// The durable failure lives entirely on turn/end.reason (with the failing
// step), not a standalone error event.
const turnEnd = agent.session.events.find(e => e.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toMatchObject({ kind: 'error', step: 1 })
})
it('disposing the loop fiber mid-turn stops the loop (HMR safety)', async () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
expect(ctx.agents.get('scoped')).toBe(agent)
expect(ctx.agents.get(AgentId('scoped'))).toBe(agent)
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
expect(agent.status).toBe('running')
@@ -488,7 +649,7 @@ describe('agent loop', () => {
await agent.done
expect(agent.status).toBe('disposed')
expect(ctx.agents.get('scoped')).toBeUndefined()
expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined()
expect(() => { send(agent, 'too late') }).toThrow('disposed')
})
@@ -501,11 +662,11 @@ describe('agent loop', () => {
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, {
agents: [{ id: 'config-agent', model: 'mock', systemPrompt: 'Config prompt' }],
agents: [{ id: AgentId('config-agent'), model: 'mock', systemPrompt: 'Config prompt' }],
})
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agents.get('config-agent')! as LoopAgent
const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent
expect(agent).toBeDefined()
expect(agent.id).toBe('config-agent')
expect(agent.options.model).toBe('mock')
@@ -530,11 +691,11 @@ describe('agent loop', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'run')
await waitForIdle(ctx, agent)
const replayed = ctx.sessions.create('replayed', { seed: [...agent.session.events] })
const replayed = ctx.sessions.create(SessionId('replayed'), { seed: [...agent.session.events] })
expect(replayed.deriveMessages()).toEqual(agent.session.deriveMessages())
// event-by-event identity of types
expect(replayed.events.map(e => e.type)).toEqual(

View File

@@ -17,8 +17,8 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentLoop, { type LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import fc from 'fast-check'
/** A never-exhausting adapter: every model call returns the same short reply. */
@@ -47,7 +47,7 @@ async function harness() {
}
/** Resolve on the agent's next transition to idle (event-based, not polled). */
function nextIdle(ctx: Context, agent: LoopAgent): Promise<void> {
function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => {
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent && status === 'idle') {
@@ -60,7 +60,7 @@ function nextIdle(ctx: Context, agent: LoopAgent): Promise<void> {
/** Record every status transition for the legal-machine assertion. Returns
* the seen list plus a disposer for the listener (per the registry convention). */
function recordStatus(ctx: Context, agent: LoopAgent): { seen: string[]; dispose: () => void } {
function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } {
const seen: string[] = []
const dispose = ctx.on('agent/status', (subject, status) => {
if (subject === agent) seen.push(status)
@@ -68,13 +68,13 @@ function recordStatus(ctx: Context, agent: LoopAgent): { seen: string[]; dispose
return { seen, dispose }
}
function userMessageTexts(agent: LoopAgent): string[] {
function userMessageTexts(agent: ReactLoopAgent): string[] {
return agent.session.events
.filter(e => e.type === 'user/message')
.map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join(''))
}
function turnNumbers(agent: LoopAgent): number[] {
function turnNumbers(agent: ReactLoopAgent): number[] {
return agent.session.events
.filter(e => e.type === 'turn/start')
.map(e => (e.data as { turn: number }).turn)
@@ -95,7 +95,7 @@ describe('agent loop scheduling properties', () => {
async (texts) => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create('a', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
const { seen: trace } = recordStatus(ctx, agent)
const idle = nextIdle(ctx, agent)
// Send all in one synchronous tick: they queue before the loop wakes.
@@ -120,7 +120,7 @@ describe('agent loop scheduling properties', () => {
async (texts) => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create('a', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
for (const text of texts) {
const idle = nextIdle(ctx, agent)
agent.send([{ type: 'text', text }])
@@ -145,7 +145,7 @@ describe('agent loop scheduling properties', () => {
async (steps) => {
const ctx = await harness()
try {
const agent = ctx.agentLoop.create('a', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' })
// Capture an idle waiter before EACH send; the last one is guaranteed
// to resolve because the final send always triggers (or joins) a turn
// that ends idle. Awaiting an already-resolved waiter is a no-op, so a

View File

@@ -8,9 +8,9 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import { MockAdapter, textResponse } from './mock-adapter.ts'
const dirs: string[] = []
@@ -31,7 +31,7 @@ async function persistentHarness(adapter: MockAdapter): Promise<{ ctx: Context;
return { ctx, root }
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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() }
@@ -43,7 +43,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const agent = ctx.agents.create({ agentId: 'a1', sessionId: 'custom-session', meta: { cwd: '/w' } })
const { agent } = ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } })
expect(agent.session.id).toBe('custom-session')
expect(agent.session.header.cwd).toBe('/w')
await ctx.fiber.dispose()
@@ -52,18 +52,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
ctx.agents.create({ agentId: 'dup', sessionId: 'sess-a' })
ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') })
// A second create with the SAME agent id but a fresh session id must reject
// up front — and must NOT leave an orphaned 'sess-b' session behind.
expect(() => ctx.agents.create({ agentId: 'dup', sessionId: 'sess-b' })).toThrow(/already registered/)
expect(ctx.sessions.get('sess-b')).toBeUndefined()
expect(() => ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).toThrow(/already registered/)
expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined()
await ctx.fiber.dispose()
})
it('createAgent works without meta (no cwd)', async () => {
const adapter = new MockAdapter([textResponse('hi')])
const { ctx } = await persistentHarness(adapter)
const agent = ctx.agents.create({ agentId: 'a-nometa', sessionId: 'nometa-session' })
const { agent } = ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') })
expect(agent.session.id).toBe('nometa-session')
expect(agent.session.header.cwd).toBeUndefined()
await ctx.fiber.dispose()
@@ -73,7 +73,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: create a no-cwd session and run a turn.
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'nocwd-sess' }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
await ctx1.fiber.dispose()
@@ -89,7 +89,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'nocwd-sess' }) as LoopAgent
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent
expect(a2.session.header.cwd).toBeUndefined()
await ctx2.fiber.dispose()
})
@@ -104,7 +104,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
]
const adapter1 = new MockAdapter([textResponse('a')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const forked = ctx1.sessions.create('forked-sess', { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } })
await ctx1.parallel('session/flush', forked)
await ctx1.fiber.dispose()
@@ -120,7 +120,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'forked-sess' }) as LoopAgent
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent
expect(a2.session.header.parentSession).toBe('parent-sess')
expect(a2.session.header.cwd).toBe('/w')
await ctx2.fiber.dispose()
@@ -133,7 +133,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// disk, since a crash before the next turn would otherwise lose it.
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -158,7 +158,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// drop it on reload (the bug this guards).
const adapter1 = new MockAdapter([textResponse('answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'm', sessionId: 'inject-sess', meta: { cwd: '/w' } }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } })
@@ -176,7 +176,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(AgentLoop, { agents: [] })
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'm', resumeSessionId: 'inject-sess' }) as LoopAgent
const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent
const flat = JSON.stringify(a2.session.deriveMessages())
expect(flat).toContain('background task 42 finished')
await ctx2.fiber.dispose()
@@ -186,7 +186,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
// Lifecycle 1: run one full turn, persisting it.
const adapter1 = new MockAdapter([textResponse('first answer')])
const { ctx: ctx1, root } = await persistentHarness(adapter1)
const a1 = ctx1.agents.create({ agentId: 'main', sessionId: 'sess-resume', meta: { cwd: '/w' } }) as LoopAgent
const a1 = ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } }).agent as ReactLoopAgent
a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } })
await waitForIdle(ctx1, a1)
const events1 = [...a1.session.events]
@@ -206,7 +206,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx2.plugin(SessionPersistenceJsonl, { root })
ctx2.llm.registerAdapter(['mock'], adapter2)
const a2 = await ctx2.agents.resume({ agentId: 'main', resumeSessionId: 'sess-resume' }) as LoopAgent
const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent
// The resumed session carries the prior history…
expect(a2.session.id).toBe('sess-resume')
expect(a2.session.events.length).toBe(events1.length)
@@ -234,7 +234,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => {
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
ctx.llm.registerAdapter(['mock'], adapter)
await expect(ctx.agents.resume({ agentId: 'm', resumeSessionId: 'nope' }))
await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') }))
.rejects.toThrow(/session persistence is not configured/)
await ctx.fiber.dispose()
})

View File

@@ -1,11 +1,11 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
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 AgentLoop, { LoopAgent } from '@deepseek-ai/dsh-agent-loop'
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'
@@ -26,7 +26,7 @@ async function harness(adapter: MockAdapter) {
return ctx
}
function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
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') {
@@ -37,7 +37,7 @@ function waitForIdle(ctx: Context, agent: LoopAgent): Promise<void> {
})
}
function send(agent: LoopAgent, text: string) {
function send(agent: ReactLoopAgent, text: string) {
agent.send([{ type: 'text', text }])
}
@@ -55,7 +55,7 @@ describe('HIGH: session log records what agent/step-result actually produced', (
return [{ type: 'text', text: 'ran' }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
// Plugin rewrites the message: replaces the text AND adds a tool call.
let rewritten = false
@@ -92,7 +92,7 @@ describe('HIGH: session log records what agent/step-result actually produced', (
})
describe('HIGH: abort during tool execution ends the turn', () => {
it('abort() inside a tool prevents both remaining tools and the next model step', async () => {
it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => {
const adapter = new MockAdapter([
// model asks for two tool calls in one step
[
@@ -106,14 +106,18 @@ describe('HIGH: abort during tool execution ends the turn', () => {
])
const ctx = await harness(adapter)
const executed: string[] = []
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'aborter',
description: '',
parameters: {},
async execute() {
executed.push('aborter')
agent.abort('user interrupt')
// Fire the in-flight step's AbortController directly (the loop registers
// it on the agent). This is the bare step-abort path — distinct from
// cancel(), which would also clear the inbox; here the subject is the
// loop's response to its running step being aborted mid-tool.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
return [{ type: 'text', text: 'done' }]
},
}))
@@ -154,7 +158,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
return [{ type: 'text', text: String(args.text) }]
},
}))
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/step-end', () => {
@@ -176,7 +180,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
textResponse('continued because of steering'),
])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => {
@@ -198,7 +202,7 @@ describe('HIGH: steering from late extension points is never stranded', () => {
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')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let steeredOnce = false
ctx.on('agent/turn-end', () => {
@@ -223,12 +227,17 @@ describe('HIGH: steering from late extension points is never stranded', () => {
it('steering queued during an aborted step is re-delivered, not silently consumed', async () => {
const adapter = new MockAdapter(['hang', textResponse('recovered')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'go')
await new Promise(r => setTimeout(r, 30))
agent.steer([{ type: 'text', text: 'redirect' }])
agent.abort('user interrupt')
// Abort ONLY the in-flight step, via its AbortController directly — NOT
// cancel(), which clears the inbox and would drop the queued steering this
// test proves survives a step abort. There is no public step-only abort
// verb (cancel() is the only public stop primitive), so reach the private
// controller the loop registered.
;(agent as unknown as { currentAbort?: AbortController }).currentAbort?.abort('user interrupt')
await waitForIdle(ctx, agent)
// a new turn ran with the steering content delivered as a message
@@ -241,7 +250,7 @@ describe('HIGH: plugin exceptions are contained', () => {
it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let threwOnce = false
ctx.on('agent/turn-continuation', async (): Promise<boolean> => {
@@ -269,7 +278,7 @@ describe('HIGH: plugin exceptions are contained', () => {
it('a rejecting session/flush listener is reported but does not kill the agent', async () => {
const adapter = new MockAdapter([textResponse('one'), textResponse('two')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
let rejectedOnce = false
ctx.on('session/flush', async () => {
@@ -297,9 +306,9 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const statuses: string[] = []
@@ -320,9 +329,9 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
const adapter = new MockAdapter(['hang'])
const ctx = await harness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('scoped', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
ctx.on('agent/status', (_agent, status) => {
@@ -335,7 +344,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => {
await agent.done // must not hang
expect(agent.status).toBe('disposed')
expect(ctx.agents.get('scoped')).toBeUndefined() // unregistered despite the throw
expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw
})
})
@@ -354,7 +363,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => {
const adapter = new MockAdapter([textResponse('never')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', {}) // no model
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model
const errors: Error[] = []
ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error))
@@ -369,7 +378,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('the agent/request waterfall can supply the model for a model-less agent', async () => {
const adapter = new MockAdapter([textResponse('routed')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', {}) // no model — router plugin decides
const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides
ctx.on('agent/request', async (_agent, _turn, _step, options, next) => {
options.model = 'mock'
@@ -385,7 +394,7 @@ describe('MEDIUM: misc registry and config fixes', () => {
it('agent/queued carries the resolved source; agent/steering carries its source', async () => {
const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.tools.register(defineTool({
name: 'noop',
description: '',
@@ -414,7 +423,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
it('a forked agent continues turn numbers after the seed log', async () => {
const first = new MockAdapter([textResponse('turn one')])
const ctx = await harness(first)
const agent = ctx.agentLoop.create('a1', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
send(agent, 'first')
await waitForIdle(ctx, agent)
@@ -429,8 +438,8 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
await ctx2.plugin(AgentLoop, { agents: [] })
ctx2.llm.registerAdapter(['mock'], second)
const seeded = ctx2.sessions.create('forked', { seed: [...agent.session.events] })
const forked = new LoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] })
const forked = new ReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded)
ctx2.effect(() => forked.start())
const turns: number[] = []
@@ -446,90 +455,6 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', ()
})
})
describe('LOW: BlockAssembler and streamBlocks edge cases', () => {
it('ignores deltas arriving after block-end for the same index (malformed stream)', async () => {
const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
const assembler = new BlockAssembler()
assembler.push({ type: 'block-start', index: 0, blockType: 'text' })
assembler.push({ type: 'text-delta', index: 0, text: 'good' })
assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'good' } })
assembler.push({ type: 'text-delta', index: 0, text: ' straggler' })
expect(assembler.blocks()).toEqual([{ type: 'text', text: 'good' }])
})
it('assembles tool-call blocks from deltas without block-end', async () => {
const { BlockAssembler } = await import('@deepseek-ai/dsh-llm')
const assembler = new BlockAssembler()
assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c9'), name: 'echo', argumentsDelta: '{"a"' })
assembler.push({ type: 'tool-call-delta', index: 0, id: CallId('c9'), argumentsDelta: ':1}' })
expect(assembler.blocks()).toEqual([
{ type: 'tool-call', id: CallId('c9'), name: 'echo', arguments: '{"a":1}' },
])
})
it('streamBlocks flushes delta-only blocks at end of stream (matches generate())', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const deltaOnly: StreamChunk[] = [
{ type: 'text-delta', index: 0, text: 'no ' },
{ type: 'text-delta', index: 0, text: 'block-end' },
{ type: 'finish', reason: { kind: 'stop' } },
]
ctx.llm.registerAdapter(['m'], new MockAdapter([deltaOnly, deltaOnly]))
const blocks: ContentBlock[] = []
for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
expect(blocks).toEqual([{ type: 'text', text: 'no block-end' }])
const generated = await ctx.llm.generate({ model: 'm', messages: [] })
expect(generated.message.content).toEqual(blocks)
})
it('streamBlocks preserves stream order when an open block precedes a closed one', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
// index 0 never gets block-end (delta-only); index 1 closes mid-stream.
const interleaved: StreamChunk[] = [
{ type: 'text-delta', index: 0, text: 'first, open' },
{ type: 'block-start', index: 1, blockType: 'text' },
{ type: 'text-delta', index: 1, text: 'second, closed' },
{ type: 'block-end', index: 1, block: { type: 'text', text: 'second, closed' } },
{ type: 'finish', reason: { kind: 'stop' } },
]
ctx.llm.registerAdapter(['m'], new MockAdapter([interleaved, interleaved]))
const blocks: ContentBlock[] = []
for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
expect(blocks).toEqual([
{ type: 'text', text: 'first, open' },
{ type: 'text', text: 'second, closed' },
])
// identical to generate()'s assembled order
const generated = await ctx.llm.generate({ model: 'm', messages: [] })
expect(generated.message.content).toEqual(blocks)
})
it('streamBlocks yields closed blocks incrementally once preceding blocks close', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
const script: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: 'a' },
{ type: 'block-end', index: 0, block: { type: 'text', text: 'a' } },
{ type: 'block-start', index: 1, blockType: 'text' },
{ type: 'text-delta', index: 1, text: 'b' },
{ type: 'block-end', index: 1, block: { type: 'text', text: 'b' } },
{ type: 'finish', reason: { kind: 'stop' } },
]
ctx.llm.registerAdapter(['m'], new MockAdapter([script]))
const blocks: ContentBlock[] = []
for await (const block of ctx.llm.streamBlocks({ model: 'm', messages: [] })) blocks.push(block)
expect(blocks).toEqual([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])
})
})
describe('LOW: discriminated SessionEvent narrows without casts', () => {
it('narrows event.data from event.type', () => {
const session = new Session(SessionId('s'))
@@ -559,7 +484,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-finish-error', { model: 'mock' })
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))
@@ -567,11 +492,13 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', message: 'provider 401', code: 'AUTH' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' }])
const events = [...agent.session.events]
expect(events.some(event => event.type === 'error'
&& event.data.message === 'provider 401' && event.data.code === 'AUTH')).toBe(true)
// The durable failure lives on turn/end.reason (with the failing step), not
// a standalone error event.
const turnEnd = events.find(event => event.type === 'turn/end')
expect(turnEnd?.type === 'turn/end' && turnEnd.data.reason).toEqual({ kind: 'error', step: 1, message: 'provider 401', code: 'AUTH' })
// Crucially: no assistant/message was logged for the failed step.
expect(events.some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -582,7 +509,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
]
const adapter = new MockAdapter([abortedStream])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-finish-aborted', { model: 'mock' })
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))
@@ -590,7 +517,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', message: 'model stream aborted', code: 'ABORTED' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'model stream aborted', code: 'ABORTED' }])
expect([...agent.session.events].some(event => event.type === 'assistant/message')).toBe(false)
})
@@ -600,7 +527,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
]
const adapter = new MockAdapter([errorStream])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-finish-error-nocode', { model: 'mock' })
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))
@@ -608,7 +535,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete
send(agent, 'go')
await waitForIdle(ctx, agent)
expect(reasons).toEqual([{ kind: 'error', message: 'codeless failure' }])
expect(reasons).toEqual([{ kind: 'error', step: 1, message: 'codeless failure' }])
})
})
@@ -616,7 +543,7 @@ 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 () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-step-order', { model: 'mock' })
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).
@@ -660,14 +587,14 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
}
/** Count turn/step boundary events for balance assertions. */
function boundaryCounts(agent: LoopAgent) {
function boundaryCounts(agent: ReactLoopAgent) {
const e = [...agent.session.events]
return {
turnStart: e.filter(x => x.type === 'turn/start').length,
turnEnd: e.filter(x => x.type === 'turn/end').length,
stepStart: e.filter(x => x.type === 'step/start').length,
stepEnd: e.filter(x => x.type === 'step/end').length,
errors: e.filter(x => x.type === 'error').length,
errors: e.filter(x => x.type === 'turn/end' && x.data.reason.kind === 'error').length,
lastTurnEnd: e.findLast(x => x.type === 'turn/end'),
}
}
@@ -675,7 +602,7 @@ 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('a-turnstart', { model: 'mock' })
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') } })
@@ -686,10 +613,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// turn opened and closed; no step ran; exactly one error logged + emitted.
// 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', message: '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)
})
@@ -697,7 +624,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
it('a throwing agent/step-start 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('a-stepstart', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' })
let threw = false
ctx.on('agent/step-start', () => { if (!threw) { threw = true; throw new Error('boom step-start') } })
@@ -726,7 +653,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create('a-errorlistener', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' })
let threw = false
ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } })
@@ -739,7 +666,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(c.turnStart).toBe(1)
expect(c.turnEnd).toBe(1)
expect(c.stepStart).toBe(c.stepEnd)
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', message: 'provider 500' })
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason).toMatchObject({ kind: 'error', step: 1, message: 'provider 500' })
// loop survives: a second turn runs to completion (invariants oracle would
// throw on its turn/start if turn 1 had been left open).
@@ -757,9 +684,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// balanced with reason disposed (no error event for a disposal).
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('a-dispose', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
const reasons: TurnEndReason[] = []
@@ -776,8 +703,8 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(turnStarts).toBe(1)
expect(turnEnds).toBe(1) // balanced — the turn was closed despite disposal
expect(reasons).toEqual([{ kind: 'disposed' }])
// no error event: disposal is not a failure.
expect(e.some(x => x.type === 'error')).toBe(false)
// no error reason: disposal is not a failure.
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 () => {
@@ -788,9 +715,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// throw. This is the only path that exercises that catch sub-branch.
const adapter = new MockAdapter(['hang'])
const ctx = await balancedHarness(adapter)
let agent!: LoopAgent
let agent!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
agent = inner.agentLoop.create('a-dispose-emit-throw', { model: 'mock' })
agent = inner.agentLoop.create(AgentId('a-dispose-emit-throw'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
// The FIRST agent/turn-end emit throws (the disposal-driven turn end).
@@ -816,9 +743,10 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
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: no error event is logged and
// no agent/error is emitted (disposal is not a failure; the throw is swallowed).
expect(e.some(x => x.type === 'error')).toBe(false)
// 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)
expect(errorEmits).toHaveLength(0)
})
@@ -833,7 +761,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// subscriber.)
const adapter = new MockAdapter([textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-preturn', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' })
let threw = false
ctx.on('session/event', (_session, event) => {
@@ -872,12 +800,13 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// 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('a-tend', { model: 'mock' })
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)
@@ -887,6 +816,9 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
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'])
@@ -904,7 +836,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// swallowed the throw in the normal (no-tool, no-steering) path.
const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')])
const ctx = await balancedHarness(adapter)
const agent = ctx.agentLoop.create('a-stepend-throw', { model: 'mock' })
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') } })
@@ -915,11 +847,11 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// step opened and closed; exactly one error; turn balanced; turn ends error.
// step opened and closed; exactly one error turn-end; turn balanced.
expect(c).toMatchObject({ turnStart: 1, turnEnd: 1, stepStart: 1, stepEnd: 1, errors: 1 })
expect(errors.map(e => e.message)).toEqual(['boom step-end'])
expect(c.lastTurnEnd?.type === 'turn/end' && c.lastTurnEnd.data.reason)
.toEqual({ kind: 'error', message: 'boom step-end' })
.toEqual({ kind: 'error', step: 1, message: 'boom step-end' })
// step/end precedes turn/end (ordering contract)
const e = [...agent.session.events]
@@ -946,7 +878,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
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('a-double', { model: 'mock' })
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') } })
@@ -957,12 +889,12 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
await waitForIdle(ctx, agent)
const c = boundaryCounts(agent)
// exactly one error event + one agent/error emit, despite two failTurn calls.
// 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', message: 'provider down' })
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')
@@ -970,42 +902,6 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
expect(boundaryCounts(agent).turnEnd).toBe(2)
})
it('a throwing session/event listener on the error event still closes the turn (finalizer containment)', async () => {
// failTurn appends the `error` event; Session.append pushes it BEFORE
// notifying session/event listeners, so a throwing listener leaves `error`
// in the log but must NOT abort finalization — `reason` is set before the
// append and the throw is contained, so closeTurn(false) still runs and
// turn/end is appended (the turn is balanced, not left open).
// Plain harness (no invariants oracle): the throwing listener is itself a
// session/event subscriber. A finish-error drives the boundary-error path.
const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider down' } }]
const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-errthrow', { model: 'mock' })
let threw = false
ctx.on('session/event', (_s, event) => {
if (!threw && event.type === 'error') { threw = true; throw new Error('boom error-event listener') }
})
send(agent, 'go')
await waitForIdle(ctx, agent)
const e = [...agent.session.events]
// The error event is in the log (pushed before the listener threw)…
expect(e.some(x => x.type === 'error')).toBe(true)
// …and the turn was still closed with the error reason (finalization did not
// abort): the last event is turn/end carrying the error reason.
const last = e.at(-1)
expect(last?.type).toBe('turn/end')
expect(last?.type === 'turn/end' && last.data.reason).toMatchObject({ kind: 'error', message: 'provider down' })
// loop survives: a second turn runs normally.
send(agent, 'again')
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(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
@@ -1014,7 +910,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// throw is contained + surfaced via failTurn, so turn/end is still appended.
const adapter = new MockAdapter([textResponse('never reached')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-stependthrow', { model: 'mock' })
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).
@@ -1052,7 +948,7 @@ describe('P1-5: a started turn (and any open step) is always closed on a boundar
// what throws.)
const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create('a-turnendappend', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' })
let threw = false
ctx.on('session/event', (_s, event) => {
@@ -1098,7 +994,7 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
return Promise.resolve({ callId: CallId('wrong-proxy-id'), content: [{ type: 'text', text: 'ok' }], isError: false })
}, { prepend: true })
const agent = ctx.agentLoop.create('a-callid', { model: 'mock' })
const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' })
send(agent, 'use tool')
await waitForIdle(ctx, agent)
@@ -1122,3 +1018,32 @@ describe('P1-7: tool/result is logged under the originating call.id, not result.
}
})
})
describe('surface: assistant/message omits sourceEventSeqs when no chunks streamed', () => {
it('a step-result listener injecting content over an empty stream appends with surfaceOp but no sourceEventSeqs', async () => {
// An empty stream yields zero assistant/chunk events (finish defaults to
// `stop`), so chunkSeqs is empty. A step-result listener injects content, so
// the content-or-usage guard fires and an assistant/message is appended. Its
// sourceEventSeqs MUST be omitted (not `[]`) — the surface invariant rejects
// an empty sourceEventSeqs, and the dev invariants plugin would throw on it.
const adapter = new MockAdapter([[]])
const ctx = await harness(adapter)
await ctx.plugin(Invariants)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({
role: 'assistant' as const,
content: [{ type: 'text' as const, text: 'injected' }],
}))
send(agent, 'go')
await waitForIdle(ctx, agent)
const recorded = agent.session.events.find(e => e.type === 'assistant/message')!
expect(recorded.type).toBe('assistant/message')
expect(recorded.surfaceOp).toBe('append')
expect(recorded.sourceEventSeqs).toBeUndefined()
// The injected content reaches derived history.
expect(JSON.stringify(agent.session.deriveMessages())).toContain('injected')
})
})

View File

@@ -0,0 +1,39 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/tools"
},
{
"path": "../../core/agent"
}
]
}

View File

@@ -9,7 +9,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
### Public API
- `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber.
- `ctx.agents.get(id: string): Agent | undefined`
- `ctx.agents.get(id: AgentId): Agent | undefined`
- `ctx.agents.list(): Agent[]`
#### Factory seam (creation)
@@ -17,8 +17,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i
Agent *creation* is provided by whichever plugin implements `AgentFactory` (phase 1: `dsh-agent-loop`), registered via `setFactory`. This keeps creation on the `dsh-agent` interface so consumers (UI, the ACP bridge) program against `ctx.agents` without depending on the concrete loop package.
- `ctx.agents.setFactory(factory: AgentFactory): () => void` — register the creation factory (the loop calls this on construction). Throws on a second factory; the slot clears on dispose.
- `ctx.agents.create(options: CreateAgentOptions): Agent` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<Agent>` — load a persisted session ([session persistence](../../docs/rfc/implemented/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
- `ctx.agents.create(options: CreateAgentOptions): AgentHandle` — construct, start, AND register a new agent on a caller-supplied `sessionId` (with optional `meta.cwd`). Distinct from `register` (which only records). Throws if no factory is registered.
- `ctx.agents.resume(options: ResumeAgentOptions): Promise<AgentHandle>` — load a persisted session ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)) and resume an agent on it. Async; rejects if no factory is registered, or if the factory finds session persistence unconfigured.
`AgentHandle = { agent: Agent; dispose(): Promise<void> }`. The disposer is a **capability** — only the holder can tear this agent down. `dispose()` stops the loop, `await`s its exit (quiescence — NOT just the `disposed` status flip), unregisters the agent, and removes its session from the store, in an order that captures the loop's final `session/flush` before the session is detached. `ctx.agents.get(id)` still returns a bare `Agent` — the handle is only for the OWNER that created it. The ACP bridge is the production consumer (one handle per session, disposed on disconnect/teardown); config-created agents are owned by the loop fiber and never need a handle.
### Events
@@ -53,8 +55,9 @@ The handle every plugin programs against:
- `agent.send(content, options?)` — queue a message; starts a turn when idle
- `agent.steer(content, options?)` — steer a running turn (inject between steps); behaves like `send` when idle
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../docs/rfc/implemented/2026-06-15-turn-enclosure-invariant.md))
- `agent.abort(reason?)` — abort the in-flight step
- `agent.inject(content, options?)` — inject in-session context (context/message event); the next request sees it. Does not run the model. While a turn is open it joins that turn; while idle it is wrapped in a one-shot `injection` turn so every event stays turn-enclosed ([the turn-enclosure invariant](../../../docs/rfc/implemented/architecture/2026-06-15-turn-enclosure-invariant.md))
- `agent.cancel(reason?)` cancel ALL pending work: clears the queued + steering FIFOs, aborts the in-flight step, and drops a turn about to start (the pre-step window) so a queued-but-not-started prompt never runs. A UI/ACP `session/cancel` maps to this. The single public stop primitive. Idle with nothing pending → a safe no-op.
- `agent.whenIdle()` — resolve once the agent reaches quiescence after settling out of `running` (idle → immediately; disposed → awaits the loop exit). A non-owner's quiescence-observation hook: it observes the work settling WITHOUT tearing the agent down. Teardown is separate — a lifecycle owner stops and unregisters via `AgentHandle.dispose()`, which awaits the loop exit directly.
- `agent.session`, `agent.status`, `agent.options`, `agent.id`
### Extension points

View File

@@ -20,11 +20,13 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"

View File

@@ -7,7 +7,7 @@
import { Context, Service } from 'cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { Agent, AgentOptions } from './types.ts'
import type { Agent, AgentId, AgentOptions } from './types.ts'
export * from './types.ts'
@@ -26,9 +26,9 @@ declare module 'cordis' {
*/
export interface CreateAgentOptions {
/** The agent's id (the registry handle). */
agentId: string
agentId: AgentId
/** The live session's id (NOT derived from agentId). */
sessionId: string
sessionId: SessionId
/**
* Session creation metadata: validated absolute `cwd` and `parentSession`
* fork lineage. Mirrors the `cwd`/`parentSession` fields of
@@ -47,13 +47,30 @@ export interface CreateAgentOptions {
*/
export interface ResumeAgentOptions {
/** The agent's id (the registry handle). */
agentId: string
agentId: AgentId
/** The persisted session id to load and resume on. */
resumeSessionId: string
resumeSessionId: SessionId
/** Per-agent options (model, system prompt). */
agentOptions?: AgentOptions
}
/**
* An owned agent plus its disposer, returned by {@link AgentRegistry.create} /
* {@link AgentRegistry.resume}. The disposer is a CAPABILITY: only the holder
* can tear this agent down. `dispose()` unregisters the agent, stops its loop,
* awaits the loop's exit (quiescence NOT just the `disposed` status flip), and
* removes the agent's session from the store, in an order that captures the
* loop's final `session/flush` before the session is detached.
*
* `ctx.agents.get(id)` still returns a bare {@link Agent} the handle is only
* for the OWNER that created it. Config-created agents (the loop's own startup)
* are owned by the loop fiber and never need a handle.
*/
export interface AgentHandle {
agent: Agent
dispose(): Promise<void>
}
/**
* The agent-creation factory the loop implementation provides to the registry
* via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so
@@ -61,16 +78,23 @@ export interface ResumeAgentOptions {
* depending on the concrete `dsh-agent-loop` package.
*/
export interface AgentFactory {
/** Create, start, and register a new agent on a caller-supplied session id. */
createAgent(options: CreateAgentOptions): Agent
/**
* Create, start, and register a new agent on a caller-supplied session id.
* Returns an {@link AgentHandle} the owner disposes it to tear down exactly
* this agent (unregister + stop loop + await quiescence + remove session).
*/
createAgent(options: CreateAgentOptions): AgentHandle
/**
* Load a persisted session and resume an agent on it. Async because it awaits
* `ctx.sessionPersistence.load`; must be called after that service exists
* (consumers inject `sessionPersistence`).
* (consumers inject `sessionPersistence`). Returns an {@link AgentHandle}.
*/
resume(options: ResumeAgentOptions): Promise<Agent>
resume(options: ResumeAgentOptions): Promise<AgentHandle>
}
/** Thrown when create/resume is called before an agent factory is registered. */
const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plugin)'
/**
* Agent registry (`ctx.agents`): tracks live agents so UI, hook, and
* orchestrator plugins can find them without depending on the concrete loop
@@ -79,7 +103,7 @@ export interface AgentFactory {
* {@link setFactory}.
*/
export class AgentRegistry extends Service {
private store = new Map<string, Agent>()
private store = new Map<AgentId, Agent>()
private factory: AgentFactory | undefined
constructor(ctx: Context) {
@@ -104,20 +128,21 @@ export class AgentRegistry extends Service {
* Create, start, and register a new agent through the registered factory.
* Distinct from {@link register} (which records an already-constructed
* agent): this constructs the agent and its session. Throws if no factory is
* registered.
* registered. Returns an {@link AgentHandle} the owner disposes it to tear
* down exactly this agent.
*/
create(options: CreateAgentOptions): Agent {
if (this.factory === undefined) throw new Error('no agent factory registered (load an agent-loop plugin)')
create(options: CreateAgentOptions): AgentHandle {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.createAgent(options)
}
/**
* 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.
* session persistence is not configured. Returns an {@link AgentHandle}.
*/
async resume(options: ResumeAgentOptions): Promise<Agent> {
if (this.factory === undefined) throw new Error('no agent factory registered (load an agent-loop plugin)')
async resume(options: ResumeAgentOptions): Promise<AgentHandle> {
if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE)
return this.factory.resume(options)
}
@@ -139,7 +164,22 @@ export class AgentRegistry extends Service {
// The duplicate throw above fires before any mutation — it leaks nothing.
yield () => {
this.store.delete(agent.id)
this.ctx.emit('agent/disposed', agent)
// CONTAIN a throwing `agent/disposed` listener: this disposer runs as
// one link in the owning fiber/effect's disposal chain, and Cordis
// chains later disposers with `task.then(next)` — so an UNCAUGHT throw
// here rejects the chain and SKIPS every later disposer. When this
// registration shares a composite effect with a session (the agent
// factory's `AgentLoop.start`, where the session-detach disposer runs
// AFTER this one), a swallowed-less throw would strand the session in
// the store with `onAppend` attached — a leak AND a durability hole.
// The store entry is already removed above (the useful state), so
// logging the listener bug and continuing is correct (mirrors the
// guarded `agent/status` emit in dsh-agent-loop's ReactLoopAgent).
try {
this.ctx.emit('agent/disposed', agent)
} catch (error: unknown) {
this.ctx.logger.warn(`agent "${agent.id}": agent/disposed listener threw: ${String(error)}`)
}
}
this.ctx.emit('agent/created', agent)
}.bind(this), 'agents.register()')
@@ -148,7 +188,7 @@ export class AgentRegistry extends Service {
return () => void dispose()
}
get(id: string): Agent | undefined {
get(id: AgentId): Agent | undefined {
return this.store.get(id)
}

View File

@@ -9,7 +9,8 @@
* @module @deepseek-ai/dsh-agent/types
*/
import type { Branded, ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock, GenerateOptions, Message, MessageSource, StreamChunk } from '@deepseek-ai/dsh-llm'
/** Identifies one live agent in the registry. */
export type AgentId = Branded<'AgentId'>
@@ -40,7 +41,7 @@ export type AgentStatus = 'idle' | 'running' | 'disposed'
/**
* The agent handle the surface every plugin (UI, hooks, orchestrators)
* programs against. The concrete implementation lives in
* `@deepseek-ai/dsh-agent-loop` (class `LoopAgent`); nothing outside the loop
* `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop
* package should depend on the implementation.
*/
export interface Agent {
@@ -72,13 +73,50 @@ export interface Agent {
* (inject is synchronous): a failing flush is reported via `agent/error`
* (step `0`) and the logger, never thrown into the caller.
*
* TODO(review): exact envelope/rendering rules live in dsh-session and need
* review once a real adapter exists.
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
*/
inject(content: ContentBlock[], options?: SendOptions): void
/** Abort the in-flight step (if any); the turn ends with reason 'aborted'. */
abort(reason?: string): void
/**
* Cancel ALL pending work for the agent. `cancel()`:
*
* - clears the queued FIFO (un-started prompts never run) and the steering
* FIFO (steering for the cancelled turn is dropped, not re-enqueued);
* - aborts the in-flight step if one is running (the turn ends `aborted`);
* - drops a turn that is about to start (a `cancel()` landing in the
* pre-step window after a `send()` queued but before the loop flips to
* `running`, or after `running` is emitted but before the first step) so
* that queued prompt does not run and cannot be batched into the cancelled
* turn.
*
* After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state.
* `cancel()` on an idle agent with nothing queued or running is a safe no-op
* it does NOT arm anything that would drop a later legitimate prompt.
*/
cancel(reason?: string): void
/**
* Resolve once the agent has reached quiescence after settling out of
* `running`, or immediately if it is already idle with no queued work. A
* non-owner's quiescence-observation hook: a consumer that does NOT own the
* agent's lifecycle awaits this to proceed only after queued/running work has
* fully stopped, rather than returning while the driver is still streaming or
* about to start a queued turn without itself tearing the agent down. (A
* lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the
* loop-exit promise directly as part of stopping and unregistering. So this is
* for a non-owning observer e.g. a test awaiting a turn to settle, or a
* monitor that wants the settle signal but must not dispose the agent.)
*
* "Quiescence", not merely "status changed": a disposed agent emits
* `agent/status('disposed')` from inside its disposer, BEFORE the driver loop
* has unwound so `whenIdle()` resolving on `disposed` must wait for the loop
* to actually exit (the implementation chains the loop-exit promise), not just
* observe the status flip. A mid-step disposal that never reaches `idle` still
* unblocks the await this way.
*/
whenIdle(): Promise<void>
// TODO(sub-agents): spawn/fork seams — semantics deliberately deferred.
// The intended shape: a creation option referencing a parent agent
@@ -90,48 +128,94 @@ export interface Agent {
declare module 'cordis' {
interface Events {
// ---- lifecycle (emit) ----
/** An agent was registered. */
/**
* An agent was registered in the {@link AgentRegistry} and is ready to
* receive messages.
* @mode emit
*/
'agent/created'(agent: Agent): void
/** An agent was disposed. */
/**
* An agent was disposed and removed from the registry; its fiber and any
* in-flight turn have been torn down.
* @mode emit
*/
'agent/disposed'(agent: Agent): void
/** Agent status changed (idle/running/disposed). */
/**
* 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.
* @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.
* @mode emit
*/
'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void
// ---- turn/step boundaries (emit) ----
/**
* A turn began. `turn` is the 1-based turn number within the session.
* @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
// ---- interception seams (waterfall) ----
/**
* Waterfall: mutate the fully-assembled GenerateOptions before the model
* call (hooks, compaction, model switching, tool filtering, ).
* Waterfall: mutate the fully-assembled {@link GenerateOptions} before the
* model call (hooks, compaction, model switching, tool filtering, ). Call
* `next()` to delegate, or return without it to short-circuit.
* @mode waterfall
*/
'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions>
/**
* Waterfall: post-process the assembled assistant message before tool
* dispatch (validation, content rewriting, ).
* Waterfall: post-process the assembled assistant {@link Message} before
* tool dispatch (validation, content rewriting, ).
* @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).
* @mode waterfall
*/
'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: boolean, next: () => Promise<boolean>): Promise<boolean>
// ---- streaming + tool notifications (emit) ----
/** A raw stream chunk arrived (token-level UI/log feed). */
/**
* 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. */
/**
* Steering content was injected into a running turn.
* @mode emit
*/
'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void
/** A step or turn errored. */
/**
* 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.
* @mode emit
*/
'agent/error'(agent: Agent, turn: number, step: number, error: Error): void
}
}

View File

@@ -13,7 +13,8 @@ function stubAgent(rawId: string): Agent {
send() {},
steer() {},
inject() {},
abort() {},
cancel() {},
whenIdle() { return Promise.resolve() },
}
}
@@ -30,12 +31,12 @@ describe('AgentRegistry', () => {
const agent = stubAgent('a1')
const dispose = ctx.agents.register(agent)
expect(created).toEqual(['a1'])
expect(ctx.agents.get('a1')).toBe(agent)
expect(ctx.agents.get(AgentId('a1'))).toBe(agent)
expect(ctx.agents.list()).toEqual([agent])
dispose()
expect(disposed).toEqual(['a1'])
expect(ctx.agents.get('a1')).toBeUndefined()
expect(ctx.agents.get(AgentId('a1'))).toBeUndefined()
})
it('rejects duplicate ids and unregisters on fiber dispose (HMR safety)', async () => {
@@ -64,14 +65,14 @@ describe('AgentRegistry', () => {
// The throwing emit must roll the entry back, not leak it.
expect(() => ctx.agents.register(stubAgent('main'))).toThrow('boom created listener')
expect(ctx.agents.get('main')).toBeUndefined() // rolled back, not leaked
expect(ctx.agents.get(AgentId('main'))).toBeUndefined() // rolled back, not leaked
// A subsequent listener-free register of the SAME id succeeds and is
// tracked exactly once (the duplicate-id check is not wedged).
const dispose = ctx.agents.register(stubAgent('main'))
expect(ctx.agents.list().map(a => a.id)).toEqual(['main'])
dispose()
expect(ctx.agents.get('main')).toBeUndefined()
expect(ctx.agents.get(AgentId('main'))).toBeUndefined()
})
})
@@ -80,8 +81,14 @@ describe('AgentRegistry factory seam', () => {
function stubFactory() {
const calls: { create: unknown[]; resume: unknown[] } = { create: [], resume: [] }
const factory: import('@deepseek-ai/dsh-agent').AgentFactory = {
createAgent(options) { calls.create.push(options); return stubAgent(options.agentId) },
resume(options) { calls.resume.push(options); return Promise.resolve(stubAgent(options.agentId)) },
createAgent(options) {
calls.create.push(options)
return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() }
},
resume(options) {
calls.resume.push(options)
return Promise.resolve({ agent: stubAgent(options.agentId), dispose: () => Promise.resolve() })
},
}
return { factory, calls }
}
@@ -89,8 +96,8 @@ describe('AgentRegistry factory seam', () => {
it('create()/resume() throw when no factory is registered', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).toThrow(/no agent factory/)
await expect(ctx.agents.resume({ agentId: 'a', resumeSessionId: 's' })).rejects.toThrow(/no agent factory/)
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).toThrow(/no agent factory/)
await expect(ctx.agents.resume({ agentId: AgentId('a'), resumeSessionId: SessionId('s') })).rejects.toThrow(/no agent factory/)
})
it('setFactory registers a factory; create/resume delegate to it', async () => {
@@ -99,13 +106,13 @@ describe('AgentRegistry factory seam', () => {
const { factory, calls } = stubFactory()
ctx.agents.setFactory(factory)
const created = ctx.agents.create({ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } })
expect(created.id).toBe('c1')
expect(calls.create).toEqual([{ agentId: 'c1', sessionId: 'sess-1', meta: { cwd: '/w' } }])
const created = ctx.agents.create({ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } })
expect(created.agent.id).toBe('c1')
expect(calls.create).toEqual([{ agentId: AgentId('c1'), sessionId: SessionId('sess-1'), meta: { cwd: '/w' } }])
const resumed = await ctx.agents.resume({ agentId: 'r1', resumeSessionId: 'old-sess' })
expect(resumed.id).toBe('r1')
expect(calls.resume).toEqual([{ agentId: 'r1', resumeSessionId: 'old-sess' }])
const resumed = await ctx.agents.resume({ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') })
expect(resumed.agent.id).toBe('r1')
expect(calls.resume).toEqual([{ agentId: AgentId('r1'), resumeSessionId: SessionId('old-sess') }])
})
it('setFactory rejects a second factory', async () => {
@@ -122,10 +129,10 @@ describe('AgentRegistry factory seam', () => {
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
dispose = inner.agents.setFactory(stubFactory().factory)
}, { inject: ['agents'] }))
expect(() => ctx.agents.create({ agentId: 'a', sessionId: 's' })).not.toThrow()
expect(() => ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).not.toThrow()
void dispose
await fiber.dispose()
// factory slot cleared → create throws again
expect(() => ctx.agents.create({ agentId: 'a2', sessionId: 's2' })).toThrow(/no agent factory/)
expect(() => ctx.agents.create({ agentId: AgentId('a2'), sessionId: SessionId('s2') })).toThrow(/no agent factory/)
})
})

View File

@@ -0,0 +1,83 @@
/**
* Negative-path tests for the cordis catalog generator (`scripts/gen-cordis-catalog.ts`).
*
* 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.
*/
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'
/** Write a fixture package exposing one `interface Events` block and return the
* scan root to hand `collectEvents`. */
function fixtureRoot(eventsBlock: 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 Events {\n${eventsBlock}\n }\n}\n`,
)
return root
}
const roots: string[] = []
const make = (block: string): string => {
const r = fixtureRoot(block)
roots.push(r)
return r
}
afterEach(() => {
while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true })
})
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',
))
expect(events).toHaveLength(1)
expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' })
})
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>',
))
expect(events[0]?.mode).toBe('waterfall')
})
it('accepts a parallel (awaited, no next) event by trusting the tag', () => {
const events = collectEvents(make(
' /**\n * Flush.\n * @mode parallel\n */\n \'fix/flush\'(): Promise<void> | void',
))
expect(events[0]?.mode).toBe('parallel')
})
it('hard-errors when an event is missing its @mode tag', () => {
expect(() => collectEvents(make(
' /** No mode here. */\n \'fix/untagged\'(id: string): 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>',
))).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',
))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/)
})
})

View File

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

View File

@@ -8,10 +8,20 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
### Public API
- `ctx.sessions.create(id?: string, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
- `ctx.sessions.get(id: string): Session | undefined`
- `ctx.sessions.create(id?: SessionId, options?: { seed?: SessionEvent[]; meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } }): Session` — Create a session. `options.seed` replays/forks an existing event log; `options.meta` attaches creation metadata (validated absolute `cwd`, `parentSession` lineage) as the immutable `SessionHeader`. The store fills `version`/`id` and defaults `createdAt` to now; a caller reconstructing a persisted session passes the original `createdAt` to preserve it. Disposed with the calling fiber.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
#### Advanced: ordered-teardown lifecycle primitives
`create()` covers the common case (the session is owned by the calling fiber). When a session must be torn down **in order with another resource** — so a final flush is captured before `onAppend` detaches — `create()`'s self-contained effect is wrong, because a fiber unload disposes sibling effects *concurrently*. For that, split the lifecycle and fold it into the owner's single effect:
- `ctx.sessions.prepare(id?, options?): Session` — validate the id/cwd and construct the `Session`, WITHOUT entering it into the store. Same options as `create`.
- `ctx.sessions.enter(session): () => void` — wire `onAppend``session/event` and add the session to the store; returns the DETACH disposer. Does NOT emit `session/created` (the caller yields the disposer first, then calls `announce`, so a throwing listener rolls the attach back). The id was already validated by `prepare`, which runs in the same synchronous sequence, so `enter` does not re-check.
- `ctx.sessions.announce(session): void` — emit `session/created` for an entered session.
`dsh-agent-loop`'s `AgentLoop.start` is the canonical consumer: it yields `enter`'s detach disposer, the registry unregister, and the loop-stop disposer into ONE composite effect, so teardown stops + awaits the loop (final flush captured) BEFORE detaching the session — whether the trigger is the `AgentHandle`'s `dispose()` or a fiber unload.
### Events
| Event | Mode | Purpose |
@@ -28,7 +38,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`.
- `session.deriveMessages(): Message[]` — derive the LLM message history. If any event in the log carries `surfaceOp`, derivation walks the surface linked list (skipping non-surface events). Otherwise, falls back to a linear scan of the raw log (legacy sessions without surface markers).
- `session.surface: SurfaceManager` — the derived surface, lazily rebuilt from `surfaceOp` markers in the log. Processes only new events (delta) on each access — the log is append-only, so prior events never change.
- `session.events`, `session.seq`, `session.id`
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal v1 header is synthesized for bare `Session` construction.
- `session.header: SessionHeader` — immutable creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`). Kept out of the event log (a storage concern, not replayable state); a minimal header (stamped with the current `SESSION_FORMAT_VERSION`) is synthesized for bare `Session` construction.
### Surface types
@@ -38,7 +48,7 @@ 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`, `usage`, `error`.
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`. Token usage rides on `assistant/message.usage`; an operational error's step is on `turn/end.reason` for `kind: 'error'`.
Merge-extensible via `SessionEventMap` — a compaction plugin adds `compaction/marker`, etc.
@@ -51,13 +61,11 @@ Every `SessionEvent` carries two optional top-level fields (structural metadata)
### Metadata types (`types.ts`)
- `SessionHeader` — immutable, written once: `{ version, id, createdAt, cwd?, parentSession? }`.
- `SessionSummary` — mutable, updateable without touching the log: `{ updatedAt, title?, firstPrompt? }`.
- `SessionMeta = SessionHeader & SessionSummary` — owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export these rather than own them (which would force a package cycle).
- `SessionHeader` — immutable session metadata, written once: `{ version, id, createdAt, cwd?, parentSession? }`. Owned here (beside `SessionId`) because `Session.header` is typed by it; persistence backends re-export it rather than own it (which would force a package cycle).
### Extension points
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`/`SessionSummary`/`SessionMeta`, `session.header`) is what such a backend stores beside the log.
- Persistence plugins: subscribe to `session/event` (write-behind) and drain on `session/flush` (awaited) and fiber dispose. A durable backend reads the log and reloads it into a live session; the metadata seam (`SessionHeader`, `session.header`) is what such a backend stores beside the log.
- Replay/fork: `ctx.sessions.create(id, { seed })` seeds a new session with an existing event log. The surface rebuilds deterministically from `surfaceOp` markers in the seeded events.
- Compaction: a future plugin appends a new event with `surfaceOp: { op: 'replace', start, end }` to shadow old surface nodes.

View File

@@ -20,10 +20,12 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
}

View File

@@ -9,7 +9,7 @@
import { Context, Service } from 'cordis'
import { isAbsolute } from 'node:path'
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
import { SessionId } from './types.ts'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceAppendOpts, SurfaceEventType } from './types.ts'
import { isJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
@@ -26,15 +26,24 @@ declare module 'cordis' {
}
interface Events {
/** A session was created in the store. */
/**
* A session was created in the store.
* @mode emit
*/
'session/created'(session: Session): void
/** An event was appended to a session log (sync, fire-and-forget). */
/**
* An event was appended to a session log (sync, fire-and-forget). This is
* the per-append feed a UI or invariant plugin tails.
* @mode emit
*/
'session/event'(session: Session, event: SessionEvent): void
/**
* Awaited durability checkpoint. The agent loop awaits
* `ctx.parallel('session/flush', session)` at every turn end; persistence
* plugins (JSONL, sqlite TODO, future phase) drain their write-behind
* buffers here and on fiber dispose.
* 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.
* @mode parallel
*/
'session/flush'(session: Session): Promise<void> | void
}
@@ -45,7 +54,9 @@ declare module 'cordis' {
* synthetic user-role message (the system-reminder pattern: zero adapter
* burden, models distinguish it from real user prompts by the envelope).
*
* TODO(review): revisit the envelope once a real adapter exists.
* Live-adapter review has validated the tagged-envelope rendering against
* current DeepSeek behavior; provider-specific mismatches belong in that
* adapter, not in the canonical session vocabulary.
*/
function renderTagged(tag: string, content: ContentBlock[], source: MessageSource): ContentBlock[] {
const open = `<${tag} source=${JSON.stringify(source.kind)}>`
@@ -86,9 +97,10 @@ export class Session {
/**
* Immutable creation metadata (format version, cwd, lineage). Supplied by
* the store via `ctx.sessions.create()`. When a `Session` is constructed
* bare (tests, ad-hoc replay), a minimal v1 header is synthesized so
* `session.header` is always present. Kept out of the event log it is a
* storage concern, not replayable conversation state.
* bare (tests, ad-hoc replay), a minimal header is synthesized (stamped with
* the current {@link SESSION_FORMAT_VERSION}) so `session.header` is always
* present. Kept out of the event log it is a storage concern, not
* replayable conversation state.
*/
readonly header: SessionHeader
@@ -119,7 +131,7 @@ export class Session {
// structuredClone can never hit a non-cloneable value here.
this.log = seed.map(event => structuredClone(event))
}
this.header = header ?? { version: 1, id, createdAt: Date.now() }
this.header = header ?? { version: SESSION_FORMAT_VERSION, id, createdAt: Date.now() }
}
get events(): readonly SessionEvent[] {
@@ -200,7 +212,10 @@ export class Session {
*
* - `user/message` user message
* - `assistant/message` assistant message (chunks are skipped they are
* replay/UI data; the assembled message is authoritative for history)
* replay/UI data; the assembled message is authoritative for history). An
* EMPTY-content assistant/message is skipped: a max-tokens step cut off with
* no content still records an assistant/message to host its `usage`, but a
* content-less assistant turn must not enter the provider transcript.
* - `tool/result` user message carrying a tool-result block
* - `context/message` / `steering/message` tagged synthetic user messages
* at their chronological position
@@ -221,10 +236,9 @@ export class Session {
// index by construction. The non-null assertion expresses that invariant.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const msg = this._deriveOneMessage(this.log[node.seq]!)
// isSurfaceEvent guarantees only the five surface-eligible types
// enter the surface, and all five produce messages → msg is never
// null. Defensive guard retained for interface contract clarity.
/* v8 ignore next */
// A surface node is one of the five message-producing types, but an
// empty-content assistant/message (a max-tokens step that hosts only
// usage) derives to null and must not enter the transcript.
if (msg) messages.push(msg)
}
return messages
@@ -253,6 +267,10 @@ export class Session {
return { role: 'user', content: structuredClone(event.data.content) }
}
case 'assistant/message': {
// Skip an empty-content assistant/message: it exists only to host a
// max-tokens step's usage and must not inject a content-less assistant
// turn into the provider transcript.
if (event.data.content.length === 0) return null
return { role: 'assistant', content: structuredClone(event.data.content) }
}
case 'tool/result': {
@@ -283,7 +301,7 @@ export class Session {
* subscribe to `session/event` and flush on `session/flush` / dispose.
*/
export class SessionStore extends Service {
private store = new Map<string, Session>()
private store = new Map<SessionId, Session>()
private counter = 0
constructor(ctx: Context) {
@@ -291,17 +309,48 @@ export class SessionStore extends Service {
}
/**
* Create a session. `options.seed` populates the session with a copy of
* those events (replay/fork); `options.meta` attaches creation metadata
* (validated absolute `cwd`, `parentSession` lineage) as the immutable
* {@link SessionHeader} (the store fills `version`/`id`/`createdAt`). The
* session is a Cordis effect: disposing the calling fiber stops event
* notification and removes the session from the store.
* Create a session owned by the calling fiber: disposing that fiber stops
* event notification and removes the session from the store. `options.seed`
* populates the session with a copy of those events (replay/fork);
* `options.meta` attaches creation metadata (validated absolute `cwd`,
* `parentSession` lineage) as the immutable {@link SessionHeader} (the store
* fills `version`/`id`/`createdAt`).
*
* For an agent whose session must be torn down IN ORDER with its loop (so the
* loop's final flush is captured before `onAppend` detaches), do NOT use this
* fold the session lifecycle into the agent's own effect via
* {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s
* `startOwned`).
*
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path (storage backends key directories off it).
*/
create(id?: string, options?: CreateSessionOptions): Session {
create(id?: SessionId, options?: CreateSessionOptions): Session {
const session = this.prepare(id, options)
// Single effect owned by the calling fiber. Yield the detach BEFORE
// announcing so a throwing `session/created` listener rolls the attach back
// (the generator effect disposes already-yielded disposers on a throw)
// instead of leaking the store entry + onAppend.
this.ctx.effect(function* (this: SessionStore) {
yield this.enter(session)
this.announce(session)
}.bind(this), 'sessions.create()')
return session
}
/**
* Build a session WITHOUT entering it into the store validate the id/cwd and
* construct the {@link Session} (with its immutable {@link SessionHeader}).
* Pairs with {@link enter} + {@link announce}: a caller that owns a composite
* `ctx.effect` (the agent factory) folds the session lifecycle into that ONE
* effect so a fiber unload tears the session + agent down as a single ORDERED
* chain rather than as racing sibling effects which would detach `onAppend`
* before the loop's closing `session/flush`, dropping the closing events.
*
* @throws if a session with `id` already exists, or if `meta.cwd` is a
* non-absolute path.
*/
prepare(id?: SessionId, options?: CreateSessionOptions): Session {
const sessionId = SessionId(id ?? `session-${++this.counter}`)
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
const cwd = options?.meta?.cwd
@@ -309,32 +358,51 @@ export class SessionStore extends Service {
throw new Error(`session cwd must be an absolute path, got "${cwd}"`)
}
const header: SessionHeader = {
version: 1,
version: SESSION_FORMAT_VERSION,
id: sessionId,
createdAt: options?.meta?.createdAt ?? Date.now(),
...cwd !== undefined ? { cwd } : {},
...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {},
}
const session = new Session(sessionId, options?.seed, header)
this.ctx.effect(function* (this: SessionStore) {
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
this.store.set(sessionId, session)
// Yield the rollback BEFORE emitting `session/created`: a generator
// effect collects each yielded disposer before the next step runs, so a
// throwing `session/created` listener detaches onAppend and removes the
// store entry instead of leaking them (a leak would wedge the
// already-exists check until restart). The duplicate throw above fires
// before any mutation — it leaks nothing.
yield () => {
session.onAppend = undefined
this.store.delete(sessionId)
}
this.ctx.emit('session/created', session)
}.bind(this), 'sessions.create()')
return session
return new Session(sessionId, options?.seed, header)
}
get(id: string): Session | undefined {
/**
* Enter a {@link prepare}d session into the store: wire `onAppend`
* `session/event` and add it to the store. Returns the DETACH disposer
* (`onAppend = undefined` + store removal). Does NOT emit `session/created`
* the caller yields this disposer inside its effect and THEN calls
* {@link announce}, so a throwing `session/created` listener rolls the attach
* back instead of leaking it.
*
* Re-checks the id for a duplicate: `prepare` and `enter` are public
* cross-package primitives and a caller may interleave arbitrary work (or
* another create) between them, so a stale prepared session must NOT overwrite
* a live store entry of the same id its detach disposer would later delete
* the REAL session. The {@link create} convenience and the agent factory call
* the two back-to-back so they never trip this, but the public seam cannot
* assume that.
*
* @throws if a session with this id is already in the store.
*/
enter(session: Session): () => void {
if (this.store.has(session.id)) throw new Error(`session "${session.id}" already exists`)
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
this.store.set(session.id, session)
return () => {
session.onAppend = undefined
this.store.delete(session.id)
}
}
/** 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}). */
announce(session: Session): void {
this.ctx.emit('session/created', session)
}
get(id: SessionId): Session | undefined {
return this.store.get(id)
}

View File

@@ -27,9 +27,10 @@
* which every provider rejects as an invalid transcript on the next request.
* Synthesizing an error result per orphaned call keeps resume safe.
*
* This module computes those synthetic closers from an event list; the backend
* returns them inline from `load` (so the reconstructed session is balanced and
* immediately usable) and persists them on the first post-load `append`.
* This module computes those synthetic closers from an event list; backends
* return them inline from `load` (so the reconstructed session is balanced and
* immediately usable) and persist them during that mutating load before any
* later append continues the log.
*
* @module @deepseek-ai/dsh-session/repair
*/
@@ -83,6 +84,7 @@ export function interruptedTurnClosers(events: readonly SessionEvent[]): Session
openStep = event.data.step
break
case 'step/end':
pendingCalls.clear()
openStep = null
break
case 'assistant/message':

View File

@@ -1,4 +1,5 @@
import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
/** Identifies one session in the store (and its persistence artifacts). */
export type SessionId = Branded<'SessionId'>
@@ -8,6 +9,23 @@ export function SessionId(id: string): SessionId {
return id as SessionId
}
/**
* The on-disk session format version, stamped into every newly-written
* {@link SessionHeader} and enforced by every persistence backend on load. The
* single source of truth for the version write sites and the load-time check
* all read it.
*
* It is **`0`** deliberately: while the harness is unreleased the on-disk format
* is **unstable / pre-release, with no compatibility implied**. Breaking changes
* to the persisted {@link SessionEventMap} shape (folding fields onto an event,
* removing a variant, ) happen freely and do NOT bump this v0 absorbs all
* pre-release churn, and a backend simply REJECTS any log not at v0 (there is no
* migration; no persisted user data exists to preserve). A real, monotonically
* bumped version policy begins at the first tagged release, when a specific
* format boundary becomes worth distinguishing.
*/
export const SESSION_FORMAT_VERSION = 0
/**
* Immutable session metadata written once at creation and never rewritten.
*
@@ -18,7 +36,11 @@ export function SessionId(id: string): SessionId {
* metadata) writes such a header.
*/
export interface SessionHeader {
/** On-disk format version; a persistence backend rejects unknown versions. */
/**
* On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the
* session is created. A persistence backend rejects any other version on load
* (no migration see the constant).
*/
version: number
/** The session's id (mirrors the {@link Session}'s id). */
id: SessionId
@@ -30,29 +52,6 @@ export interface SessionHeader {
parentSession?: SessionId
}
/**
* Mutable session metadata updateable without touching the append-only log.
* A persistence backend stores this beside the log (a sidecar file, a header
* row) and rewrites only it on update.
*/
export interface SessionSummary {
/** Unix epoch milliseconds of the last mutation (event append or update). */
updatedAt: number
/** Human-facing title (derived/edited), if any. */
title?: string
/** The first user prompt, cached for listing previews. */
firstPrompt?: string
}
/**
* Full session metadata: the immutable {@link SessionHeader} merged with the
* mutable {@link SessionSummary}. Owned here in `dsh-session` (beside
* {@link SessionId}) because `Session.header` is typed by it; the persistence
* package imports/re-exports these rather than owning them, which would force
* a package cycle.
*/
export type SessionMeta = SessionHeader & SessionSummary
/**
* Options for creating a {@link Session} via the store. `seed` replays/forks
* an existing event log; `meta` carries the caller-supplied storage fields the
@@ -110,7 +109,13 @@ export type TurnTrigger = TurnTriggerMap[keyof TurnTriggerMap]
export interface TurnEndReasonMap {
completed: { kind: 'completed' }
aborted: { kind: 'aborted'; reason?: string }
error: { kind: 'error'; message: string; code?: string }
/**
* The turn failed: a step threw or the model reported a failure. `step` is the
* step number the failure occurred on (the operational error's location the
* single durable record of an in-turn failure; live diagnostics also fire via
* `agent/error`). `code` is the error's code when one was attached.
*/
error: { kind: 'error'; step: number; message: string; code?: string }
disposed: { kind: 'disposed' }
'max-tokens': { kind: 'max-tokens' }
/**
@@ -162,14 +167,17 @@ export interface SessionEventMap {
'context/message': { content: ContentBlock[]; source: MessageSource }
/** Raw stream chunk — token-level replay fidelity. */
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
/** Assembled assistant message for one step (derived history uses this). */
'assistant/message': { turn: number; step: number; content: ContentBlock[] }
/**
* Assembled assistant message for one step (derived history uses this).
* Carries the step's `usage` when the adapter reported token accounting, so
* the model output and its accounting travel together (there is no separate
* usage record). `usage` is absent when the adapter reported none.
*/
'assistant/message': { turn: number; step: number; content: ContentBlock[]; usage?: TokenUsage }
'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 } }
/** Steering content injected between steps of a running turn. */
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
'usage': { turn: number; step: number; usage: TokenUsage }
'error': { turn: number; step: number; message: string; code?: string }
}
export type SessionEventType = keyof SessionEventMap

View File

@@ -24,6 +24,7 @@ const textContentArb = fc.array(
const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, usage: { inputTokens: 1, outputTokens: 1 } } })),
fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() })
.map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError } })),
)
@@ -35,8 +36,6 @@ const nonMessageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
fc.constant<Appendable>({ type: 'step/start', data: { turn: 1, step: 1 } }),
fc.constant<Appendable>({ type: 'step/end', data: { turn: 1, step: 1 } }),
fc.string().map((text): Appendable => ({ type: 'assistant/chunk', data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text } } })),
fc.constant<Appendable>({ type: 'usage', data: { turn: 1, step: 1, usage: { inputTokens: 1, outputTokens: 1 } } }),
fc.constant<Appendable>({ type: 'error', data: { turn: 1, step: 1, message: 'x' } }),
)
const anyEventArb = fc.oneof(messageEventArb, nonMessageEventArb)

View File

@@ -82,6 +82,21 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.type)).toEqual(['step/end', 'turn/end'])
})
it('does NOT synthesize a result after the owning step already closed', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
] } },
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['turn/end'])
expect(closers[0]?.seq).toBe(4)
})
it('synthesizes results only for the still-open turn, not a committed earlier turn', () => {
// Turn 1 completed with its own tool call+result (balanced). Turn 2 crashed
// with an unanswered call. Only turn 2's call must get a synthetic result.

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('derives message history from the event log', () => {
@@ -213,19 +213,53 @@ describe('SessionStore', () => {
it('rejects duplicate ids and supports seeding', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const a = ctx.sessions.create('fixed')
expect(() => ctx.sessions.create('fixed')).toThrow('already exists')
const a = ctx.sessions.create(SessionId('fixed'))
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
const forked = ctx.sessions.create('fork', { seed: [...a.events] })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
})
it('synthesizes a minimal v1 header for a bare-created session', async () => {
it('enter() rejects a stale prepared session whose id is already live (no overwrite)', async () => {
// prepare()/enter() are public cross-package primitives that a caller may
// separate with arbitrary work. A stale prepared session must NOT overwrite
// a live store entry of the same id — its detach disposer would later delete
// the REAL session, breaking the store-uniqueness invariant.
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('plain')
expect(session.header).toMatchObject({ version: 1, id: 'plain' })
const stale = ctx.sessions.prepare(SessionId('racy'))
const live = ctx.sessions.create(SessionId('racy'))
expect(() => ctx.sessions.enter(stale)).toThrow(/already exists/)
// The live session is intact and still the store entry.
expect(ctx.sessions.get(SessionId('racy'))).toBe(live)
})
it('prepare() + enter() + announce() register a session and emit session/created', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const created: Session[] = []
ctx.on('session/created', session => void created.push(session))
const session = ctx.sessions.prepare(SessionId('lifecycle'))
// prepare alone does NOT enter the store.
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
const detach = ctx.sessions.enter(session)
expect(ctx.sessions.get(SessionId('lifecycle'))).toBe(session)
// enter does NOT announce.
expect(created).toEqual([])
ctx.sessions.announce(session)
expect(created).toEqual([session])
// The detach disposer removes the entry + stops notification.
detach()
expect(ctx.sessions.get(SessionId('lifecycle'))).toBeUndefined()
})
it('synthesizes a minimal current-version header for a bare-created session', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('plain'))
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
expect(typeof session.header.createdAt).toBe('number')
expect(session.header.cwd).toBeUndefined()
expect(session.header.parentSession).toBeUndefined()
@@ -234,11 +268,11 @@ describe('SessionStore', () => {
it('attaches cwd and parentSession from meta to the header', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create('child', {
const session = ctx.sessions.create(SessionId('child'), {
meta: { cwd: '/work/project', parentSession: SessionId('parent') },
})
expect(session.header).toMatchObject({
version: 1,
version: SESSION_FORMAT_VERSION,
id: 'child',
cwd: '/work/project',
parentSession: 'parent',
@@ -248,15 +282,15 @@ describe('SessionStore', () => {
it('rejects a non-absolute meta.cwd', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
expect(() => ctx.sessions.create('rel', { meta: { cwd: 'relative/path' } }))
expect(() => ctx.sessions.create(SessionId('rel'), { meta: { cwd: 'relative/path' } }))
.toThrow(/cwd must be an absolute path/)
// the rejected session was not registered
expect(ctx.sessions.get('rel')).toBeUndefined()
expect(ctx.sessions.get(SessionId('rel'))).toBeUndefined()
})
it('a bare Session() constructed without the store still exposes a v1 header', () => {
it('a bare Session() constructed without the store still exposes a current-version header', () => {
const session = new Session(SessionId('bare'))
expect(session.header).toMatchObject({ version: 1, id: 'bare' })
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'bare' })
expect(typeof session.header.createdAt).toBe('number')
})
@@ -266,15 +300,15 @@ describe('SessionStore', () => {
let session!: Session
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
session = inner.sessions.create('scoped')
session = inner.sessions.create(SessionId('scoped'))
}, { inject: ['sessions'] }))
expect(ctx.sessions.get('scoped')).toBe(session)
expect(ctx.sessions.get(SessionId('scoped'))).toBe(session)
let observed = 0
ctx.on('session/event', () => void observed++)
await fiber.dispose()
expect(ctx.sessions.get('scoped')).toBeUndefined()
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } })
expect(observed).toBe(0)
})
@@ -289,15 +323,15 @@ describe('SessionStore', () => {
})
// The throwing emit must roll the store entry back, not leak it.
expect(() => ctx.sessions.create('fixed')).toThrow('boom created listener')
expect(ctx.sessions.get('fixed')).toBeUndefined() // rolled back, not leaked
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('boom created listener')
expect(ctx.sessions.get(SessionId('fixed'))).toBeUndefined() // rolled back, not leaked
// A subsequent create of the SAME id succeeds (the already-exists check is
// not wedged) and its onAppend is correctly wired (events observable).
const events: SessionEvent[] = []
ctx.on('session/event', (_session, event) => void events.push(event))
const session = ctx.sessions.create('fixed')
expect(ctx.sessions.get('fixed')).toBe(session)
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } })
expect(events).toHaveLength(1)
})

View File

@@ -292,19 +292,19 @@ describe('Session.append surface opts', () => {
expect((s.events[0]! as SurfaceEvent).surfaceOp).toBe('append')
})
it('deriveMessages skips surface nodes whose event type is not message-producing', () => {
// A surface node with a type not handled by _deriveOneMessage (e.g., 'usage'
// placed on surface) should be skipped — the null-check in the surface
// derivation path is exercised.
it('deriveMessages skips a surface node that derives to null (empty assistant/message)', () => {
// An empty-content assistant/message is surface-eligible (it can host usage)
// but _deriveOneMessage returns null for it, so the surface derivation path's
// null-check is exercised — the node is on the surface yet produces no message.
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'usage', seq: 2, time: 3, data: { turn: 1, step: 1, usage: { inputTokens: 0, outputTokens: 0 } }, surfaceOp: 'append' as const } as SessionEvent,
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [] }, surfaceOp: 'append' },
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
const s = new Session(SessionId('nomessage'), seed)
// The usage event is on the surface but _deriveOneMessage returns null for it.
// The empty assistant/message is on the surface but _deriveOneMessage returns null for it.
expect(s.deriveMessages()).toHaveLength(0)
})

View File

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

View File

@@ -15,9 +15,18 @@ declare module 'cordis' {
}
interface Events {
/** Waterfall around prompt assembly — mutate/extend the assembly. */
/**
* 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.
* @mode waterfall
*/
'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly>
/** A section or tool provider was registered or unregistered. */
/**
* A section or tool provider was registered or unregistered (the assembly
* inputs changed).
* @mode emit
*/
'system-prompt/change'(): void
}
}
@@ -116,15 +125,21 @@ export class SystemPrompt extends Service {
/**
* Assemble the current prompt (sections sorted by order, tools collected
* from all providers). Runs through the `system-prompt/assemble` waterfall,
* giving listeners the opportunity to mutate or replace the assembly before
* it reaches the model. Await the result before reading the assembly values
* from all providers). Section records are top-level clones (the `text`
* provider may be a function and is intentionally shared); tool schemas are
* deep-cloned because adapters and request waterfalls may mutate schema
* objects. Runs through the `system-prompt/assemble` waterfall, giving
* 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.
*/
assemble(): Promise<PromptAssembly> {
const assembly: PromptAssembly = {
sections: [...this.sections].sort((a, b) => a.order - b.order),
tools: this.toolProviders.flatMap(provider => provider()),
sections: this.sections
.map(section => ({ ...section }))
.sort((a, b) => a.order - b.order),
tools: this.toolProviders.flatMap(provider =>
provider().map(tool => ({ ...tool, parameters: structuredClone(tool.parameters) }))),
}
return this.ctx.waterfall(this, 'system-prompt/assemble', assembly, () => Promise.resolve(assembly))
}

View File

@@ -107,6 +107,23 @@ describe('SystemPrompt', () => {
expect(assembly.sections).toHaveLength(0)
})
it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
ctx.systemPrompt.section({ name: 'base', order: 0, text: 'base' })
ctx.systemPrompt.tools(() => [{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
const first = await ctx.systemPrompt.assemble()
first.sections[0]!.name = 'mutated'
first.tools[0]!.description = 'mutated'
const firstParameters = first.tools[0]!.parameters as { properties: Record<string, unknown> }
firstParameters.properties['leak'] = { type: 'string' }
const second = await ctx.systemPrompt.assemble()
expect(second.sections.map(section => section.name)).toEqual(['base'])
expect(second.tools).toEqual([{ name: 't', description: 'tool', parameters: { type: 'object', properties: {} } }])
})
it('filters out empty section text from renderPrompt', () => {
// Direct test of renderPrompt: function returning empty string, and empty static text
const result = renderPrompt({

View File

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

View File

@@ -24,9 +24,10 @@ Tool registry and execution waterfall. Tool plugins register their schemas and e
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`.
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[]>`, plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay).
- `ToolCallPresentation` / `ToolResultPresentation` — provider-neutral shapes a tool returns from `presentCall` / `presentResult` to own how a UI renders ITS calls (see "Tool-owned UI presentation").
### Extension points
@@ -67,6 +68,39 @@ A `defineTool` tool also **validates the model-generated arguments against its `
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
### Tool-owned UI presentation
A tool owns how ITS calls render in a UI (an editor's tool-call card, a CLI log line) — a UI plugin must NOT special-case tool names. A `ToolDefinition` may declare two optional, pure, display-only methods:
- `presentCall(args): ToolCallPresentation | undefined` — the PENDING state: a human-readable `title` (always-visible label), an optional `kind` (`read`/`edit`/`execute`/… for icon/treatment, default `other`), an optional `rawInput` (the salient input to show in a detail view — e.g. a shell command as a string, NOT the whole args object), an optional `content` (UI content shown alongside the title/card — e.g. a bash `description` as a text block above the terminal card), and an optional `terminal` (a neutral `{ cwd? }` asking a capable UI to render this call as a TERMINAL, e.g. for `bash`).
- `presentResult(args, result): ToolResultPresentation | undefined` — the COMPLETED state, given the same `args` and the `{ content, isError }` result: an optional replacement `title`, reformatted `content` (e.g. wrap command output in a fenced ` ```console ` block — a UI-only affordance that must NOT appear in the model-facing `execute` result), and an optional `terminal` (the `{ output?, exitCode?, signal? }` for a terminal-rendered call). The `ToolTerminal` shape is provider-neutral; a UI bridge (the ACP bridge) maps it to a terminal card (with an exit-status pill) and a UI that can't ignores it and uses `content`.
Returning `undefined` (or omitting a method) tells a UI to fall back to a generic presentation (title = tool name, raw args as input, raw result content). Both methods must be **pure and side-effect-free**: a UI may call them during live streaming AND during a session-log replay, so they depend only on their arguments. With `defineTool`, `args` is the typed `InferArgs<S>` shape; the helper soft-validates before calling (a malformed/older logged arg shape yields `undefined` rather than throwing, since display must never crash a replay). The shapes are provider-neutral — the ACP bridge (`dsh-acp`) maps them to ACP `tool_call`/`tool_call_update` wire fields, and `dsh-tool-bash` is the reference implementation.
```ts
import { defineTool } from '@deepseek-ai/dsh-tools'
const bash = defineTool({
name: 'bash',
description: 'Run a shell command.',
parameters: {
command: { type: 'string', required: true, description: 'The command to run.' },
description: { type: 'string', required: true, description: 'One-line summary shown in the UI.' },
},
async execute(args) {
return [{ type: 'text', text: `ran: ${args.command}` }]
},
// The command is the readable title; the description rides as a content block.
presentCall: args => ({ title: args.command, kind: 'execute', rawInput: args.command, content: [{ type: 'text', text: args.description }] }),
// Wrap the output as a console block for the UI (not in the model-facing result).
presentResult: (_args, result) => {
const block = result.content.length === 1 ? result.content[0] : undefined
if (block === undefined || block.type !== 'text') return undefined
return { content: [{ type: 'text', text: '```console\n' + block.text + '\n```' }] }
},
})
```
### What is NOT here (TODO)
- **Tool shapes review** — when real tools land (e.g. a concurrency-safety hint for parallel execution); phase 1 executes tool calls sequentially.

View File

@@ -0,0 +1,376 @@
/**
* Tool registry and execution waterfall. 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.
*
* @module @deepseek-ai/dsh-tools
*/
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 {} from '@deepseek-ai/dsh-system-prompt'
export {
defineTool,
schemaSpecToJsonSchema,
validateArgs,
ToolArgsError,
type SchemaSpec,
type SchemaProp,
type SchemaType,
type InferArgs,
type DefineToolOptions,
type JsonSchemaObject,
} from './schema.ts'
declare module 'cordis' {
interface Context {
tools: ToolRegistry
}
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).
* @mode waterfall
*/
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* A tool was registered or unregistered (the available tool set changed).
* @mode emit
*/
'tools/change'(): void
}
}
// TODO(review): revisit these shapes when the first real tools and
// sandbox/permission plugins land (e.g. a concurrency-safety hint for
// parallel execution — Claude Code partitions read-only tools; phase 1
// executes sequentially).
/**
* Category of a tool call, used by a UI to pick an icon / treatment. A neutral
* vocabulary owned here (NOT an ACP type) so tools describe themselves without
* depending on any client protocol; a UI bridge maps it to its own enum. The
* member set mirrors the common ACP `ToolKind` values; `other` is the default.
*/
export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other'
// FIXME(tool-presentation): the ToolCallPresentation / ToolResultPresentation /
// ToolTerminal shapes need a rethink. They grew incrementally (title/kind/
// rawInput, then a `content` block, then a `terminal` sub-shape carrying cwd/
// output/exit) and the split of responsibility is now muddy: the call vs result
// terminal fields overlap, the bridge has to reconcile a `content` block AND a
// `terminal` block AND `rawInput` per call, and the "pending vs completed"
// boundary doesn't cleanly map to how editors actually render (terminal card,
// diff, generic card). Before more tools/UIs depend on this, redesign the type
// so a tool declares its render INTENT once (e.g. a tagged union over card
// kinds) rather than a bag of optional fields the bridge stitches together.
// Pin the design in an RFC and migrate dsh-tool-bash + the ACP bridge together.
/**
* How a tool wants ONE of its calls shown in a UI (an editor's tool-call card,
* a CLI log line) BEFORE the result is known — the *pending* state. Provider-
* neutral: a tool returns this from {@link ToolDefinition.presentCall} and a UI
* plugin (e.g. the ACP bridge) maps it to its own wire shape. The tool owns its
* own presentation — the UI must not special-case tool names.
*/
export interface ToolCallPresentation {
/**
* Human-readable, always-visible label describing what THIS call does (e.g.
* the model-written one-line summary of a bash command). Keep it short — a UI
* shows it as a card header / log line. Required: a presentation must have a
* title (a UI falls back to the tool name only when `presentCall` is absent).
*/
title: string
/** Category for icon/treatment; defaults to `other` when omitted. */
kind?: ToolCallKind
/**
* The salient input to surface in a detail/expanded view — e.g. the bash
* COMMAND itself (as a string), so the title can stay a readable summary
* while the exact command is still visible. Omit to show nothing; a string is
* rendered as-is, an object as pretty JSON. NOT the full raw args object
* unless that is genuinely what a reader wants.
*/
rawInput?: unknown
/**
* UI-facing content to show on the PENDING call alongside the title/card —
* harness {@link ContentBlock}s, in render order. A terminal tool uses this to
* surface its human-readable `description` as a text block ABOVE the terminal
* card (the card itself is requested via {@link terminal} and labelled by the
* command in `title`), since the card has no description slot. Omit to show no
* extra content. A UI maps these to its own content blocks and renders a
* {@link terminal} block (if any) as a terminal card.
*/
content?: ContentBlock[]
/**
* Ask a capable UI to render this call as a TERMINAL (a command running in a
* working directory), not a generic tool card — set by a tool whose call IS a
* shell command (e.g. `bash`). Provider-neutral; a UI bridge maps it to its
* own terminal affordance and a UI that can't falls back to the normal card.
* Pair with {@link ToolResultPresentation.terminal} for the output/exit.
*/
terminal?: ToolTerminal
}
/**
* A request to render a tool call as a terminal. The pending presentation
* supplies the working directory; the result presentation (see
* {@link ToolResultPresentation.terminal}) supplies the captured output and exit
* status. Provider-neutral — no client-protocol types. A UI that supports
* terminals shows a cwd-headed terminal card with the command, its output, and
* an exit-status pill; a UI that does not ignores this and renders the ordinary
* card/content.
*/
export interface ToolTerminal {
/**
* Working directory the command ran in, shown as the terminal header. An
* ABSOLUTE path is used as-is; a RELATIVE path is resolved by the UI bridge
* against the session workspace (the pure tool presenter can't see the
* session cwd). Omit entirely to let the bridge use the session workspace.
*/
cwd?: string
/** Captured command output (stdout+stderr as the tool chooses to combine them). Result-state only. */
output?: string
/**
* Process exit code, when the run ended by exiting (not a signal). Result-state
* only; lets a capable UI show an exit-status pill on the terminal card. Omit
* when the command was killed by a signal or the exit code is unknown.
*/
exitCode?: number
/**
* Signal name that killed the process (e.g. `SIGTERM`), when it died by signal
* rather than exiting. Result-state only; mutually exclusive with `exitCode`.
*/
signal?: string
}
/**
* How a tool wants the COMPLETED call shown — the *result* state, after
* `execute` returns. Lets the tool reformat its result for a UI distinctly from
* the model-facing text it returned from `execute` (e.g. wrap command output in
* a fenced ```console block for monospace rendering, which the model-facing
* result must NOT carry). All fields optional: a UI keeps the pending-state
* title and renders the raw result content for anything left unset.
*/
export interface ToolResultPresentation {
/** Replacement title for the completed call (e.g. append an exit status). Omit to keep the pending-state title. */
title?: string
/**
* UI-facing result content (harness {@link ContentBlock}s), reformatted from
* the model-facing result. Omit to let the UI render the raw result content.
* Stays in harness vocabulary; the UI maps these to its own content blocks.
*/
content?: ContentBlock[]
/**
* Terminal output/exit for a call the pending presentation marked as a
* terminal (see {@link ToolCallPresentation.terminal}). A capable UI renders
* `output` in the terminal card and shows the exit status; an incapable UI
* uses `content` (the tool should supply a text fallback there too).
*/
terminal?: ToolTerminal
}
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]>
/**
* Optional: how to present the PENDING state of one call in a UI, derived
* from the call's `args` (parsed arguments, `unknown` — the tool validates/
* narrows its own input). Returning `undefined` (or omitting the method) tells
* a UI to fall back to a generic presentation (title = tool name, raw args as
* input). Pure and side-effect-free: a UI may call it during live streaming
* AND a session-log replay, so it must depend only on `args`.
*/
presentCall?(args: unknown): ToolCallPresentation | undefined
/**
* Optional: how to present the COMPLETED state, given the same `args` and the
* `result` (`execute`'s content + whether it errored). Returning `undefined`
* (or omitting the method) tells a UI to keep the pending title and render the
* raw result content. Pure and side-effect-free for the same replay reason.
*/
presentResult?(args: unknown, result: ToolResult): ToolResultPresentation | undefined
}
/** The completed outcome handed to {@link ToolDefinition.presentResult}. */
export interface ToolResult {
/** The model-facing content `execute` returned (or the error text on failure). */
content: ContentBlock[]
/** Whether the call failed. */
isError: boolean
}
/** One pending tool call, as it flows through the execution waterfall. */
export interface ToolExecution {
callId: CallId
name: string
/** Parsed JSON arguments (unknown — tools validate their own input). */
arguments: unknown
/** The agent on whose behalf the call runs (set by the agent loop). */
agent?: Agent
signal?: AbortSignal
}
/** Structured error metadata for a failed tool call (alongside the model-facing text). */
export interface ToolErrorInfo {
name: string
code: string
}
/**
* Thrown (internally) when the model requests a tool that isn't registered.
* Extends {@link HarnessError} (`code: 'UNKNOWN_TOOL'`) so an unknown-tool
* failure is as routable as a tool-thrown one — retry/sandbox/replay code can
* distinguish it from a tool body's own error.
*/
export class ToolNotFoundError extends HarnessError {
constructor(public readonly toolName: string) {
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
this.name = 'ToolNotFoundError'
}
}
/** The outcome of one tool call. */
export interface ToolExecutionResult {
callId: CallId
content: ContentBlock[]
isError: boolean
/**
* Set when the call failed with a {@link HarnessError}: machine-routable
* `{ name, code }` for retry/sandbox plugins and replay. The model-facing
* text in `content` is always present; this is extra structure for code.
*/
error?: ToolErrorInfo
}
/**
* Best-effort human-readable message from an arbitrary thrown value: Error
* instances use `.message`; non-Error objects with a string `message`
* property (e.g. `throw { message: 'denied' }`) use it too; everything else
* is stringified.
*/
function errorMessage(error: unknown): string {
if (error instanceof Error) return error.message
if (typeof error === 'object' && error !== null
&& 'message' in error && typeof error.message === 'string') {
return error.message
}
return String(error)
}
/** Structured `{ name, code }` for a thrown HarnessError, else undefined. */
function errorInfo(error: unknown): ToolErrorInfo | undefined {
return error instanceof HarnessError ? { name: error.name, code: error.code } : 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.
*/
export class ToolRegistry extends Service {
static inject = ['systemPrompt']
private store = new Map<string, ToolDefinition>()
constructor(ctx: Context) {
super(ctx, 'tools')
ctx.systemPrompt.tools(() => this.schemas())
}
/**
* Register a tool. Throws if a tool with the same name is already
* 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.
*/
register(definition: ToolDefinition): () => void {
const dispose = this.ctx.effect(function* (this: ToolRegistry) {
if (this.store.has(definition.name)) {
throw new Error(`tool "${definition.name}" is already registered`)
}
this.store.set(definition.name, definition)
// Yield the rollback BEFORE emitting `tools/change`: a generator effect
// collects each yielded disposer before the next step runs, so a throwing
// `tools/change` listener removes the tool instead of leaking it (a leak
// would wedge the duplicate-name check until restart). The duplicate
// throw above fires before any mutation — it leaks nothing.
yield () => {
this.store.delete(definition.name)
this.ctx.emit('tools/change')
}
this.ctx.emit('tools/change')
}.bind(this), 'tools.register()')
// ctx.effect's disposer returns Promise<void>; our disposer API is
// synchronous fire-and-forget — discard the (always-resolved) promise.
return () => void dispose()
}
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.
*/
schemas(): ToolSchema[] {
return [...this.store.values()].map(({ name, description, parameters, strict }): 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}
* surfaces its `{ name, code }` on the result.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
try {
return await this.ctx.waterfall(this, 'tools/execute', exec, async (): Promise<ToolExecutionResult> => {
try {
const tool = this.store.get(exec.name)
// Unknown tool routes through the same catch as a tool-thrown error, so
// both failure classes get structured `{ name, code }` from one path.
if (!tool) throw new ToolNotFoundError(exec.name)
const content = await tool.execute(exec.arguments, exec)
return { callId: exec.callId, content, isError: false }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
})
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
}
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
const info = errorInfo(error)
return {
callId,
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
isError: true,
...info ? { error: info } : {},
}
}
export default ToolRegistry

View File

@@ -21,7 +21,7 @@
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import type { ToolDefinition, ToolExecution } from './index.ts'
import type { ToolCallPresentation, ToolDefinition, ToolExecution, ToolResult, ToolResultPresentation } from './index.ts'
// ---------------------------------------------------------------------------
// SchemaSpec — the author-facing per-property type
@@ -39,7 +39,14 @@ export interface SchemaProp {
description?: string
/** Enum of allowed values (strings only). */
enum?: string[]
/** Default value. */
/**
* Default value, emitted into the JSON Schema only (validation never applies
* it see the validator note below).
*
* XXX(unused-default): no tool definition in the repo sets `default`; it rides
* into the wire schema for a model that no tool surfaces it to. Drop the field
* and its converter line unless a real tool needs a model-visible default.
*/
default?: unknown
/** Nested properties for type: 'object'. */
properties?: SchemaSpec
@@ -287,6 +294,22 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* casts needed.
*/
execute(args: InferArgs<S>, exec: ToolExecution): Promise<ContentBlock[]>
/**
* Optional: how to present the PENDING state of one call in a UI (an editor
* tool-call card, a CLI log line). `args` is the typed, schema-validated
* argument shape zero casts. Pure and side-effect-free: a UI may call it
* during live streaming AND a session-log replay, so depend only on `args`.
* The tool owns its presentation so a UI never special-cases tool names. See
* {@link ToolCallPresentation}.
*/
presentCall?(args: InferArgs<S>): ToolCallPresentation | undefined
/**
* Optional: how to present the COMPLETED state, given the typed `args` and the
* `result`. Use it to reformat result content for a UI distinctly from the
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
* free for the same replay reason. See {@link ToolResultPresentation}.
*/
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultPresentation | undefined
/** Whether the tool requires structured output (default false). */
strict?: boolean
}
@@ -322,7 +345,11 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
// Object-literal execute methods don't use `this`; the reference is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
return {
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
@@ -337,4 +364,21 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
return userExecute(args as InferArgs<S>, exec)
},
}
// Presentation is display-only and may run on REPLAY of arbitrary logged args
// (possibly from an older schema), so it must never throw: validate softly and
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
// than the hard `ToolArgsError` the execute path raises.
if (userPresentCall) {
tool.presentCall = (args: unknown): ToolCallPresentation | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentCall(args as InferArgs<S>)
}
}
if (userPresentResult) {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultPresentation | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result)
}
}
return tool
}

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
@@ -41,6 +41,39 @@ describe('ToolRegistry', () => {
expect(assembly.tools.map(t => t.name)).toEqual(['echo'])
})
it('schemas() drops the UI presentation callbacks — they must never reach the model', async () => {
const ctx = await setup()
// A tool that declares presentCall/presentResult (functions). schemas() feeds
// the system-prompt assembly → the model request, so those callbacks (and
// `execute`) must be stripped: a function in the JSON tool schema would
// corrupt the request. schemas() is an explicit allowlist, so it can't leak.
ctx.tools.register(defineTool({
name: 'present',
description: 'has presenters',
parameters: { x: { type: 'string', required: true } },
async execute() { return [] },
presentCall: args => ({ title: args.x }),
presentResult: (args, result) => ({ title: args.x, content: result.content }),
}))
const schema = ctx.tools.schemas()[0] as unknown as Record<string, unknown>
expect(Object.keys(schema).sort()).toEqual(['description', 'name', 'parameters'])
expect(schema.presentCall).toBeUndefined()
expect(schema.presentResult).toBeUndefined()
expect(schema.execute).toBeUndefined()
})
it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'strict-tool',
description: 'd',
parameters: { x: { type: 'string', required: true } },
strict: true,
async execute() { return [] },
}))
expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true })
})
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -122,6 +155,54 @@ describe('ToolRegistry', () => {
expect(order).toEqual(['first:before', 'second:before', 'second:after', 'first:after'])
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
throw new Error('permission 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: permission hook broke' }],
isError: true,
})
})
it('preserves structured error info when a tools/execute listener throws HarnessError', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => {
throw new HarnessError('denied', 'DENIED')
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toMatchObject({
callId: CallId('c1'),
isError: true,
error: { name: 'HarnessError', code: 'DENIED' },
})
})
it('schemas() snapshots tool schemas instead of exposing registry objects', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const first = ctx.tools.schemas()
const firstParameters = first[0]!.parameters as { properties: Record<string, unknown> }
firstParameters.properties['mutated'] = { type: 'string' }
first[0]!.description = 'mutated'
expect(ctx.tools.schemas()).toEqual([{
name: 'echo',
description: 'echo arguments back',
parameters: { type: 'object', properties: { text: { type: 'string' } } },
}])
})
it('rejects duplicate names and unregisters on fiber dispose (HMR safety)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -814,3 +895,53 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
expect(result.isError).toBe(false)
})
})
describe('defineTool presentation (presentCall / presentResult)', () => {
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
const tool = defineTool({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true }, n: { type: 'number' } },
async execute() { return [{ type: 'text', text: 'ok' }] },
presentCall(args) {
// args is typed { path: string; n?: number } — zero casts.
expectTypeOf(args).toEqualTypeOf<{ path: string; n?: number }>()
return { title: `Open ${args.path}`, kind: 'read', rawInput: args.path }
},
presentResult(args, result) {
return { title: `Opened ${args.path}`, content: result.content }
},
})
expect(tool.presentCall!({ path: '/a', n: 2 })).toEqual({ title: 'Open /a', kind: 'read', rawInput: '/a' })
expect(tool.presentResult!({ path: '/a' }, { content: [{ type: 'text', text: 'x' }], isError: false }))
.toEqual({ title: 'Opened /a', content: [{ type: 'text', text: 'x' }] })
})
it('a tool without presentCall/presentResult leaves them undefined (UI falls back generically)', () => {
const tool = defineTool({
name: 'plain',
description: 'plain',
parameters: { x: { type: 'string', required: true } },
async execute() { return [] },
})
expect(typeof tool.presentCall).toBe('undefined')
expect(typeof tool.presentResult).toBe('undefined')
})
it('presentCall/presentResult validate softly: malformed args return undefined, never throw (display runs on replay)', () => {
const tool = defineTool({
name: 'demo',
description: 'demo',
parameters: { path: { type: 'string', required: true } },
async execute() { return [] },
presentCall: args => ({ title: args.path }),
presentResult: (args, result) => ({ title: args.path, content: result.content }),
})
// Unlike execute (which throws ToolArgsError on a mismatch), the display
// methods soft-validate and fall back to undefined so a UI never crashes
// replaying an old/foreign log entry. The ToolDefinition methods take
// `unknown`, so malformed shapes pass without a cast.
expect(tool.presentCall?.({})).toBeUndefined()
expect(tool.presentResult?.({ wrong: 1 }, { content: [], isError: false })).toBeUndefined()
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../core/agent"
}
]
}

View File

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

View File

@@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../../vendor/cosmokit" },
{ "path": "../../vendor/cordis" },
{ "path": "../../vendor/schemastery" },
{ "path": "../llm" }
]
}

View File

@@ -1,14 +0,0 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib"
},
"include": ["src"],
"references": [
{ "path": "../../vendor/cosmokit" },
{ "path": "../../vendor/cordis" },
{ "path": "../../vendor/schemastery" },
{ "path": "../llm" }
]
}

View File

@@ -1,46 +1,11 @@
# dsh-llm
# llm/ — LLM capability family
Provider-neutral LLM vocabulary and abstract service. This package defines the canonical language spoken by the agent loop, session logs, and every plugin.
The LLM seam and its provider adapters. The interface package (`llm`) owns the abstract service, the content-block vocabulary, and the stream-chunk assembler; the adapters are concrete implementations that register on `ctx.llm`. All **product** packages.
## Service: `LlmService` (ctx key: `llm`)
An adapter registry plus streaming / non-streaming call surfaces. Both call surfaces are interceptable via waterfall events.
### Public API
- `ctx.llm.registerAdapter(models: string[], adapter: LlmAdapter): () => void` Register an adapter for the given model names. Disposed with the calling fiber.
- `ctx.llm.models(): string[]` — model names with a registered adapter.
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas).
- `ctx.llm.streamBlocks(options: GenerateOptions): AsyncIterable<ContentBlock>` Stream as completed content blocks (convenience view).
- `ctx.llm.generate(options: GenerateOptions): Promise<GenerateResult>` One model call, fully assembled.
### Events
| Event | Mode | Purpose |
| Package | Role | ctx key |
|---|---|---|
| `llm/stream` | waterfall | Intercept/wrap every streaming model call (retry, caching, routing) |
| `llm/generate` | waterfall | Intercept/wrap every non-streaming model call |
| `llm/adapter-change` | emit | An adapter was registered or unregistered |
| `llm/` | Abstract LLM service + content-block vocabulary + chunk assembler | `ctx.llm` |
| `llm-deepseek/` | DeepSeek API adapter (hand-rolled fetch/SSE) | (registers on `ctx.llm`) |
| `llm-pi-ai/` | DeepSeek adapter via `@earendil-works/pi-ai` (design twin) | (registers on `ctx.llm`) |
### Extension points
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(models, adapter)` to add a new model provider.
- Wrap `llm/stream` or `llm/generate` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
### Content-block vocabulary (`types.ts`)
Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging.
Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages.
### Classes
- `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`.
- `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. Used by the agent loop (raw chunks for replay
+ assembled for history) and by `streamBlocks()`/`generate()`.
- `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams.
- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response.
### Real adapters
Two adapters implement `LlmAdapter` against this vocabulary, deliberately built on different internals to keep the contract honest (see [the twin LLM adapters](../../docs/rfc/implemented/2026-06-13-twin-llm-adapters.md)): [`@deepseek-ai/dsh-llm-deepseek`](../llm-deepseek) (hand-rolled fetch/SSE) and [`@deepseek-ai/dsh-llm-pi-ai`](../llm-pi-ai) (via `@earendil-works/pi-ai`). The pair pinned down the `StreamChunk` conventions now documented in `types.ts` (usage before finish, raw-string tool arguments, the two sanctioned error paths).
The interface lives at `llm/llm/`; adapters are flat siblings under the group. A new provider adapter joins here and registers on `ctx.llm` without touching the interface. See [twin LLM adapters](../../docs/rfc/implemented/architecture/2026-06-13-twin-llm-adapters.md) for why two adapters exist.

View File

@@ -1,9 +1,10 @@
import { describe, expect, it } from 'vitest'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import type { GenerateResult, Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble, type AssembledResult } from './assemble.ts'
/**
* Real-API e2e for the hand-rolled adapter: V4 Flash + V4 Pro across
@@ -13,19 +14,25 @@ import type { Config } from '@deepseek-ai/dsh-llm-deepseek'
const FLASH = 'deepseek-v4-flash'
const PRO = 'deepseek-v4-pro'
const contexts: Context[] = []
async function harness(model: string, config: Partial<Config> = {}) {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { models: [model], ...config })
return ctx
}
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
function ask(text: string): Message[] {
return [{ role: 'user', content: [{ type: 'text', text }] }]
}
function textOf(result: GenerateResult): string {
function textOf(result: AssembledResult): string {
return result.message.content
.filter(block => block.type === 'text')
.map(block => block.text)
@@ -45,7 +52,7 @@ const weatherTool: ToolSchema = {
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', () => {
it('flash + thinking disabled: plain text generation', async () => {
const ctx = await harness(FLASH, { thinking: 'disabled' })
const result = await ctx.llm.generate({
const result = await assemble(ctx,{
model: FLASH,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,
@@ -59,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
it('flash + thinking enabled (effort high): reasoning blocks + reasoning tokens', async () => {
const ctx = await harness(FLASH, { thinking: 'enabled', reasoningEffort: 'high' })
const result = await ctx.llm.generate({
const result = await assemble(ctx,{
model: FLASH,
messages: ask('Which is larger, 9.11 or 9.8? Answer with just the number.'),
maxTokens: 2000,
@@ -76,7 +83,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
const ctx = await harness(PRO, { thinking: 'enabled', reasoningEffort: effort })
// Turn 1: the model must call the tool (and think before it).
const first = await ctx.llm.generate({
const first = await assemble(ctx,{
model: PRO,
messages: ask('What is the weather in Paris right now? Use the get_weather tool.'),
tools: [weatherTool],
@@ -90,7 +97,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
// Turn 2: send the tool result back WITH the assistant's reasoning
// block in history (the official thinking+tools passback rule).
const second = await ctx.llm.generate({
const second = await assemble(ctx,{
model: PRO,
messages: [
...ask('What is the weather in Paris right now? Use the get_weather tool.'),
@@ -114,7 +121,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('llm-deepseek e2e (real API)', ()
it('pro + thinking disabled: plain generation without reasoning blocks', async () => {
const ctx = await harness(PRO, { thinking: 'disabled' })
const result = await ctx.llm.generate({
const result = await assemble(ctx,{
model: PRO,
messages: ask('Reply with exactly the word: pong'),
maxTokens: 50,

View File

@@ -5,6 +5,7 @@ import { Context } from 'cordis'
import LlmService, { LlmError } from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek'
import { assemble } from './assemble.ts'
/** One scripted behavior for the next request the mock server receives. */
type Behavior =
@@ -90,11 +91,11 @@ async function harness(baseURL: string, config: object = {}) {
}
describe('DeepSeekAdapter against a mock server', () => {
it('streams a text generation end to end through ctx.llm.generate', async () => {
it('streams a text generation end to end through the assembler', async () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url)
const result = await ctx.llm.generate({
const result = await assemble(ctx, {
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
@@ -130,7 +131,7 @@ describe('DeepSeekAdapter against a mock server', () => {
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url, { thinking: 'disabled', reasoningEffort: 'high' })
await ctx.llm.generate({
await assemble(ctx,{
model: 'deepseek-v4-flash',
messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }],
})
@@ -155,15 +156,15 @@ describe('DeepSeekAdapter against a mock server', () => {
}
const server = await mockServer([behavior, behavior, behavior])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(`failed with ${status}`)
await expect(
ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).code),
).resolves.toBe(code)
// The numeric HTTP status is carried on the error for explicit handling.
await expect(
ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
.catch((error: unknown) => (error as LlmError).status),
).resolves.toBe(status)
})
@@ -171,14 +172,14 @@ describe('DeepSeekAdapter against a mock server', () => {
it('keeps the status-line message for JSON error bodies without a message', async () => {
const server = await mockServer([{ kind: 'http-error', status: 500, body: '{"error":{"type":"x"}}' }])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 500/)
})
it('keeps the status-line message for non-JSON error bodies', async () => {
const server = await mockServer([{ kind: 'http-error', status: 502, body: 'Bad Gateway', contentType: 'text/plain' }])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/HTTP 502/)
})
@@ -207,7 +208,7 @@ describe('DeepSeekAdapter against a mock server', () => {
events: ['{"choices":[{"delta":{"content":"par"}}]}'],
}])
const ctx = await harness(server.url)
await expect(ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] }))
await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }))
.rejects.toThrow(/terminated|socket|without \[DONE\]/)
})
@@ -278,7 +279,7 @@ describe('plugin registration and config', () => {
vi.stubEnv('DEEPSEEK_BASE_URL', 'http://env-host:1')
const server = await mockServer([{ kind: 'sse', events: textEvents }])
const ctx = await harness(server.url) // harness passes explicit config
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1) // hit the explicit URL, not env
})
@@ -288,7 +289,7 @@ describe('plugin registration and config', () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', models: ['deepseek-v4-flash'] })
await ctx.llm.generate({ model: 'deepseek-v4-flash', messages: [] })
await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })
expect(server.requests).toHaveLength(1)
})

View File

@@ -0,0 +1,26 @@
/**
* Test helper: drive `ctx.llm.stream()` through a `BlockAssembler` and return
* the assembled message + usage + finish reason. This exercises the same
* streaming path production uses (the loop), rather than a service-level
* one-shot convenience method.
*/
import { BlockAssembler } from '@deepseek-ai/dsh-llm'
import type { Context } from 'cordis'
import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm'
export interface AssembledResult {
message: Message
usage?: TokenUsage
finish: FinishReason
}
export async function assemble(ctx: Context, options: GenerateOptions): Promise<AssembledResult> {
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) assembler.push(chunk)
return {
message: assembler.message(),
...assembler.usage !== undefined ? { usage: assembler.usage } : {},
finish: assembler.finish,
}
}

View File

@@ -47,7 +47,7 @@ describe('translate: text', () => {
))) {
assembler.push(chunk)
}
const result = assembler.result()
const result = { message: assembler.message(), finish: assembler.finish }
expect(result.message.content).toEqual([{ type: 'text', text: 'hi' }])
expect(result.finish).toEqual({ kind: 'stop' })
})

View File

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

View File

@@ -6,10 +6,10 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht
`@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose:
- pi-ai hands back tool-call `arguments` as **parsed objects**; the harness keeps raw JSON strings (re-stringified at `block-end`).
- pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`.
- pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses).
- pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map.
- pi-ai's options omit stop sequences; `GenerateOptions.stop` is injected via its `onPayload` hook.
- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments).
## Config

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