Merge remote-tracking branch 'origin/master' into workflow-vm-to-workerthread

This commit is contained in:
imccyu
2026-07-09 21:34:52 +08:00
214 changed files with 9166 additions and 769 deletions

View File

@@ -11,15 +11,18 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`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 |
| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: the abstract runtime seam for model-written programs + a worker-thread backend | Product — stable surface |
| [`fs/`](fs/README.md) | Filesystem capability family: the abstract seam, a local impl, and the model-facing file tools | Product — stable surface |
| [`compact/`](compact/README.md) | Compaction capability family: the abstract seam + a basic backend (tool deferred) | Product — stable surface |
| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface |
| [`workflow/`](workflow/README.md) | Workflow capability family: the script-engine seam, the worker-thread engine, and the model-facing `workflow` tool | Product — stable surface |
| [`web/`](web/README.md) | Web capability family: the abstract seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool (whole-list task tracking on the session log) | Product — stable surface |
| [`timeout/`](timeout/README.md) | Tool-call timeout policy: the `tools/execute` deadline enforcer | Product — stable surface |
| [`todo/`](todo/README.md) | Todo/planning family: the model-facing `todo_write` tool | Product — stable surface |
| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders | Product — stable surface |
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) + the app packages | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, app packages, user-interaction seam, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
@@ -29,6 +32,6 @@ The split is the point: a package's group says whether it is part of the product
The inter-package dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)).
Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs).

View File

@@ -23,6 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
@@ -30,6 +31,7 @@
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -18,6 +18,7 @@ import { Context } from 'cordis'
import z from 'schemastery'
import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash'
import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash'
import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
@@ -114,8 +115,12 @@ 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)
const timeoutMs = clampTimeout(
request.timeoutMs,
this.config.timeoutMs,
this.config.maxTimeoutMs,
'bash-local: request.timeoutMs',
)
return {
command: request.command,
workdir: request.workdir ?? this.config.cwd ?? process.cwd(),
@@ -132,29 +137,39 @@ export class LocalBashExecutor extends BashExecutor {
}
async run(spec: BashExecSpec): Promise<BashRunResult> {
// One fused deadline drives both the timeout and upstream cancellation;
// runBash listens on d.signal and runs the SIGTERM→grace→SIGKILL kill.
// `using` clears the timer across the awaited process lifetime.
using d = deadline(spec.signal, spec.timeoutMs, 'BASH_TIMEOUT')
const outcome = await runBash({
command: spec.command,
cwd: spec.workdir,
timeoutMs: spec.timeoutMs,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
signal: d.signal,
stdin: spec.stdin,
env: spec.env,
}, this.internals).done
return { ...outcome, timeoutMs: spec.timeoutMs }
// Classify the FIRST abort reason: a BASH_TIMEOUT TimeoutReason means our
// timeout cut the command short; any other abort — an upstream cancel, or a
// foreign (outer) deadline's timeout under nesting — is aborted. Scoping to
// our own code keeps a nested outer deadline from reading as our timeout.
// Mutually exclusive by construction — the fused signal reports one cause.
const timedOut = timeoutOf(d.signal, 'BASH_TIMEOUT') !== undefined
const aborted = d.signal.aborted && !timedOut
return { ...outcome, timedOut, aborted, timeoutMs: spec.timeoutMs }
}
start(spec: BashExecSpec): BashTask {
// No timeout for background tasks (matches Claude Code, which detaches
// the timeout when backgrounding); callers stop tasks via kill() — or
// via spec.signal, which the seam contract honors for background runs
// too (runBash wires it to the group kill). spec.timeoutMs is ignored
// here by design.
// too (runBash wires it to the group kill). No deadline is created here,
// so spec.timeoutMs is ignored by design — background tasks stay
// timeout-free (see the timeout-library RFC).
const running = runBash({
command: spec.command,
cwd: spec.workdir,
timeoutMs: 0,
maxOutputBytes: this.config.maxOutputBytes,
graceMs: this.config.graceMs,
signal: spec.signal,
@@ -174,8 +189,10 @@ export class LocalBashExecutor extends BashExecutor {
stdoutOffset: 0,
stderrOffset: 0,
done: running.done.then((outcome) => {
// Abort-killed tasks report as killed, not completed.
if (task.status === 'running') task.status = outcome.aborted ? 'killed' : 'completed'
// Abort-killed tasks report as killed, not completed. Background runs
// forward only the upstream signal (no timeout), so its aborted state
// is the authoritative "was this cancelled" signal.
if (task.status === 'running') task.status = spec.signal?.aborted === true ? 'killed' : 'completed'
task.exitCode = outcome.exitCode
task.signal = outcome.signal
this.notifyTaskDone(task)

View File

@@ -6,6 +6,12 @@
* Everything here is deliberately free of Cordis concepts so it can be unit
* tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
*
* runBash owns NO timing: it kills the process group when its `spec.signal`
* fires and does not distinguish a timeout from a cancel. The executor fuses
* timeout + upstream cancellation into that one signal via
* `@deepseek-ai/dsh-timeout`'s `deadline`, and classifies the outcome from the
* signal afterward — the timing/classification half is shared, the kill is not.
*
* Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
* the package README): spawn-per-call with `detached: true` so the child
* leads its own process group; kills target the group (`kill(-pid)`) so
@@ -71,13 +77,17 @@ export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
export interface SpawnSpec {
command: string
cwd: string
/** Kill the process group after this many milliseconds. 0 = no timeout. */
timeoutMs: number
/** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
maxOutputBytes: number
/** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
graceMs: number
/** Abort signal — kills the process group when fired. */
/**
* Abort signal — kills the process group when it fires. The executor owns
* timing: `run()` passes a fused timeout/cancel deadline signal (see
* `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
* runBash only listens and kills; it does NOT classify why (the executor
* reads the signal's reason afterward).
*/
signal?: AbortSignal | undefined
/**
* Bytes to write to the child's stdin, then close it. Absent (or empty)
@@ -94,12 +104,15 @@ export interface SpawnSpec {
env?: Record<string, string> | undefined
}
/** Raw outcome of one closed process (before result shaping). */
/**
* Raw outcome of one closed process (before result shaping). Deliberately
* carries NO timeout/cancel classification: runBash kills on abort but does not
* decide why — the executor's `run()`/`start()` reads the deadline signal it
* owns to classify `timedOut`/`aborted` (see the package README).
*/
export interface SpawnOutcome {
exitCode: number | null
signal: NodeJS.Signals | null
timedOut: boolean
aborted: boolean
stdout: CollectedOutput
stderr: CollectedOutput
}
@@ -343,9 +356,6 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })
let timedOut = false
let aborted = false
let killTimer: NodeJS.Timeout | undefined
let graceTimer: NodeJS.Timeout | undefined
// pid is undefined when the spawn itself fails (bad cwd, missing binary);
@@ -358,17 +368,12 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
}
if (spec.timeoutMs > 0) {
killTimer = setTimeout(() => {
timedOut = true
kill()
}, spec.timeoutMs)
}
const onAbort = (): void => {
aborted = true
kill()
}
// runBash owns no timer: the executor's `run()` fuses timeout+cancel into one
// deadline signal (`@deepseek-ai/dsh-timeout`) and passes it here; we only
// listen and run the SIGTERM→grace→SIGKILL kill. Whether the abort was a
// timeout or an upstream cancel is classified by the executor from that
// signal, not tracked here.
const onAbort = (): void => { kill() }
spec.signal?.addEventListener('abort', onAbort, { once: true })
// Write stdin and close it, but ONLY when the caller supplied bytes — with no
@@ -401,14 +406,11 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB
resolve({
exitCode,
signal,
timedOut,
aborted,
stdout: stdout.finalize(),
stderr: stderr.finalize(),
})
})
function cleanup(): void {
if (killTimer !== undefined) clearTimeout(killTimer)
if (graceTimer !== undefined) clearTimeout(graceTimer)
spec.signal?.removeEventListener('abort', onAbort)
}

View File

@@ -40,12 +40,14 @@ async function readUntil(
): Promise<BashTaskRead> {
const deadline = Date.now() + timeoutMs
let last: BashTaskRead | undefined
let delta = ''
while (Date.now() < deadline) {
last = bash.readOutput(id)
if (last.delta.includes(expected)) return last
delta += last.delta
if (delta.includes(expected)) return { ...last, delta }
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; last delta was ${JSON.stringify(last?.delta ?? '')}`)
throw new Error(`task ${id} output did not include ${JSON.stringify(expected)}; output was ${JSON.stringify(delta)}, last delta was ${JSON.stringify(last?.delta ?? '')}`)
}
describe('LocalBashExecutor.run', () => {
@@ -90,8 +92,8 @@ describe('LocalBashExecutor.run', () => {
it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => {
const { bash } = await setup() // setup pins graceMs: 200 via config
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' }))
await new Promise(resolve => setTimeout(resolve, 100))
const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done' }))
await readUntil(bash, task.id, 'ready\n')
bash.kill(task.id)
await task.done
expect(task.signal).toBe('SIGKILL')
@@ -101,6 +103,8 @@ describe('LocalBashExecutor.run', () => {
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 }))
expect(result.timedOut).toBe(true)
// Mutually exclusive: a timeout classifies as timedOut, never also aborted.
expect(result.aborted).toBe(false)
expect(result.timeoutMs).toBe(100)
})
@@ -111,6 +115,20 @@ describe('LocalBashExecutor.run', () => {
setTimeout(() => { controller.abort() }, 50)
const result = await pending
expect(result.aborted).toBe(true)
// Mutually exclusive: an upstream cancel classifies as aborted, never also timedOut.
expect(result.timedOut).toBe(false)
})
it('classifies a self-killed command as neither timed out nor aborted', async () => {
// The command kills itself (SIGTERM) with no timeout and no upstream abort:
// the deadline signal never fires, so both classifications are false — the
// fused-signal classification reports the cause that cut the command short,
// and here nothing the executor owns did.
const { bash } = await setup({ timeoutMs: 60_000 })
const result = await bash.run(bash.resolve({ command: 'kill -TERM $$' }))
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
it('rejects on spawn failure (bad workdir)', async () => {

View File

@@ -26,7 +26,6 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]>
return {
command,
cwd: process.cwd(),
timeoutMs: 0,
maxOutputBytes: 64_000,
graceMs: 3_000,
...overrides,
@@ -56,13 +55,25 @@ async function waitForStdout(running: RunningBash, expected: string, timeoutMs =
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
}
async function waitForPidFile(path: string, timeoutMs = 5_000): Promise<number> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
try {
const pid = Number(readFileSync(path, 'utf8').trim())
if (Number.isSafeInteger(pid) && pid > 0) return pid
} catch {
// The child shell has not written the pid file yet.
}
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`pid file ${path} was not written after ${timeoutMs}ms`)
}
describe('runBash', () => {
it('captures stdout on success', async () => {
const result = await runBash(spec('echo hello')).done
expect(result.exitCode).toBe(0)
expect(result.signal).toBeNull()
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
expect(result.stdout.text).toBe('hello\n')
expect(result.stdout.truncated).toBe(false)
expect(result.stderr.text).toBe('')
@@ -97,17 +108,22 @@ describe('runBash', () => {
expect(result.stdout.text.trim()).toMatch(/\/tmp$/)
})
it('kills with SIGTERM on timeout', async () => {
it('kills the process group with SIGTERM when the signal fires', async () => {
// runBash owns no timer: it kills on abort. The executor drives the timeout
// by firing this signal via a deadline (see executor.spec.ts); here we
// assert the kill itself lands as SIGTERM.
const controller = new AbortController()
const start = Date.now()
const result = await runBash(spec('sleep 60', { timeoutMs: 100 })).done
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('deadline') }, 100)
const result = await running.done
expect(Date.now() - start).toBeLessThan(5_000)
expect(result.timedOut).toBe(true)
expect(result.signal).toBe('SIGTERM')
expect(result.exitCode).toBeNull()
})
it('escalates to SIGKILL when SIGTERM is trapped', async () => {
const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60', { graceMs: 200 }))
const running = runBash(spec('trap \'\' TERM; echo ready; while :; do sleep 60 & wait $!; done', { graceMs: 200 }))
await waitForStdout(running, 'ready\n')
running.kill()
const result = await running.done
@@ -119,8 +135,7 @@ describe('runBash', () => {
// group must take the sleep down with bash.
const pidFile = join(spillDir, `grandchild-${Date.now()}.pid`)
const running = runBash(spec(`sleep 60 & echo $! > ${pidFile}; wait`))
await new Promise(resolve => setTimeout(resolve, 300))
const grandchild = Number(readFileSync(pidFile, 'utf8').trim())
const grandchild = await waitForPidFile(pidFile)
expect(grandchild).toBeGreaterThan(0)
running.kill()
@@ -134,7 +149,6 @@ describe('runBash', () => {
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort('user cancelled') }, 50)
const result = await running.done
expect(result.aborted).toBe(true)
expect(result.signal).toBe('SIGTERM')
})
@@ -211,7 +225,6 @@ describe('stdin and extra env (set by in-process plugins)', () => {
const big = 'x'.repeat(1024 * 1024)
const result = await runBash(spec('exit 7', { stdin: big })).done
expect(result.exitCode).toBe(7)
expect(result.aborted).toBe(false)
})
})
@@ -339,11 +352,11 @@ describe('abort edge cases', () => {
.toThrow(/aborted before spawn: aborted/)
})
it('reports an externally self-killed command without the timeout marker', async () => {
it('reports the terminating signal of an externally self-killed command', async () => {
// runBash reports the raw signal; whether it counts as timeout/cancel is the
// executor's classification (a self-kill is neither) — see executor.spec.ts.
const result = await runBash(spec('kill -TERM $$')).done
expect(result.signal).toBe('SIGTERM')
expect(result.timedOut).toBe(false)
expect(result.aborted).toBe(false)
})
})
@@ -396,10 +409,9 @@ describe('review fixes: env scrubbing and spill hardening', () => {
it('honors AbortSignal on background-style runs (no timeout)', async () => {
const controller = new AbortController()
const running = runBash(spec('sleep 60', { timeoutMs: 0, signal: controller.signal }))
const running = runBash(spec('sleep 60', { signal: controller.signal }))
setTimeout(() => { controller.abort() }, 50)
const result = await running.done
expect(result.aborted).toBe(true)
expect(result.signal).toBe('SIGTERM')
})
})

View File

@@ -20,6 +20,9 @@
{
"path": "../../util/brand"
},
{
"path": "../../util/timeout"
},
{
"path": "../../bash/bash"
}

View File

@@ -0,0 +1,10 @@
# code-runtime/ — code-execution capability family
The code-execution capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): an abstract runtime interface for executing one model-written program against host-provided async bindings, capturing what it printed and returned. The consumer is the tool registry's Code Mode, specified alongside the seam in the [Code Mode RFC](../../docs/rfc/proposed/feature/2026-06-15-code-mode.md). **Product** packages.
| Package | Role | ctx key |
|---|---|---|
| `code-runtime/` | Abstract code-execution seam (interface + vocabulary) | `ctx.codeRuntime` |
| [`code-runtime-worker/`](code-runtime-worker/README.md) | Worker-thread backend: fresh worker per run, TypeScript via host-side type-strip (annotations advisory, never type-checked), port-bridged bindings, budget/heap containment | registers `ctx.codeRuntime` |
The interface lives at `code-runtime/code-runtime/`; the shipped backend at `code-runtime/code-runtime-worker/`. Backends differ by execution substrate (worker thread, process, container) and by source language — both readonly descriptors on the service — and register `ctx.codeRuntime` without touching the interface or its consumer; that split is what makes a hardened backend a drop-in later.

View File

@@ -0,0 +1,32 @@
# @deepseek-ai/dsh-code-runtime-worker
Worker-thread implementation of the [`@deepseek-ai/dsh-code-runtime`](../code-runtime/README.md) seam: `WorkerCodeRuntime` runs each program in ONE fresh Node `worker_threads.Worker` — TypeScript in, type-stripped host-side, bindings bridged over the message port, `{ value, logs, error? }` out. **Containment, not a security boundary**: trust posture is bash-equivalent by design (the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md) § Trust posture), with containment bash does not have — separate isolate, empty environment, heap cap, hard termination.
## Config
```yaml
- id: code-runtime
name: '@deepseek-ai/dsh-code-runtime-worker'
config:
computeMs: 60000 # busy-time budget (measured event-loop active time)
maxWallMs: 600000 # wall-clock ceiling; never pauses for anything
maxLogBytes: 65536 # shared byte budget for captured log text
maxValueBytes: 32768 # rendered-completion-value cap
maxOldGenerationSizeMb: 512 # worker heap cap (resourceLimits)
```
Every field is validated (positive numbers) and defaulted; there are no other tunables.
## Design
- **One fresh worker per run, no pooling** — a program's world dies with its worker: no cross-run state to log, state bleed unrepresentable, runs reconstructable from the session log alone.
- **Type-strip host-side, in execution context** — the program is wrapped in an async-function shell, stripped with `node:module`'s `stripTypeScriptTypes` (erasable syntax only — `enum`/namespaces are rejected as a program `exception` and no worker spawns), and sliced back out byte-positioned; it then executes as the body of an `AsyncFunction`, so top-level `await`/`return` work.
- **The port assumes a hostile peer** — model code can reach `parentPort` and forge traffic, so every inbound message is shape-validated and REBUILT before anything reads it (`null`, primitives, junk types, and malformed payloads drop without a throw; forged extra fields never ride along), the host answers each call id at most once, resolves binding names as OWN properties only (a forged `constructor` cannot walk a prototype chain), drops post-settlement replies, and converts a non-cloneable binding resolution into an error reply. Forged `log`/`done` messages cannot bypass the caps: one host-side ledger bounds everything that lands in `logs`, and the completion value is re-capped host-side. Worker-side namespaces are null-prototype with `defineProperty`, so `__proto__`-shaped binding names are ordinary keys.
- **Two independent budgets, because the peer is hostile** — `computeMs` meters the worker's MEASURED busy time (`worker.performance.eventLoopUtilization()` polling): a hot loop cannot hide behind a pending decoy dispatch, and a program awaiting a slow tool accrues nothing. `maxWallMs` backstops what busy time cannot see (awaiting a promise nobody resolves). Both funnel into `worker.terminate()`, which ends hot synchronous loops too; heap overflow surfaces as the worker's OOM exit (`kind: 'worker-exit'`).
- **Logs stream eagerly** — console/stdout/stderr entries cross the port as they happen, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed entries, forged port traffic, and pipe bytes that bypass the patched streams (appended after), with the overflow marked in-band once.
- **Empty environment** — the worker gets `env: {}` and `execArgv: []`: no ambient credentials (stronger than the scrubbed-env rule for spawned commands) and no inherited loader flags.
- **Dispose to quiescence** — teardown fails in-flight runs as `abort` and AWAITS each worker's exit before resolving.
## The worker entry, unbuilt and built
`worker.ts` is deliberately erasable-only TypeScript with type-only cross-package imports: unbuilt (vitest/tsx), the host spawns `src/worker.ts` directly and Node's native type stripping loads it; built, the entry ships as the sibling bundle `lib/worker.js` (its own tsdown entry). The built path is pinned by `tests/built-lib.e2e.ts`, the real-load-path guard from [docs/testing.md](../../../docs/testing.md).

View File

@@ -0,0 +1,40 @@
{
"name": "@deepseek-ai/dsh-code-runtime-worker",
"description": "Worker-thread implementation of the DeepSeek Harness code-execution seam",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./worker": {
"types": "./lib/types/worker.d.ts",
"default": "./lib/worker.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/worker.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,301 @@
/**
* Worker-side execution logic, written as plain functions over an injected
* port so the unit suite can run every line IN-PROCESS against a fake port
* (a real worker thread is a separate V8 isolate the coverage provider
* cannot observe). The real worker entry (`worker.ts`) is a thin
* self-executing glue file over {@link runWorkerMain}, excluded from
* coverage the same way `bin.ts` entrypoints are, and exercised end-to-end
* by the integration tests that spawn real workers.
*
* @module @deepseek-ai/dsh-code-runtime-worker/src/bootstrap
*/
import { inspect } from 'node:util'
import { serialize } from 'node:v8'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
import { logTruncationMarker } from './protocol.ts'
import type { DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** The port surface the bootstrap needs — satisfied by `parentPort` and by the tests' fake. */
export interface BootstrapPort {
postMessage(message: WorkerToHost): void
on(event: 'message', listener: (message: ReplyMessage) => void): void
}
/**
* A writable stream's `write` slot, as the bootstrap patches it (see
* {@link captureStreamWrites}). Method-typed so the real
* `process.stdout`/`process.stderr` (narrower chunk parameters) remain
* assignable.
*/
export interface PatchableStream {
write(chunk: unknown, ...rest: unknown[]): boolean
}
/**
* Ordered log capture under one shared byte budget, delivered to a sink as
* each entry lands (the real sink streams entries over the port eagerly, so
* captured output survives a mid-run termination). Once the budget is
* exhausted it emits exactly one in-band marker entry (on the `stderr`
* diagnostics channel) and silently drops everything after — the cap is a
* blast-radius bound, so "how much was lost" intentionally stays unmeasured.
*/
export class LogBuffer {
private remaining: number
private truncated = false
// Explicit fields, not constructor parameter properties: this module loads
// under Node's native strip-only mode, which rejects non-erasable syntax —
// and parameter properties are non-erasable.
private readonly maxBytes: number
private readonly sink: (entry: CodeLogEntry) => void
constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) {
this.maxBytes = maxBytes
this.sink = sink
this.remaining = maxBytes
}
/**
* Emit one entry to the sink, charging its text against the budget (drops + marks once exhausted).
* @param entry - the log entry to deliver.
*/
push(entry: CodeLogEntry): void {
if (this.truncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
if (cost > this.remaining) {
this.truncated = true
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) })
return
}
this.remaining -= cost
this.sink(entry)
}
}
/** The five console methods the shim captures, in the seam's level vocabulary. */
const CONSOLE_LEVELS = ['log', 'info', 'warn', 'error', 'debug'] as const
/**
* A `console` replacement whose five leveled methods render their arguments
* `util.inspect`-style (matching real console formatting closely enough for
* a model to recognize its own output) into the buffer. Only these five
* exist — the program gets a deliberately small console, not Node's full
* surface.
* @param logs - the buffer every rendered line is pushed into.
* @returns the five-method console object handed to the program.
*/
export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void> {
const render = (args: unknown[]): string =>
args.map(arg => typeof arg === 'string' ? arg : inspect(arg, INSPECT_OPTIONS)).join(' ')
const shim = Object.create(null) as Record<(typeof CONSOLE_LEVELS)[number], (...args: unknown[]) => void>
for (const level of CONSOLE_LEVELS) {
shim[level] = (...args: unknown[]) => { logs.push({ source: 'console', level, text: render(args) }) }
}
return shim
}
/**
* Redirect a stream's `write` into the log buffer (the program-visible
* `process.stdout`/`process.stderr` in the real worker), so raw writes land
* in emission order alongside console output instead of racing down a pipe.
* The shim keeps Node's `write(chunk[, encoding][, callback])` contract: the
* callback fires asynchronously once the chunk is admitted (a program
* awaiting flush completion must complete, not sit until the wall timeout),
* even for writes the exhausted budget drops.
* @param logs - the buffer captured writes are pushed into.
* @param stream - the stream whose `write` slot is patched.
* @param source - the log source the captured writes are attributed to.
* @returns the restore function (the in-process tests un-patch; the real
* worker never needs to).
*/
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream, source: 'stdout' | 'stderr'): () => void {
// The slot's VALUE is stored for restore and reassigned — never invoked
// detached, so the unbound-method concern does not apply.
// eslint-disable-next-line @typescript-eslint/unbound-method
const original = stream.write
stream.write = (chunk: unknown, ...rest: unknown[]): boolean => {
logs.push({ source, text: typeof chunk === 'string' ? chunk : String(chunk) })
// Node's optional-encoding shape: the callback is whichever of the next
// two positions holds a function (a non-function there is the encoding).
const callback = [rest[0], rest[1]].find(
(arg): arg is (error?: Error | null) => void => typeof arg === 'function',
)
if (callback) queueMicrotask(() => { callback(null) })
return true
}
return () => { stream.write = original }
}
/** Bounded inspect options: deep enough to be useful, bounded so a pathological value cannot explode the rendering. */
const INSPECT_OPTIONS = { depth: 4, maxArrayLength: 100, maxStringLength: 10_000 } as const
/**
* The longest prefix of `text` whose UTF-8 encoding fits `maxBytes`, cut at
* a code-point boundary (never mid-surrogate-pair). The byte caps are BYTE
* caps — `String.prototype.slice` counts UTF-16 code units, up to 3× smaller
* than what a multibyte string actually costs across the boundary.
* @param text - the string to bound.
* @param maxBytes - the UTF-8 byte budget the prefix must fit.
* @returns the prefix (all of `text` when it already fits).
*/
export function truncateUtf8Bytes(text: string, maxBytes: number): string {
if (Buffer.byteLength(text, 'utf8') <= maxBytes) return text
let bytes = 0
let end = 0
for (const char of text) {
const cost = Buffer.byteLength(char, 'utf8')
if (bytes + cost > maxBytes) break
bytes += cost
end += char.length
}
return text.slice(0, end)
}
/**
* Prepare the program's completion value for the done message: a value whose
* MEASURED cross-boundary size fits `maxValueBytes` crosses raw — exact
* bytes for a string, the structured-clone wire size (`v8.serialize`) for
* everything else, so a huge container whose BOUNDED inspect rendering
* happens to be small cannot smuggle itself past the cap. Anything else
* (non-cloneable, or oversized) is REPLACED by its bounded `util.inspect`
* rendering, byte-truncated ({@link truncateUtf8Bytes}) with an in-band
* marker — the seam contract's "a non-transferable value is replaced by a
* string rendering", extended to oversized ones so a huge return cannot
* flood the host.
* @param value - the program's completion value.
* @param maxValueBytes - the byte cap for the value.
* @returns the done-message fragment: `{}` for `undefined`, else `{ value }`.
*/
export function prepareValue(value: unknown, maxValueBytes: number): { value?: unknown } {
if (value === undefined) return {}
if (typeof value === 'string') {
if (Buffer.byteLength(value, 'utf8') <= maxValueBytes) return { value }
} else {
let size: number | undefined
try {
size = serialize(value).byteLength
} catch {
// Only the verdict matters: the value has parts the structured-clone
// algorithm rejects (functions, classes, …) and must cross as its
// rendering instead.
size = undefined
}
if (size !== undefined && size <= maxValueBytes) return { value }
}
const rendered = typeof value === 'string' ? value : inspect(value, INSPECT_OPTIONS)
const capped = Buffer.byteLength(rendered, 'utf8') > maxValueBytes
? `${truncateUtf8Bytes(rendered, maxValueBytes)}… [truncated]`
: rendered
return { value: capped }
}
/** One awaited binding call's settlement handles, keyed by call id in the pending map. */
export interface PendingCall {
resolve(value: unknown): void
reject(error: Error): void
}
/**
* Route host replies into the pending-call map: each reply settles its call
* at most once, and a reply for an unknown id (stray, or a duplicate answer
* to an id already settled) is ignored. Shared wiring between
* {@link runWorkerMain} and the tests that exercise {@link makeNamespaces}
* standalone.
* @param port - the port whose `message` events carry the replies.
* @param pending - the id-keyed map of unsettled binding calls.
*/
export function wireReplies(port: BootstrapPort, pending: Map<number, PendingCall>): void {
port.on('message', (message: ReplyMessage) => {
const entry = pending.get(message.id)
if (!entry) return
pending.delete(message.id)
if (message.ok) entry.resolve(message.value)
else entry.reject(new Error(message.message))
})
}
/**
* Build the binding namespace objects the program sees: one null-prototype
* global per namespace, each declared name an own enumerable async function
* that bridges over the port (`__proto__`/`constructor`/`toString` are
* ordinary keys, never prototype collisions). A non-cloneable argument
* rejects that one call with a descriptive error; the host's reply (`ok`
* false) rejects it likewise, so a failed tool call surfaces in the program
* as an ordinary promise rejection.
* @param data - the boot payload's namespace declarations (globals + names).
* @param port - the port binding calls are posted to.
* @param pending - the id-keyed map each posted call parks its handles in.
* @param nextId - the shared mutable id counter (worker-issued correlation ids).
* @returns one namespace object per declaration, in declaration order.
*/
export function makeNamespaces(
data: Pick<WorkerBootData, 'namespaces'>,
port: BootstrapPort,
pending: Map<number, PendingCall>,
nextId: { value: number },
): Record<string, unknown>[] {
return data.namespaces.map(({ global, names }) => {
const namespace = Object.create(null) as Record<string, unknown>
for (const name of names) {
Object.defineProperty(namespace, name, {
enumerable: true,
value: (args: unknown): Promise<unknown> => new Promise((resolve, reject) => {
const id = nextId.value++
pending.set(id, { resolve, reject })
try {
port.postMessage({ type: 'call', id, global, name, args })
} catch (error: unknown) {
pending.delete(id)
reject(new Error(`binding arguments must be structured-cloneable: ${error instanceof Error ? error.message : String(error)}`))
}
}),
})
}
return namespace
})
}
/**
* Run one program to settlement and post the {@link DoneMessage}: wires the
* reply handler, materializes the namespaces and console shim, compiles the
* type-stripped body as an async function (top-level `await`/`return`
* work), and reports a thrown program error as the done message's `error`
* field. Exactly one done message is ever posted.
* @param port - the message port to the host (the real `parentPort`, or the tests' fake).
* @param data - the boot payload the host sent.
* @param streams - the stream objects whose `write` is captured (the real
* `process.stdout`/`process.stderr` in the worker; fakes in tests).
* @returns resolves after the done message is posted (the tests await it;
* the real entry lets the worker exit naturally).
*/
export async function runWorkerMain(
port: BootstrapPort,
data: WorkerBootData,
streams: { stdout: PatchableStream; stderr: PatchableStream },
): Promise<void> {
const logs = new LogBuffer(data.maxLogBytes, (entry) => { port.postMessage({ type: 'log', entry }) })
captureStreamWrites(logs, streams.stdout, 'stdout')
captureStreamWrites(logs, streams.stderr, 'stderr')
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const nextId = { value: 1 }
const namespaces = makeNamespaces(data, port, pending, nextId)
const consoleShim = makeConsoleShim(logs)
let done: DoneMessage
try {
// The async function constructor, reached through an instance because
// `AsyncFunction` is not a global. The program body is strict-mode.
/* v8 ignore next -- the arrow exists only to reach the AsyncFunction constructor; it is never invoked. */
const AsyncFunction = (async () => {}).constructor as new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>
const fn = new AsyncFunction(...data.namespaces.map(namespace => namespace.global), 'console', `'use strict';\n${data.code}`)
const value = await fn(...namespaces, consoleShim)
done = { type: 'done', ...prepareValue(value, data.maxValueBytes) }
} catch (error: unknown) {
const message = error instanceof Error ? error.stack ?? error.message : String(error)
done = { type: 'done', error: { message } }
}
port.postMessage(done)
}

View File

@@ -0,0 +1,441 @@
/**
* Worker-thread implementation of the code-execution seam: one fresh Node
* worker per run, executing the model's TypeScript after a host-side
* type-strip, with bindings bridged over the message port. Containment, not
* a security boundary (bash-equivalent trust — see the Code Mode RFC's
* trust-posture section): the worker gets an EMPTY environment, a heap cap,
* and two independent budgets — `computeMs` metered on the worker's
* measured event-loop busy time (a hot loop cannot hide behind a pending
* binding call) and a never-pausing `maxWallMs` ceiling — all funneling
* into `worker.terminate()`, which ends hot synchronous loops too.
*
* @module @deepseek-ai/dsh-code-runtime-worker
*/
import { Worker } from 'node:worker_threads'
import { stripTypeScriptTypes } from 'node:module'
import { Context } from 'cordis'
import z from 'schemastery'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeBindingFunction, CodeLogEntry, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import { prepareValue, truncateUtf8Bytes } from './bootstrap.ts'
import { logTruncationMarker } from './protocol.ts'
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
export type { BootstrapPort, PatchableStream } from './bootstrap.ts'
export type { CallMessage, DoneMessage, ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
export interface Config {
/**
* Busy-time budget in milliseconds: the run fails with kind `'timeout'`
* once the worker's MEASURED event-loop active time
* (`worker.performance.eventLoopUtilization()`) exceeds this. Metering
* measured busy time — not wall time, not host-side pending-call
* bookkeeping — is what makes the budget both fair (a program awaiting a
* slow tool accrues nothing) and ungameable (a hot loop accrues whether
* or not a decoy dispatch is in flight).
*/
computeMs?: number
/**
* Wall-clock ceiling in milliseconds; never pauses for anything. The
* backstop for what busy-time cannot see (a program awaiting a promise
* nobody will resolve).
*/
maxWallMs?: number
/** Shared byte budget for captured log text (console + raw stream writes), truncation marked in-band. */
maxLogBytes?: number
/**
* Byte cap for the completion value, measured by its real cross-boundary
* size (string bytes, or structured-clone wire size); an oversized or
* non-cloneable value crosses as a capped string rendering.
*/
maxValueBytes?: number
/** The worker's max old-generation heap in MiB (`resourceLimits`); overflow kills the worker, surfacing as kind `'worker-exit'`. */
maxOldGenerationSizeMb?: number
}
/** {@link Config} after schemastery fills the defaults (every field present). */
type ResolvedConfig = Required<Config>
/**
* How often the host samples the worker's event-loop utilization for the
* `computeMs` budget. An internal cadence, not config: the only effect of
* the interval is budget-expiry granularity (a run can overshoot by up to
* one interval), and nothing a deployment could tune here improves that
* without burning host CPU.
*/
const ELU_POLL_INTERVAL_MS = 25
/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */
const RESERVED_WORDS = new Set([
'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do',
'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in',
'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof',
'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package',
'private', 'protected', 'public', 'arguments', 'eval',
])
/** Valid async-function parameter name (the binding global becomes one). */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
/**
* The shell a program is wrapped in for the type-strip, matching the
* grammatical context it will execute in (an async function body, where
* top-level `return` and `await` are legal — a bare module parse would
* reject the `return`). Strip mode is position-preserving (removed syntax
* becomes whitespace, nothing shifts), so the wrapper survives the strip
* byte-identical and the body slices back out with the model's own
* line/column positions intact.
*/
const STRIP_WRAP = { prefix: 'async function __dsh_program__() {\n', suffix: '\n}' } as const
/** One in-flight run's host-side state, tracked for disposal. */
interface LiveRun {
worker: Worker
settle(failure: CodeRunFailure): void
finished: Promise<void>
}
/**
* The worker entry module. Source runs unbuilt (`src/worker.ts`, loadable
* directly on this repo's Node range via native type stripping — the file
* is erasable-only with type-only relative imports); the built package
* ships it as a sibling bundle (`lib/worker.js`, its own tsdown entry).
* The URL *pathname*'s extension says which world this module is in —
* pathname, because dev-time module runners (vitest) may suffix
* `import.meta.url` with a query string; relative resolution drops it.
*/
/* v8 ignore next -- the './worker.js' arm is the built-lib world, unreachable unbuilt by construction; the built-lib e2e pins it. */
const WORKER_URL = new URL(new URL(import.meta.url).pathname.endsWith('.ts') ? './worker.ts' : './worker.js', import.meta.url)
/** Render an unknown thrown value as a message, `Error` or not. */
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
/** The log sources / console levels the seam vocabulary admits, as runtime sets for inbound-message validation. */
const LOG_SOURCES = new Set<string>(['console', 'stdout', 'stderr'])
const LOG_LEVELS = new Set<string>(['log', 'info', 'warn', 'error', 'debug'])
/**
* Runtime shape gate for inbound port traffic. The peer runs MODEL CODE and
* can post anything — `null`, primitives, objects with poisoned fields — so
* the compile-time `WorkerToHost` type means nothing here: everything is
* re-validated and REBUILT field by field (a forged extra field never rides
* along; a non-number call id can never be echoed into a reply). Junk returns
* `undefined` and is dropped — a throw in the host's `message` listener would
* crash the host process.
*/
function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
if (typeof raw !== 'object' || raw === null) return undefined
const m = raw as Record<string, unknown>
switch (m.type) {
case 'call': {
if (typeof m.id !== 'number' || typeof m.global !== 'string' || typeof m.name !== 'string') return undefined
return { type: 'call', id: m.id, global: m.global, name: m.name, args: m.args }
}
case 'log': {
const entry = m.entry
if (typeof entry !== 'object' || entry === null) return undefined
const e = entry as Record<string, unknown>
if (typeof e.text !== 'string') return undefined
if (typeof e.source !== 'string' || !LOG_SOURCES.has(e.source)) return undefined
if (e.level !== undefined && (typeof e.level !== 'string' || !LOG_LEVELS.has(e.level))) return undefined
return {
type: 'log',
entry: {
source: e.source as CodeLogEntry['source'],
...e.level !== undefined ? { level: e.level as Exclude<CodeLogEntry['level'], undefined> } : {},
text: e.text,
},
}
}
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
const error = m.error
if (typeof error !== 'object' || error === null) return undefined
const message = (error as Record<string, unknown>).message
if (typeof message !== 'string') return undefined
return { type: 'done', ...m.value !== undefined ? { value: m.value } : {}, error: { message } }
}
default: return undefined
}
}
/**
* Headroom the host's value re-cap grants over `maxValueBytes`: exactly the
* truncation suffix {@link prepareValue} appends, so a value the WORKER
* already capped (byte-exact prefix + this marker) passes through unchanged
* instead of being marked twice.
*/
const VALUE_RENDER_SLACK = Buffer.byteLength('… [truncated]', 'utf8')
/**
* The shipped {@link CodeRuntime} backend (`ctx.codeRuntime`). Registers as
* the `codeRuntime` service; every cap comes from validated config. See the
* module doc for the containment model and the class JSDoc on the seam for
* the contract this implements (error-as-field, hostile-peer port,
* no cross-run state, dispose to quiescence).
*/
export class WorkerCodeRuntime extends CodeRuntime {
static Config: z<Config> = z.object({
computeMs: z.number().default(60_000),
maxWallMs: z.number().default(600_000),
maxLogBytes: z.number().default(65_536),
maxValueBytes: z.number().default(32_768),
maxOldGenerationSizeMb: z.number().default(512),
})
readonly language = 'typescript'
readonly isolation = 'worker-thread'
private readonly config: ResolvedConfig
private readonly live = new Set<LiveRun>()
private disposed = false
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery filled the defaults; the cast records that. Positivity is a
// semantic check the schema's plain number type does not carry.
this.config = config as ResolvedConfig
for (const [key, value] of Object.entries(this.config)) {
if (!(Number.isFinite(value) && value > 0)) throw new Error(`dsh-code-runtime-worker: config.${key} must be a positive number, got ${String(value)}`)
}
ctx.effect(() => () => this.teardown(), 'worker code-runtime teardown')
}
/**
* Dispose to quiescence: mark the service unusable, fail every in-flight
* run as aborted, and AWAIT each worker's exit so no worker outlives the
* fiber.
*/
private async teardown(): Promise<void> {
this.disposed = true
const runs = [...this.live]
for (const run of runs) run.settle({ kind: 'abort', message: 'runtime disposed' })
await Promise.all(runs.map(run => run.finished))
}
/**
* Execute one program in a fresh worker. Program outcomes — including a
* type-strip syntax error, which never spawns a worker — resolve with
* `result.error`; the method rejects only for seam misuse (a disposed
* runtime, an invalid binding namespace).
* @param request - the program, its bindings, and the abort signal.
* @returns the run's outcome per the seam contract.
*/
async run(request: CodeRunRequest): Promise<CodeRunResult> {
if (this.disposed) throw new Error('dsh-code-runtime-worker: run() after disposal')
const bindings = this.validateBindings(request)
if (request.signal?.aborted) {
return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } }
}
let code: string
try {
const stripped = stripTypeScriptTypes(STRIP_WRAP.prefix + request.program + STRIP_WRAP.suffix)
code = stripped.slice(STRIP_WRAP.prefix.length, stripped.length - STRIP_WRAP.suffix.length)
} catch (error: unknown) {
// A program that does not survive the type-strip (syntax error,
// non-erasable syntax like `enum`) is a program failure, reported the
// same way a thrown exception would be — and no worker ever spawns.
return { logs: [], error: { kind: 'exception', message: messageOf(error) } }
}
return await this.execute(request, code, bindings)
}
/** Reject (seam misuse) malformed binding namespaces: non-identifier or reserved globals, duplicates, and the `console` collision. */
private validateBindings(request: CodeRunRequest): Map<string, Record<string, CodeBindingFunction>> {
const bindings = new Map<string, Record<string, CodeBindingFunction>>()
for (const namespace of request.bindings) {
if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`)
}
if (namespace.global === 'console' || bindings.has(namespace.global)) {
throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`)
}
bindings.set(namespace.global, namespace.functions)
}
return bindings
}
/** Spawn the worker for one validated, type-stripped run and drive it to settlement. */
private execute(
request: CodeRunRequest,
code: string,
bindings: Map<string, Record<string, CodeBindingFunction>>,
): Promise<CodeRunResult> {
const bootData: WorkerBootData = {
code,
namespaces: [...bindings].map(([global, functions]) => ({ global, names: Object.keys(functions) })),
maxLogBytes: this.config.maxLogBytes,
maxValueBytes: this.config.maxValueBytes,
}
const worker = new Worker(WORKER_URL, {
workerData: bootData,
// Model code gets NO ambient environment — stronger than the scrubbed
// env the defensive-patterns rule requires for spawned commands.
env: {},
// Hermetic flags too: without this the worker inherits the host
// process's execArgv (a test runner's or tsx's loader hooks), which a
// bare isolate with an empty environment cannot satisfy. The entry
// needs nothing beyond native type stripping, on this repo's whole
// Node range.
execArgv: [],
resourceLimits: { maxOldGenerationSizeMb: this.config.maxOldGenerationSizeMb },
// Backstop capture: the bootstrap patches JS-level writes into its own
// ordered buffer, so these pipes normally stay silent; anything that
// still arrives (native-level writes) is appended after the done logs.
stdout: true,
stderr: true,
})
return new Promise<CodeRunResult>((resolve) => {
let settled = false
const answered = new Set<number>()
const logs: CodeLogEntry[] = []
const strayLogs: CodeLogEntry[] = []
// ONE host-side ledger for everything that lands in `logs`/`strayLogs`,
// whatever the path: honest port entries, FORGED port entries (model
// code posting `log` messages directly, bypassing the worker-side
// LogBuffer), and stray pipe bytes. On the first overflow it emits the
// same in-band marker the worker's LogBuffer would and drops the rest,
// so the documented cap is one shared `maxLogBytes` however it is hit.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
if (logsTruncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
if (cost > logBudget) {
logsTruncated = true
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
return
}
logBudget -= cost
sink.push(entry)
}
// No settled guard: `finish` snapshots the arrays when it resolves, so
// a chunk flushing after settlement mutates only the discarded buffers,
// and the ledger bounds that growth until the pipes close.
const captureStray = (source: 'stdout' | 'stderr') => (chunk: Buffer) => {
admit({ source, text: chunk.toString('utf8') }, strayLogs)
}
worker.stdout.on('data', captureStray('stdout'))
worker.stderr.on('data', captureStray('stderr'))
// Settlement: exactly one outcome wins; every path funnels through
// here, cleans up the timers/listeners, terminates the worker, and
// resolves only after the worker actually exited (quiescence). Logs
// streamed eagerly before the settlement are kept — a timed-out or
// killed program still shows the model what it printed.
let finishResolve!: () => void
const finished = new Promise<void>((done) => { finishResolve = done })
const finish = (result: Omit<CodeRunResult, 'logs'>): void => {
if (settled) return
settled = true
clearInterval(eluTimer)
clearTimeout(wallTimer)
request.signal?.removeEventListener('abort', onAbort)
this.live.delete(live)
void worker.terminate().then(() => {
finishResolve()
resolve({ ...result, logs: [...logs, ...strayLogs] })
})
}
const onDone = (message: WorkerToHost): void => {
if (message.type !== 'done') return
// Re-cap the completion value HOST-side: the honest path already
// capped it in the worker (prepareValue there), but a forged done
// message bypasses the bootstrap entirely — without this, model code
// could flood the host past maxValueBytes. Honest values pass
// unchanged (see VALUE_RENDER_SLACK); the error text is bounded too.
finish({
...prepareValue(message.value, this.config.maxValueBytes + VALUE_RENDER_SLACK),
...message.error ? { error: { kind: 'exception' as const, message: truncateUtf8Bytes(message.error.message, this.config.maxValueBytes) } } : {},
})
}
const onCall = (message: WorkerToHost): void => {
if (message.type !== 'call' || settled) return
// Hostile-peer rules: a duplicate id is ignored, an unknown name is
// answered with a failure, and a binding throw/reject becomes the
// program-side rejection — contained here, never a host crash.
if (answered.has(message.id)) return
answered.add(message.id)
const reply = (payload: ReplyMessage): void => {
if (settled) return
try {
worker.postMessage(payload)
} catch {
// The reply value failed structured clone; renegotiate as an error
// reply, which is always clone-plain. Nothing else throws here.
worker.postMessage({ type: 'reply', id: message.id, ok: false, message: 'binding resolution is not structured-cloneable' })
}
}
const record = bindings.get(message.global)
// Own-property lookup only: a forged name like 'constructor' or
// 'hasOwnProperty' must not walk the record's prototype chain and
// reach a callable the consumer never declared.
const fn = record && Object.hasOwn(record, message.name) ? record[message.name] : undefined
if (typeof fn !== 'function') {
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
return
}
void (async () => {
try {
reply({ type: 'reply', id: message.id, ok: true, value: await fn(message.args) })
} catch (error: unknown) {
reply({ type: 'reply', id: message.id, ok: false, message: messageOf(error) })
}
})()
}
worker.on('message', (raw: unknown) => {
// Parse before touching: the peer can post ANY shape, and a throw in
// this listener would crash the host process. Junk drops silently.
const message = parseWorkerMessage(raw)
if (!message) return
if (message.type === 'log' && !settled) admit(message.entry, logs)
onCall(message)
onDone(message)
})
worker.on('error', (error: Error) => {
finish({ error: { kind: 'worker-exit', message: `worker error: ${error.message}` } })
})
worker.on('exit', (exitCode: number) => {
finish({ error: { kind: 'worker-exit', message: `worker exited with code ${exitCode} before completing` } })
})
// The compute budget reads the worker's own measured busy time, so a
// hot loop expires it no matter what dispatches are in flight, while a
// program idling on a slow binding accrues nothing.
const eluTimer = setInterval(() => {
const elu = worker.performance.eventLoopUtilization()
if (elu.active > this.config.computeMs) {
finish({ error: { kind: 'timeout', message: `compute budget exhausted (${this.config.computeMs}ms busy)` } })
}
}, ELU_POLL_INTERVAL_MS)
const wallTimer = setTimeout(() => {
finish({ error: { kind: 'timeout', message: `wall-clock ceiling reached (${this.config.maxWallMs}ms)` } })
}, this.config.maxWallMs)
const onAbort = (): void => {
finish({ error: { kind: 'abort', message: String(request.signal?.reason) } })
}
request.signal?.addEventListener('abort', onAbort, { once: true })
const live: LiveRun = {
worker,
finished,
settle: (failure: CodeRunFailure) => { finish({ error: failure }) },
}
this.live.add(live)
})
}
}
export default WorkerCodeRuntime

View File

@@ -0,0 +1,78 @@
/**
* Wire protocol between the host runtime and the worker bootstrap. Everything
* crossing the message port is structured-clone-plain and versionless — both
* ends ship in this package, always at the same version. The host treats
* inbound traffic as HOSTILE (the worker runs model code, which can reach
* `parentPort` via `import('node:worker_threads')` and forge any of these
* shapes); the worker treats inbound traffic as trusted.
*
* @module @deepseek-ai/dsh-code-runtime-worker/src/protocol
*/
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/** What the host hands the worker at spawn, via `workerData`. */
export interface WorkerBootData {
/** The type-stripped (plain JS) program body. */
code: string
/** Binding namespaces to materialize: the global name plus the function names (functions themselves stay host-side). */
namespaces: { global: string; names: string[] }[]
/** Shared byte budget for captured log text; exceeding it drops further entries after one in-band marker. */
maxLogBytes: number
/** Byte cap for the rendered completion value (see the value-preparation contract in bootstrap.ts). */
maxValueBytes: number
}
/** Worker → host: one bridged binding call. */
export interface CallMessage {
type: 'call'
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number
/** The namespace global the call targets. */
global: string
/** The function name within the namespace. */
name: string
/** The single argument, structured-clone-plain. */
args: unknown
}
/** Worker → host: one captured log entry, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
export interface LogMessage {
type: 'log'
entry: CodeLogEntry
}
/**
* Worker → host: the program settled. `error` carries a program exception
* (the only failure the bootstrap itself can report — budgets, aborts, and
* substrate death are observed host-side). `value` is present only on a
* clean completion that produced one (already size-capped and
* clone-safe per the bootstrap's value preparation). Logs are NOT carried
* here — they streamed eagerly as {@link LogMessage}s.
*/
export interface DoneMessage {
type: 'done'
value?: unknown
error?: { message: string }
}
/** Every message the worker sends. */
export type WorkerToHost = CallMessage | LogMessage | DoneMessage
/** Host → worker: the answer to one {@link CallMessage}. */
export type ReplyMessage =
| { type: 'reply'; id: number; ok: true; value: unknown }
| { type: 'reply'; id: number; ok: false; message: string }
/**
* The in-band marker entry text announcing that log capture stopped at the
* byte budget. Shared wire vocabulary: the worker's LogBuffer emits it when
* ITS budget exhausts, and the host emits the identical text when its own
* ledger drops an entry first (forged port traffic, stray pipe bytes) — so
* a truncated run reads the same however the cap was hit.
* @param maxBytes - the configured `maxLogBytes` the marker names.
* @returns the marker line.
*/
export function logTruncationMarker(maxBytes: number): string {
return `[dsh-code-runtime-worker] log capture truncated at ${maxBytes} bytes`
}

View File

@@ -0,0 +1,20 @@
/**
* The worker-thread entrypoint: self-executing glue over
* `bootstrap.ts`'s {@link runWorkerMain}, kept to the spawn wiring alone.
* Like `bin.ts` CLI entrypoints, this file executes only inside a spawned
* worker isolate — a place the coverage provider cannot observe — so it is
* excluded from the coverage gate while every line of actual logic lives in
* `bootstrap.ts`, unit-tested in-process; the real spawn path is pinned by
* the integration tests that run genuine workers.
*
* @module @deepseek-ai/dsh-code-runtime-worker/src/worker
*/
import { parentPort, workerData } from 'node:worker_threads'
import { runWorkerMain } from './bootstrap.ts'
import type { WorkerBootData } from './protocol.ts'
// A worker always has a parent port; guard loudly rather than run detached.
if (!parentPort) throw new Error('dsh-code-runtime-worker: worker entry loaded outside a worker thread')
await runWorkerMain(parentPort, workerData as WorkerBootData, { stdout: process.stdout, stderr: process.stderr })

View File

@@ -0,0 +1,273 @@
import { describe, expect, it } from 'vitest'
import { EventEmitter } from 'node:events'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '@deepseek-ai/dsh-code-runtime-worker/src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '@deepseek-ai/dsh-code-runtime-worker/src/protocol.ts'
import type { CodeLogEntry } from '@deepseek-ai/dsh-code-runtime'
/**
* An in-process stand-in for the worker's parentPort: the test plays the
* HOST side — inspect what the bootstrap posted, feed replies back — so
* every line of worker-side logic runs under coverage without spawning an
* isolate (real-worker behavior is pinned by runtime.spec.ts).
*/
class FakePort implements BootstrapPort {
sent: WorkerToHost[] = []
private readonly emitter = new EventEmitter()
/** Host-scripted responder; return undefined to leave the call pending. */
respond: (message: WorkerToHost) => ReplyMessage | undefined = () => undefined
postMessage(message: WorkerToHost): void {
this.sent.push(message)
const reply = this.respond(message)
if (reply) queueMicrotask(() => this.emitter.emit('message', reply))
}
on(event: 'message', listener: (message: ReplyMessage) => void): void {
this.emitter.on(event, listener)
}
deliver(message: ReplyMessage): void {
this.emitter.emit('message', message)
}
logs(): CodeLogEntry[] {
return this.sent.filter(message => message.type === 'log').map(message => message.entry)
}
done(): WorkerToHost | undefined {
return this.sent.find(message => message.type === 'done')
}
}
function fakeStreams(): { stdout: PatchableStream; stderr: PatchableStream } {
return { stdout: { write: () => true }, stderr: { write: () => true } }
}
const BOOT = { maxLogBytes: 65_536, maxValueBytes: 32_768 }
describe('LogBuffer', () => {
it('streams entries to the sink until the byte budget, then emits one marker and drops the rest', () => {
const seen: CodeLogEntry[] = []
const buffer = new LogBuffer(10, entry => seen.push(entry))
buffer.push({ source: 'console', level: 'log', text: '12345' })
buffer.push({ source: 'console', level: 'log', text: '123456' })
buffer.push({ source: 'console', level: 'log', text: 'dropped' })
expect(seen.map(entry => entry.text)).toEqual([
'12345',
'[dsh-code-runtime-worker] log capture truncated at 10 bytes',
])
})
})
describe('makeConsoleShim', () => {
it('captures the five levels and renders non-strings inspect-style', () => {
const seen: CodeLogEntry[] = []
const shim = makeConsoleShim(new LogBuffer(1_000, entry => seen.push(entry)))
shim.log('plain', { a: 1 })
shim.info('i')
shim.warn('w')
shim.error('e')
shim.debug('d')
expect(seen.map(entry => entry.level)).toEqual(['log', 'info', 'warn', 'error', 'debug'])
expect(seen[0]?.text).toBe('plain { a: 1 }')
expect(seen.every(entry => entry.source === 'console')).toBe(true)
})
})
describe('captureStreamWrites', () => {
it('redirects writes into the buffer and restores on request', () => {
const seen: CodeLogEntry[] = []
const buffer = new LogBuffer(1_000, entry => seen.push(entry))
let underlying = ''
const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
const restore = captureStreamWrites(buffer, stream, 'stdout')
stream.write('captured', 'utf8')
stream.write(Buffer.from('bytes'))
restore()
stream.write('after')
expect(seen.map(entry => entry.text)).toEqual(['captured', 'bytes'])
expect(seen[0]).toMatchObject({ source: 'stdout' })
expect(underlying).toBe('after')
})
it('invokes the write callback asynchronously, in both optional-encoding shapes', async () => {
const buffer = new LogBuffer(1_000, () => {})
const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout')
const calls: (Error | null | undefined)[] = []
stream.write('two-arg', (error?: Error | null) => calls.push(error))
stream.write('three-arg', 'utf8', (error?: Error | null) => calls.push(error))
// Node's contract: the callback fires after the write call returns.
expect(calls).toEqual([])
await new Promise<void>(resolve => stream.write('awaited flush', resolve))
expect(calls).toEqual([null, null])
})
it('still fires the callback for a write the exhausted budget drops', async () => {
const buffer = new LogBuffer(4, () => {})
const stream: PatchableStream = { write: () => true }
captureStreamWrites(buffer, stream, 'stdout')
stream.write('this write overflows the budget and is dropped')
await new Promise<void>(resolve => stream.write('also dropped', resolve))
})
})
describe('prepareValue', () => {
it('omits undefined, passes small cloneable values raw', () => {
expect(prepareValue(undefined, 100)).toEqual({})
expect(prepareValue({ a: [1, 'two'] }, 100)).toEqual({ value: { a: [1, 'two'] } })
})
it('replaces a non-cloneable value with its rendering', () => {
const { value } = prepareValue({ fn: () => 1 }, 1_000)
expect(typeof value).toBe('string')
expect(value).toContain('fn')
})
it('replaces an oversized value with a truncation-marked capped rendering', () => {
const { value } = prepareValue('x'.repeat(50), 10)
expect(value).toBe(`${'x'.repeat(10)}… [truncated]`)
})
it('measures a container by its structured-clone wire size, not its bounded rendering', () => {
// The bounded inspect rendering of a huge array is tiny ("... N more
// items"), but its real cross-boundary size is not — the cap must catch
// it, replacing the value with that bounded rendering.
const huge = new Array(50_000).fill(7)
const { value } = prepareValue(huge, 1_000)
expect(typeof value).toBe('string')
expect(value).toContain('more items')
})
it('caps a multibyte string by UTF-8 bytes, not UTF-16 length', () => {
// 4 code units but 12 UTF-8 bytes: a length-counting cap would pass the
// full string through untruncated.
expect(prepareValue('€€€€', 4)).toEqual({ value: '€… [truncated]' })
})
it('caps a multibyte rendering by UTF-8 bytes too', () => {
// Wire size (24-byte string inside an array) exceeds the cap, so the
// value crosses as its rendering — whose truncation must also be
// byte-exact: "[ '" (3 bytes) + two € (6 bytes) = 9; a third € would
// overflow the 10-byte budget.
expect(prepareValue(['€€€€€€€€'], 10)).toEqual({ value: "[ '€€… [truncated]" })
})
})
describe('truncateUtf8Bytes', () => {
it('returns a fitting string whole', () => {
expect(truncateUtf8Bytes('fits', 4)).toBe('fits')
})
it('cuts at a code-point boundary, never mid-surrogate-pair', () => {
// Each 😀 is one code point, two code units, four UTF-8 bytes: a 5-byte
// budget fits exactly one — and never leaves a lone surrogate behind.
const cut = truncateUtf8Bytes('😀😀', 5)
expect(cut).toBe('😀')
expect(Buffer.byteLength(truncateUtf8Bytes('😀😀', 3), 'utf8')).toBe(0)
})
})
describe('makeNamespaces', () => {
it('exposes prototype-colliding names as ordinary own properties', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: `${message.name}-ok` } : undefined
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['__proto__', 'constructor', 'toString'] }] }, port, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
expect(Object.getPrototypeOf(tools)).toBeNull()
await expect(tools['__proto__']?.({})).resolves.toBe('__proto__-ok')
await expect(tools['constructor']?.({})).resolves.toBe('constructor-ok')
await expect(tools['toString']?.({})).resolves.toBe('toString-ok')
})
it('rejects a non-cloneable argument without leaking the pending entry', async () => {
let firstCall = true
const throwingPort: BootstrapPort = {
// First call throws an Error (the real DataCloneError shape), the
// second a bare string — the rejection renders both.
postMessage: () => {
if (firstCall) { firstCall = false; throw new Error('DataCloneError-ish') }
throw 'raw-clone-failure'
},
on: () => {},
}
const pending = new Map<number, PendingCall>()
const [tools] = makeNamespaces({ namespaces: [{ global: 'tools', names: ['x'] }] }, throwingPort, pending, { value: 1 }) as [Record<string, (args: unknown) => Promise<unknown>>]
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: DataCloneError-ish/)
await expect(tools.x?.(() => 1)).rejects.toThrow(/structured-cloneable: raw-clone-failure/)
expect(pending.size).toBe(0)
})
})
describe('runWorkerMain', () => {
it('runs a program end-to-end: bindings, console, return value', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: true, value: (message.args as { n: number }).n * 2 } : undefined
await runWorkerMain(port, {
...BOOT,
code: 'const doubled = await tools.double({ n: 21 }); console.log("got", doubled); return { doubled };',
namespaces: [{ global: 'tools', names: ['double'] }],
}, fakeStreams())
expect(port.logs()).toEqual([{ source: 'console', level: 'log', text: 'got 42' }])
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
})
it('reports a thrown program error on the done message', async () => {
const port = new FakePort()
await runWorkerMain(port, { ...BOOT, code: 'throw new Error("boom")', namespaces: [] }, fakeStreams())
const done = port.done()
expect(done?.type).toBe('done')
expect(done?.type === 'done' ? done.error?.message : undefined).toContain('boom')
expect(done?.type === 'done' ? done.value : undefined).toBeUndefined()
})
it('renders non-Error throws and stack-less Errors on the done message', async () => {
const rawPort = new FakePort()
await runWorkerMain(rawPort, { ...BOOT, code: 'throw "raw-throw"', namespaces: [] }, fakeStreams())
expect(rawPort.done()).toEqual({ type: 'done', error: { message: 'raw-throw' } })
const barePort = new FakePort()
await runWorkerMain(barePort, { ...BOOT, code: 'const e = new Error("bare"); e.stack = undefined; throw e', namespaces: [] }, fakeStreams())
expect(barePort.done()).toEqual({ type: 'done', error: { message: 'bare' } })
})
it('surfaces a host failure reply as a program-side rejection it can catch', async () => {
const port = new FakePort()
port.respond = message => message.type === 'call' ? { type: 'reply', id: message.id, ok: false, message: 'denied by host' } : undefined
await runWorkerMain(port, {
...BOOT,
code: 'try { await tools.x({}) } catch (error) { return `caught: ${error.message}` }',
namespaces: [{ global: 'tools', names: ['x'] }],
}, fakeStreams())
expect(port.done()).toEqual({ type: 'done', value: 'caught: denied by host' })
})
it('ignores replies for unknown pending ids', async () => {
const port = new FakePort()
port.respond = (message) => {
if (message.type !== 'call') return undefined
// Deliver a stray reply first; the real one follows.
port.deliver({ type: 'reply', id: 9_999, ok: true, value: 'stray' })
return { type: 'reply', id: message.id, ok: true, value: 'real' }
}
await runWorkerMain(port, {
...BOOT,
code: 'return await tools.x({})',
namespaces: [{ global: 'tools', names: ['x'] }],
}, fakeStreams())
expect(port.done()).toEqual({ type: 'done', value: 'real' })
})
it('captures raw stream writes through the patched process streams', async () => {
const port = new FakePort()
const streams = fakeStreams()
await runWorkerMain(port, { ...BOOT, code: 'return 1', namespaces: [] }, streams)
streams.stdout.write('never seen — already restored? no: patch persists in worker')
// The patch stays installed for the worker's lifetime; writes during the
// program landed in order. Here the program wrote nothing via streams, so
// only the post-run write above went through the patched slot.
expect(port.logs().at(-1)).toMatchObject({ source: 'stdout' })
})
})

View File

@@ -0,0 +1,55 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* BUILT-ARTIFACT smoke for the published package (the real-load-path guard
* from docs/testing.md): the unit suite runs `src/` under vitest, where the
* worker entry resolves to `src/worker.ts` — a consumer runs `lib/index.js`
* under plain `node`, where it must resolve the sibling `lib/worker.js`
* bundle instead. This spawns plain `node` (NOT tsx) from inside the package
* directory and imports the package BY NAME, so resolution flows through the
* real `exports` map exactly as it would from a downstream install; the
* program exercises the type-strip, the worker spawn, the binding bridge,
* and log capture end-to-end through the built bundles.
*
* It build-gates: SKIPS when the built artifacts are absent (suite run
* without `pnpm run build`); CI runs it after the build step. KEYLESS — no
* model is involved.
*/
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
const built = ['lib/index.js', 'lib/worker.js'].every(file => existsSync(join(pkgDir, file)))
&& existsSync(join(pkgDir, '../code-runtime/lib/index.js'))
describe.skipIf(!built)('built lib real load path (plain node)', () => {
it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.js entry', async () => {
const script = `
const { Context } = await import('cordis')
const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker')
const ctx = new Context()
await ctx.plugin(WorkerCodeRuntime, {})
const result = await ctx.codeRuntime.run({
program: 'const doubled: number = await tools.double({ n: 21 }); console.log("halfway", doubled); return doubled;',
bindings: [{ global: 'tools', functions: { double: async args => args.n * 2 } }],
})
console.log(JSON.stringify(result))
process.exit(0)
`
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
const result = JSON.parse(lastLine) as { value?: unknown; logs: { source: string; level?: string; text: string }[]; error?: unknown }
expect(result.error).toBeUndefined()
expect(result.value).toBe(42)
expect(result.logs).toContainEqual({ source: 'console', level: 'log', text: 'halfway 42' })
})
})

View File

@@ -0,0 +1,451 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker'
import type { Config } from '@deepseek-ai/dsh-code-runtime-worker'
import type { CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
/**
* Integration suite over REAL worker threads (no mocks — workers are cheap
* and local, per docs/testing.md's real-over-mock policy). Each test builds
* a fresh context so budgets can be tuned per case.
*/
async function setup(config: Config = {}) {
const ctx = new Context()
await ctx.plugin(WorkerCodeRuntime, config)
const runtime = ctx.codeRuntime as WorkerCodeRuntime
return { ctx, runtime }
}
/** Convenience: one namespace `tools` with the given functions. */
function tools(functions: Record<string, (args: unknown) => Promise<unknown>>) {
return [{ global: 'tools', functions }]
}
describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
it('registers with the seam descriptors', async () => {
const { runtime } = await setup()
expect(runtime.language).toBe('typescript')
expect(runtime.isolation).toBe('worker-thread')
})
it('runs TypeScript (erasable syntax), captures console/stdout in order, returns the value', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
interface Point { x: number; y: number }
const p: Point = { x: 1, y: 2 } as Point;
console.log('point', p);
process.stdout.write('raw-out\\n');
console.warn('careful');
return p.x + p.y;
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe(3)
expect(result.logs.map(entry => [entry.source, entry.level ?? null])).toEqual([
['console', 'log'],
['stdout', null],
['console', 'warn'],
])
expect(result.logs[0]?.text).toBe('point { x: 1, y: 2 }')
})
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
const { runtime } = await setup()
const calls: unknown[] = []
const result = await runtime.run({
program: `
const first = await tools.echo({ n: 1 });
let caught = '';
try { await tools.fail({}) } catch (error) { caught = error.message }
let caughtRaw = '';
try { await tools.failRaw({}) } catch (error) { caughtRaw = error.message }
return { first, caught, caughtRaw };
`,
bindings: tools({
echo: async (args) => { calls.push(args); return { echoed: args } },
fail: async () => { throw new Error('nope') },
// A non-Error throw: the host renders it, the program still catches.
failRaw: async () => { throw 'raw-nope' },
}),
})
expect(result.error).toBeUndefined()
expect(result.value).toEqual({ first: { echoed: { n: 1 } }, caught: 'nope', caughtRaw: 'raw-nope' })
expect(calls).toEqual([{ n: 1 }])
})
it('reports non-erasable syntax as an exception without spawning a worker', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'enum E { A }\nreturn 1', bindings: [] })
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toMatch(/enum|strip/i)
})
it('reports a runtime throw as an exception with the message', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'throw new Error("kaboom")', bindings: [] })
expect(result.error?.kind).toBe('exception')
expect(result.error?.message).toContain('kaboom')
})
it('gives the program an EMPTY environment', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return JSON.stringify(process.env)', bindings: [] })
expect(result.value).toBe('{}')
})
it('replaces a non-cloneable return value with a string rendering', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return { f: () => 1 }', bindings: [] })
expect(typeof result.value).toBe('string')
})
it('completes a program that returns nothing with no value at all', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'const x = 1', bindings: [] })
expect(result.error).toBeUndefined()
expect('value' in result).toBe(false)
})
it('keeps logs streamed before a failure', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'console.log("before"); throw new Error("after-log")',
bindings: [],
})
expect(result.error?.kind).toBe('exception')
expect(result.logs.map(entry => entry.text)).toContain('before')
})
})
describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
it('ends a hot loop at the compute budget — including behind a pending decoy dispatch', async () => {
const { runtime } = await setup({ computeMs: 300, maxWallMs: 30_000 })
const result = await runtime.run({
// The decoy: fire a call at a never-resolving binding WITHOUT awaiting,
// then spin. Host-side pending-call bookkeeping would pause a naive
// budget here; measured busy time cannot be fooled.
program: 'void tools.slow({}); for (;;) {}',
bindings: tools({ slow: () => new Promise(() => {}) }),
})
expect(result.error?.kind).toBe('timeout')
expect(result.error?.message).toContain('compute budget')
}, 15_000)
it('does not charge time spent awaiting a slow binding against the compute budget', async () => {
const { runtime } = await setup({ computeMs: 250, maxWallMs: 30_000 })
const result = await runtime.run({
program: 'return await tools.slow({})',
bindings: tools({ slow: () => new Promise(resolve => setTimeout(() => { resolve('slow-done') }, 700)) }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('slow-done')
}, 15_000)
it('ends an idle-forever run at the wall-clock ceiling', async () => {
const { runtime } = await setup({ computeMs: 30_000, maxWallMs: 400 })
const result = await runtime.run({
program: 'await tools.never({}); return 1',
bindings: tools({ never: () => new Promise(() => {}) }),
})
expect(result.error?.kind).toBe('timeout')
expect(result.error?.message).toContain('wall-clock ceiling')
}, 15_000)
it('reports an abort mid-run and stops the worker', async () => {
const { runtime } = await setup()
const controller = new AbortController()
setTimeout(() => { controller.abort('user-cancel') }, 150)
const result = await runtime.run({ program: 'for (;;) {}', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'user-cancel' })
}, 15_000)
it('reports a pre-aborted signal without spawning', async () => {
const { runtime } = await setup()
const controller = new AbortController()
controller.abort('too-late')
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'too-late' })
})
it('drops a binding resolution that lands after the run settled', async () => {
const { runtime } = await setup()
const controller = new AbortController()
let replyDelivered!: Promise<void>
const result = await runtime.run({
program: 'void tools.late({}); for (;;) {}',
bindings: tools({
// Anchored on invocation: abort 100ms after the call reaches the
// host, resolve 400ms after — by then the run has settled, so the
// resolution's reply hits the post-settlement drop.
late: () => new Promise((resolve) => {
setTimeout(() => { controller.abort('cancel-now') }, 100)
replyDelivered = new Promise(done => setTimeout(() => { resolve('too-late'); done() }, 400))
}),
}),
signal: controller.signal,
})
expect(result.error).toEqual({ kind: 'abort', message: 'cancel-now' })
// Let the late resolution actually fire so its reply executes instead of
// being cancelled with the test.
await replyDelivered
}, 15_000)
it('contains an OOM under resourceLimits as worker-exit, host process healthy', async () => {
const { runtime } = await setup({ maxOldGenerationSizeMb: 32 })
const result = await runtime.run({
program: 'const hog = []; for (;;) hog.push(new Array(1e6).fill(1));',
bindings: [],
})
expect(result.error?.kind).toBe('worker-exit')
// And the host is fine: run something else.
const after = await runtime.run({ program: 'return "alive"', bindings: [] })
expect(after.value).toBe('alive')
}, 30_000)
it('truncates runaway log output at the byte budget with an in-band marker', async () => {
const { runtime } = await setup({ maxLogBytes: 300 })
const result = await runtime.run({
program: 'for (let i = 0; i < 1000; i++) console.log("spam line", i); return 1',
bindings: [],
})
expect(result.logs.at(-1)?.text).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
expect(total).toBeLessThan(1_000)
})
it('caps an oversized return value with a truncation marker', async () => {
const { runtime } = await setup({ maxValueBytes: 64 })
const result = await runtime.run({ program: 'return "y".repeat(10_000)', bindings: [] })
expect(result.value).toBe(`${'y'.repeat(64)}… [truncated]`)
})
it('caps a multibyte return value by UTF-8 bytes, not string length', async () => {
// 4 code units, 12 UTF-8 bytes: a length-counting cap would let the full
// string cross. The worker's byte-exact capped rendering then passes the
// host re-cap unchanged (cap + marker is exactly the granted slack).
const { runtime } = await setup({ maxValueBytes: 4 })
const result = await runtime.run({ program: 'return "€€€€"', bindings: [] })
expect(result.value).toBe('€… [truncated]')
})
it('completes a program that awaits its write callback, capturing the chunk', async () => {
// Node's write(chunk[, encoding][, callback]) contract: dropping the
// callback would leave this promise pending until the wall ceiling and
// misreport a completed program as a timeout.
const { runtime } = await setup({ maxWallMs: 2_000 })
const result = await runtime.run({
program: 'await new Promise(resolve => process.stdout.write("flushed", resolve)); return "done"',
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs).toContainEqual({ source: 'stdout', text: 'flushed' })
})
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
const { runtime } = await setup()
const result = await runtime.run({ program: 'return new Array(50_000).fill(7)', bindings: [] })
expect(result.error).toBeUndefined()
expect(typeof result.value).toBe('string')
expect(result.value).toContain('more items')
})
it('captures pipe writes that bypass the patched write slot as stray logs, capped by the same budget', async () => {
const { runtime } = await setup({ maxLogBytes: 4 })
const result = await runtime.run({
// The bootstrap patches the stream instance's own `write`; going
// through the prototype's slot reaches the real pipe underneath, so
// the bytes arrive host-side as stray data. The pauses keep the two
// writes in separate pipe chunks and let them land before settlement.
program: `
const write = (text) => Object.getPrototypeOf(process.stdout).write.call(process.stdout, text);
write('abcd');
await new Promise(resolve => setTimeout(resolve, 150));
write('ef');
await new Promise(resolve => setTimeout(resolve, 100));
return 1;
`,
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' })
expect(result.logs.map(entry => entry.text)).not.toContain('ef')
}, 15_000)
})
describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
it('survives forged port traffic: unknown binding names, duplicate ids, junk shapes', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
parentPort.postMessage({ type: 'call', id: 7777, global: 'tools', name: 'missing', args: {} });
parentPort.postMessage({ type: 'call', id: 7778, global: 'tools', name: 'constructor', args: {} });
parentPort.postMessage({ type: 'junk' });
return await tools.real({});
`,
bindings: tools({ real: async () => 'still-works' }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('still-works')
})
it('survives arbitrary junk on the port: non-objects, junk types, malformed calls, logs, and dones', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
for (const junk of [
null, 42, 'junk', [],
{ type: 'nope' },
{ type: 'call' },
{ type: 'call', id: 'x', global: 'tools', name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 7, name: 'real', args: {} },
{ type: 'call', id: 1e9, global: 'tools', name: 7, args: {} },
{ type: 'log' },
{ type: 'log', entry: null },
{ type: 'log', entry: { source: 'stdout', text: 7 } },
{ type: 'log', entry: { source: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 'nope', text: 'x' } },
{ type: 'log', entry: { source: 'console', level: 7, text: 'x' } },
{ type: 'done', error: 5 },
{ type: 'done', error: { message: 5 } },
]) parentPort.postMessage(junk);
return await tools.real({});
`,
bindings: tools({ real: async () => 'still-works' }),
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('still-works')
expect(result.logs).toEqual([])
})
it('caps forged log floods and forged done values at the configured budgets, dropping forged extra fields', async () => {
const { runtime } = await setup({ maxLogBytes: 200, maxValueBytes: 64 })
const result = await runtime.run({
// Forged messages bypass the worker-side LogBuffer and prepareValue
// entirely — only the host-side ledger and re-cap stand between model
// code and an unbounded result.
program: `
const { parentPort } = await import('node:worker_threads');
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', entry: { source: 'stdout', text: 'F'.repeat(100), forged: true } });
parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
for (;;) {}
`,
bindings: [],
})
expect(typeof result.value).toBe('string')
const value = result.value as string
expect(value.startsWith('V'.repeat(64))).toBe(true)
expect(value.endsWith('… [truncated]')).toBe(true)
expect(value.length).toBeLessThan(120)
const marker = '[dsh-code-runtime-worker] log capture truncated at 200 bytes'
const total = result.logs.reduce((sum, entry) => sum + Buffer.byteLength(entry.text, 'utf8'), 0)
expect(total).toBeLessThanOrEqual(200 + Buffer.byteLength(marker, 'utf8'))
expect(result.logs.at(-1)?.text).toBe(marker)
expect(result.logs.every(entry => !('forged' in entry))).toBe(true)
})
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', value: 'lied', error: { message: 'fake failure' } });
for (;;) {}
`,
bindings: [],
})
expect(result.value).toBe('lied')
expect(result.error).toEqual({ kind: 'exception', message: 'fake failure' })
})
it('byte-bounds forged multibyte error text at the host', async () => {
// Forged error text bypasses the worker entirely; the host bound is a
// BYTE bound (two € = 6 bytes fit an 8-byte cap, a third would not).
const { runtime } = await setup({ maxValueBytes: 8 })
const result = await runtime.run({
program: `
const { parentPort } = await import('node:worker_threads');
parentPort.postMessage({ type: 'done', error: { message: '€'.repeat(1000) } });
for (;;) {}
`,
bindings: [],
})
expect(result.error).toEqual({ kind: 'exception', message: '€€' })
})
it('answers a binding whose resolution cannot be cloned with a failure reply', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'try { await tools.bad({}) } catch (error) { return error.message }',
bindings: tools({ bad: async () => (() => 1) }),
})
expect(result.value).toContain('not structured-cloneable')
})
it('exposes binding names that collide with Object.prototype as ordinary functions', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: 'return [await tools["__proto__"]({}), await tools["constructor"]({}), typeof tools["hasOwnProperty"]]',
// Computed keys: a literal `'__proto__': …` entry would SET the record's
// prototype instead of declaring a binding of that name.
bindings: tools({ ['__proto__']: async () => 'proto-ok', ['constructor']: async () => 'ctor-ok' }),
})
expect(result.value).toEqual(['proto-ok', 'ctor-ok', 'undefined'])
})
})
describe('WorkerCodeRuntime — seam misuse and lifecycle', () => {
it('rejects invalid binding globals loudly (identifier, reserved word, duplicate, console)', async () => {
const { runtime } = await setup()
const cases: [string, RegExp][] = [
['not valid!', /not a usable identifier/],
['await', /not a usable identifier/],
['console', /duplicate binding global/],
]
for (const [global, message] of cases) {
await expect(runtime.run({ program: 'return 1', bindings: [{ global, functions: {} }] })).rejects.toThrow(message)
}
await expect(runtime.run({
program: 'return 1',
bindings: [{ global: 'tools', functions: {} }, { global: 'tools', functions: {} }],
})).rejects.toThrow(/duplicate binding global/)
})
it('rejects config values that are not positive numbers', async () => {
const ctx = new Context()
await expect(ctx.plugin(WorkerCodeRuntime, { computeMs: -1 })).rejects.toThrow(/positive number/)
})
it('keeps runs isolated: no state survives from one run to the next', async () => {
const { runtime } = await setup()
await runtime.run({ program: 'globalThis.leak = "value"; return 1', bindings: [] })
const second = await runtime.run({ program: 'return typeof globalThis.leak', bindings: [] })
expect(second.value).toBe('undefined')
})
it('disposal aborts in-flight runs, awaits worker exit, and rejects later runs', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(WorkerCodeRuntime)
const runtime = ctx.codeRuntime as WorkerCodeRuntime
const inflight: Promise<CodeRunResult> = runtime.run({ program: 'for (;;) {}', bindings: [] })
// Give the worker a moment to actually start spinning.
await new Promise(resolve => setTimeout(resolve, 200))
await fiber.dispose()
const result = await inflight
expect(result.error).toEqual({ kind: 'abort', message: 'runtime disposed' })
await expect(runtime.run({ program: 'return 1', bindings: [] })).rejects.toThrow(/after disposal/)
}, 15_000)
it('removes ctx.codeRuntime when the providing fiber disposes (HMR safety)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(WorkerCodeRuntime)
expect(ctx.get('codeRuntime')).toBeInstanceOf(WorkerCodeRuntime)
await fiber.dispose()
expect(ctx.get('codeRuntime')).toBeUndefined()
})
})

View File

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

View File

@@ -0,0 +1,35 @@
import { defineConfig } from 'tsdown'
/**
* Package-shape override (see the root tsdown.config.ts): besides the
* default lib/index.js bundle, the worker BOOTSTRAP ships as its own
* sibling entry — `new Worker(new URL('./worker.js', import.meta.url))`
* loads it as a file, so it cannot be part of the index bundle. TWO
* single-entry builds, not one two-entry build: a multi-entry build emits
* the shared bootstrap module as a `lib/bootstrap-*.js` chunk both bundles
* import, which the package.json `files` whitelist (deliberately exact)
* would omit from the packed artifact — each single-entry build inlines its
* own bootstrap copy instead, keeping every shipped file self-contained.
*/
export default defineConfig([
{
entry: ['lib/types/index.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
{
entry: ['lib/types/worker.js'],
outDir: 'lib',
format: ['esm'],
platform: 'node',
target: 'es2024',
fixedExtension: false,
dts: false,
clean: false,
},
])

View File

@@ -0,0 +1,19 @@
# @deepseek-ai/dsh-code-runtime
The **code-execution seam**: an abstract `CodeRuntime` service (`ctx.codeRuntime`) defining WHAT a code runtime does — run one model-written program against a set of host-provided async bindings and report `{ value, logs, error? }` — without saying HOW.
This package is the interface third of the capability (the bash trio is the template — see [capability seams](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)): implementations subclass `CodeRuntime` and register the service; the consumer is the tool registry's Code Mode, which generates the model-facing SDK and bridges tool dispatch — both specified in the [Code Mode RFC](../../../docs/rfc/proposed/feature/2026-06-15-code-mode.md), whose first implementation is a Node worker-thread backend. The runtime knows nothing about tools or sessions: it is handed named async functions and a program string, and everything tool-shaped stays with the consumer.
## Service API (`ctx.codeRuntime`)
| Member | Semantics |
|---|---|
| `run(request)` | Execute one program against the request's bindings. **Resolves with an error FIELD for every program outcome** — parse/transform failure, thrown exception, budget expiry, abort, substrate death (`CodeRunFailure`'s orthogonal `kind` taxonomy); it rejects only for caller misuse of the seam itself (e.g. a run submitted after disposal). The program runs as the body of an async function: top-level `await`/`return` work, and the completion value becomes `result.value` when it survives the serialization boundary. |
| `language` | Readonly descriptor: the source language `run` expects (`'typescript'` is the well-known value). Informational, not gating — a consumer that generates language-specific presentation switches on it and fails loud on a language it cannot present. |
| `isolation` | Readonly descriptor: the execution substrate (`'worker-thread'`, `'process'`, `'container'`). A label for deployments and diagnostics, **not a security claim**. |
Semantics every implementation must honor (contract details in the class JSDoc): binding calls bridge to the caller's functions verbatim with structured-cloneable arguments/resolutions; the program is treated as a hostile peer (arbitrary binding names are own properties, malformed traffic never crashes the host); no state survives between runs; disposal terminates in-flight runs AND awaits their exit before completing.
## Vocabulary
`CodeRunRequest` (`program`, `bindings`, `signal?`) carries everything the runtime acts on — defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`. `bindings` is a list of `CodeBindingNamespace`s (`global` + `functions`), each exposed to the program as one global object of async callables. `CodeRunResult` reports the completion `value?`, the ordered `logs` (`CodeLogEntry`: `console`/`stdout`/`stderr` source, console `level`, capped text), and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.

View File

@@ -0,0 +1,30 @@
{
"name": "@deepseek-ai/dsh-code-runtime",
"description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,93 @@
/**
* The code-execution seam (`ctx.codeRuntime`): an abstract service defining
* WHAT a code runtime does — run one model-written program against a set of
* host-provided async bindings and report `{ value, logs, error? }` — without
* saying HOW. Implementations subclass {@link CodeRuntime} and register
* themselves as the `codeRuntime` service; backends may differ by execution
* substrate (worker thread, separate process, container) and by source
* language, both declared as readonly descriptors. The design and its
* consumer (the tool registry's Code Mode) are specified in the Code Mode RFC
* (docs/rfc/proposed/feature/2026-06-15-code-mode.md).
*
* The split mirrors the bash seam (`BashExecutor`): the runtime knows nothing
* about tools or sessions — it is handed named async functions and a program,
* and everything tool-shaped stays with the consumer.
*
* @module @deepseek-ai/dsh-code-runtime
*/
import { Context, Service } from 'cordis'
import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type {
CodeBindingFunction,
CodeBindingNamespace,
CodeLogEntry,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,
} from './types.ts'
declare module 'cordis' {
interface Context {
codeRuntime: CodeRuntime
}
}
/**
* Abstract code-execution service. Subclass, implement {@link run} and the
* two descriptors, and load the subclass as a plugin — it registers as
* `ctx.codeRuntime` (one implementation per context; loading a second throws,
* cordis' standard duplicate-service behavior).
*
* Semantics every implementation must honor:
* - {@link run} resolves with an error FIELD for every program outcome —
* parse/transform failures, thrown exceptions, budget expiry, abort,
* substrate death ({@link CodeRunFailure}'s taxonomy). It REJECTS only for
* caller misuse of the seam itself (e.g. a run submitted after disposal).
* - Binding calls bridge to the caller's {@link CodeBindingFunction}s
* verbatim; arguments and resolutions must be structured-cloneable, and the
* runtime treats the program as a hostile peer (arbitrary binding names are
* own properties, malformed traffic is rejected or ignored, never crashes
* the host).
* - Runs are isolated from each other: no state survives from one run to the
* next through the runtime.
* - Disposal reaches quiescence: in-flight runs are terminated AND awaited
* before the service's own teardown completes (no orphan substrate survives
* `fiber.dispose()`).
*/
export abstract class CodeRuntime extends Service {
/**
* The source language {@link run} expects `program` to be written in, as a
* lowercase identifier. Informational, not gating — a consumer that
* generates language-specific presentation (typed SDK stubs, usage
* instructions) switches on it and fails loud on a language it cannot
* present. Well-known value: `'typescript'`.
*/
abstract readonly language: string
/**
* The execution substrate, as a lowercase identifier. Informational, not
* gating — a descriptor so deployments and diagnostics can tell backends
* apart, not a security claim. Well-known values: `'worker-thread'`,
* `'process'`, `'container'`.
*/
abstract readonly isolation: string
constructor(ctx: Context) {
super(ctx, 'codeRuntime')
}
/**
* Execute one program against the request's bindings and capture what it
* emitted. See the class doc for the resolution contract (error is a result
* field; rejection means seam misuse only).
* @param request - the program, its bindings, and the abort signal; the
* request carries everything the runtime acts on, with no hidden defaults.
* @returns the run's outcome: completion value (when transferable), the
* ordered log capture, and the failure (if any).
*/
abstract run(request: CodeRunRequest): Promise<CodeRunResult>
}
export default CodeRuntime

View File

@@ -0,0 +1,105 @@
/**
* Vocabulary types for the code-execution seam: what a caller hands a
* {@link ../index.ts | CodeRuntime} and what it gets back. Pure types — no
* runtime code lives here.
*
* @module @deepseek-ai/dsh-code-runtime/src/types
*/
/**
* One host-side function exposed to the program as an async callable. The
* runtime bridges calls to it (possibly across a serialization boundary), so
* `args` and the resolution value MUST be structured-cloneable; a runtime
* rejects a non-cloneable value with a descriptive error rather than
* corrupting the run. A rejection of this function surfaces inside the
* program as a rejection of the corresponding call.
*/
export type CodeBindingFunction = (args: unknown) => Promise<unknown>
/**
* A named group of {@link CodeBindingFunction}s the runtime exposes to the
* program as one global object (e.g. `tools`). Function names are arbitrary
* strings — a runtime must treat names like `__proto__` or `constructor` as
* ordinary own properties (null-prototype construction), never as prototype
* collisions.
*/
export interface CodeBindingNamespace {
/** The global identifier the program sees (must be a valid JS identifier). */
global: string
/** The callable members, keyed by the exact name the program calls. */
functions: Record<string, CodeBindingFunction>
}
/**
* One run: the program source plus everything the runtime acts on. Per the
* explicit-over-implicit convention, defaulting (time budgets, output caps)
* is the implementation's validated config — a request carries no optional
* tuning knobs for a hidden `??` to fill in.
*/
export interface CodeRunRequest {
/**
* The program source, in the runtime's {@link ../index.ts | language}. It
* runs as the body of an async function: top-level `await` and `return`
* are available, and the completion value becomes
* {@link CodeRunResult.value}.
*/
program: string
/** Host functions exposed to the program, one global object per namespace. */
bindings: CodeBindingNamespace[]
/**
* Abort the run: the runtime stops the program (hard, even mid-loop) and
* resolves with a {@link CodeRunFailure} of kind `'abort'`. In-flight
* binding calls are the CALLER's to settle — the runtime only stops asking.
*/
signal?: AbortSignal
}
/**
* One captured output entry, in emission order. `source` says which channel
* produced it: the program's `console` (shimmed by the runtime), or a stray
* write to the underlying stdout/stderr streams.
*/
export interface CodeLogEntry {
/** Which channel produced the text. */
source: 'console' | 'stdout' | 'stderr'
/** The console method used; present only when `source` is `'console'`. */
level?: 'log' | 'info' | 'warn' | 'error' | 'debug'
/** The captured text (possibly truncated by the implementation's caps, marked in-band). */
text: string
}
/**
* Why a run failed. The kinds are orthogonal outcomes reported independently
* (per docs/defensive-patterns.md): a budget expiry is not an exception, an
* abort is not a timeout, and a substrate death is neither.
*
* - `'exception'` — the program threw or failed to parse/transform.
* - `'timeout'` — an implementation-owned budget expired; the message says which.
* - `'abort'` — {@link CodeRunRequest.signal} fired.
* - `'worker-exit'` — the execution substrate died without settling (e.g. OOM).
*/
export interface CodeRunFailure {
/** The failure class (see the interface doc for each kind's meaning). */
kind: 'exception' | 'timeout' | 'abort' | 'worker-exit'
/** Human-readable detail, suitable for feeding back to a model to self-correct. */
message: string
}
/**
* The outcome of one run. An error is a FIELD on a resolved result, never a
* rejection of `run()` — reporting a failed program is the caller's job, not
* an exception path.
*/
export interface CodeRunResult {
/**
* The program's completion value (its top-level `return`), when it ran to
* completion and the value survived the runtime's serialization boundary;
* a non-transferable value is replaced by a string rendering, and a failed
* or value-less run leaves this absent.
*/
value?: unknown
/** Everything the program emitted, in order (capped by the implementation). */
logs: CodeLogEntry[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure
}

View File

@@ -0,0 +1,87 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
/**
* Minimal concrete runtime: records requests, "executes" by invoking every
* binding once in declaration order, and lets tests script the outcome. The
* seam package ships no implementation, so the contract is exercised through
* the smallest subclass that honors it.
*/
class StubRuntime extends CodeRuntime {
readonly language = 'typescript'
readonly isolation = 'in-process-stub'
requests: CodeRunRequest[] = []
nextResult: CodeRunResult = { logs: [] }
async run(request: CodeRunRequest): Promise<CodeRunResult> {
this.requests.push(request)
if (request.signal?.aborted) {
return { logs: [], error: { kind: 'abort', message: String(request.signal.reason) } }
}
for (const namespace of request.bindings) {
for (const fn of Object.values(namespace.functions)) {
await fn({ from: 'stub' })
}
}
return this.nextResult
}
}
async function setup() {
const ctx = new Context()
await ctx.plugin(StubRuntime)
const runtime = ctx.codeRuntime as StubRuntime
return { ctx, runtime }
}
describe('CodeRuntime service seam', () => {
it('registers as ctx.codeRuntime and serves the abstract API', async () => {
const { runtime } = await setup()
expect(runtime.language).toBe('typescript')
expect(runtime.isolation).toBe('in-process-stub')
const calls: unknown[] = []
const result = await runtime.run({
program: 'return 1',
bindings: [{ global: 'tools', functions: { probe: async args => void calls.push(args) } }],
})
expect(result).toEqual({ logs: [] })
expect(calls).toEqual([{ from: 'stub' }])
expect(runtime.requests).toHaveLength(1)
})
it('reports a failed run as an error field on a resolved result, never a rejection', async () => {
const { runtime } = await setup()
runtime.nextResult = {
logs: [{ source: 'console', level: 'error', text: 'boom' }],
error: { kind: 'exception', message: 'boom' },
}
const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] })
expect(result.error).toEqual({ kind: 'exception', message: 'boom' })
expect(result.value).toBeUndefined()
})
it('reports a pre-aborted signal as an abort failure', async () => {
const { runtime } = await setup()
const controller = new AbortController()
controller.abort('cancelled')
const result = await runtime.run({ program: 'return 1', bindings: [], signal: controller.signal })
expect(result.error).toEqual({ kind: 'abort', message: 'cancelled' })
})
it('is removed from the context when the providing fiber disposes (HMR safety)', async () => {
const ctx = new Context()
const fiber = await ctx.plugin(StubRuntime)
expect(ctx.get('codeRuntime')).toBeInstanceOf(StubRuntime)
await fiber.dispose()
expect(ctx.get('codeRuntime')).toBeUndefined()
})
it('rejects a second implementation in the same context (duplicate service)', async () => {
const { ctx } = await setup()
await expect(ctx.plugin(StubRuntime)).rejects.toThrow(/registered/)
})
})

View File

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

View File

@@ -1,6 +1,6 @@
# dsh-tools
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → core dispatch`tools/post-execute` (inspect/replace the result, attach context).
Tool registry and execution pipeline. Tool plugins register their schemas and executors; the agent loop executes each call through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an around-dispatch wrapper for timeout/retry/metrics plugins)`tools/post-execute` (inspect/replace the result, attach context).
## Service: `ToolRegistry` (ctx key: `tools`)
@@ -9,7 +9,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
- `ctx.tools.register(definition: ToolDefinition): () => void` Register a tool. Disposed with the calling fiber.
- `ctx.tools.get(name: string): ToolDefinition | undefined`
- `ctx.tools.schemas(): ToolSchema[]` Schemas of all registered tools (without the `execute` functions). The shipped tools' schemas are catalogued in [docs/tool-catalog.md](../../../docs/tool-catalog.md), generated by booting each tool plugin and harvesting this method (see [the tool-schema-catalog RFC](../../../docs/rfc/implemented/process/2026-07-02-tool-schema-catalog.md)).
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute`dispatch`tools/post-execute` pipeline.
- `ctx.tools.execute(exec: ToolExecution): Promise<ToolExecutionResult>` Execute one tool call through the `tools/pre-execute``tools/execute``tools/post-execute` pipeline.
### Injected services
@@ -20,12 +20,13 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
| Event | Mode | Purpose |
|---|---|---|
| `tools/pre-execute` | waterfall | Allow/deny gate BEFORE a tool runs (sandbox, permission, hooks); returns `PreToolDecision` |
| `tools/execute` | waterfall | Around-dispatch wrapper (timeout, retry, metrics): `(exec, next)` → the dispatched `ToolExecutionResult`; `next()` is dispatch-with-normalization |
| `tools/post-execute` | waterfall | Inspect/replace the result AFTER a tool runs, attach context; returns `PostToolDecision` |
| `tools/change` | emit | A tool was registered or unregistered |
### Key types
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below).
- `ToolDefinition``ToolSchema` + `execute(args, exec): Promise<ContentBlock[] | { content: ContentBlock[]; meta? }>` (the bare array is the model-facing content; the object form additionally attaches an opaque, JSON-serializable `meta` presentation payload persisted on the `tool/result` event and handed back to `presentResult`), plus optional `presentCall(args)` / `presentResult(args, result)` for tool-owned UI presentation (see below). It also carries an optional cooperative timeout budget `timeoutMs?: number` (ms) enforced by `@deepseek-ai/dsh-timeout-policy`, never sent to the model.
- `ToolExecution` — one pending tool call: `{ callId, name, arguments, agent?, signal? }`.
- `ToolExecutionResult` — outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text (the loop forwards it onto the `tool/result` session event for retry/sandbox plugins and replay). `additionalContext` (a `HookContext`) ferries any `tools/post-execute` context up to the loop, which buffers it and appends it as a `context/message` after all `tool/result`s in the step. `meta` is the tool's opaque presentation payload from a successful `execute` (the object return form); the loop forwards it onto the `tool/result` session event for result-card rendering.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite (changing `arguments`) is deliberately NOT offered (it would desync the pre-execution audit/history/UI from what ran — its own proposed RFC); `ask` degrades to `deny` until the permission system lands.
@@ -35,7 +36,7 @@ Tool registry and execution pipeline. Tool plugins register their schemas and ex
### Extension points
- Tool plugins call `ctx.tools.register()` — schemas flow into the assembly automatically.
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch sits between them as plain code; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. Both follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)).
- `tools/pre-execute` is the allow/deny gate (sandbox, permission, hooks): listeners receive `(exec, next)` and call `next()` to delegate to the default (allow) or return a `PreToolDecision` to short-circuit; a `deny`/`ask` skips dispatch and yields an `isError` result. `tools/execute` is the around-dispatch seam (timeout, retry, metrics): listeners receive `(exec, next)` and call `next()` to delegate to core dispatch (returning its `ToolExecutionResult`, optionally wrapped), or return a replacement result to short-circuit dispatch; the base `next()` IS dispatch-with-normalization, so `await next()` already yields an `isError` result for a thrown/unknown tool (never a raw throw). A wrapper mutates `exec` in place before `next()` — e.g. replacing `exec.signal` with a per-call deadline — because cordis `next()` ignores passed arguments. `tools/post-execute` is the inspect/transform seam: `(exec, result, next)` → a `PostToolDecision` that can replace content, block with feedback, or attach `additionalContext`. Core dispatch is the base of the `tools/execute` waterfall; the tool body keeps its own try/catch so a thrown tool still reaches `post-execute` as an `isError`. All follow the typed-Decision idiom shared with the `agent/*` interception seams (see [`dsh-agent`](../agent/README.md)); `@deepseek-ai/dsh-timeout-policy` is the reference `tools/execute` wrapper.
- MCP servers: one plugin per server, discover tools, call `ctx.tools.register()` with the server's schemas.
### Typed tool parameter schemas
@@ -71,6 +72,8 @@ 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.
`defineTool` also validates an optional `timeoutMs` at definition time when present: it must be a positive finite number, or the helper throws — the budget is attached to the produced `ToolDefinition` (for `@deepseek-ai/dsh-timeout-policy`) and never reaches the model.
### Structured-output schema subset
A separate vocabulary for callers that DEMAND a machine-readable value from an agent — the subagent seam's `SubagentStartRequest.outputSchema` (and, by extension, a workflow's `agent({ schema })`). Unlike `SchemaSpec` (the author-facing DSL for tool parameters), a `StructuredOutputSchema` is an object-rooted **raw JSON Schema subset** as data: it travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated against it.

View File

@@ -1,9 +1,10 @@
/**
* Tool registry and execution pipeline. Plugins register tools; the registry
* feeds schemas into the system prompt, and `execute()` dispatches each call
* through `tools/pre-execute` (the allow/deny gate) → core dispatch →
* `tools/post-execute` (inspect/replace the result, attach context) for
* sandbox, permission, and hook plugins to gate or transform a call.
* through `tools/pre-execute` (the allow/deny gate) → `tools/execute` (an
* around-dispatch wrapper for timeout/retry/metrics plugins) → `tools/post-execute`
* (inspect/replace the result, attach context) for sandbox, permission, and hook
* plugins to gate or transform a call.
*
* @module @deepseek-ai/dsh-tools
*/
@@ -74,17 +75,37 @@ declare module 'cordis' {
* @mode waterfall
*/
'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision>
/**
* Around-dispatch waterfall wrapping the registry's core tool dispatch,
* between the `tools/pre-execute` gate and the `tools/post-execute` seam. A
* listener receives `(exec, next)`: call `next()` to delegate to dispatch
* (returning its {@link ToolExecutionResult}, optionally wrapped), or return a
* replacement result without calling `next()` to short-circuit dispatch. The
* base `next()` IS the dispatch-with-normalization thunk — a thrown tool (or
* unknown tool) is already normalized to an `isError` result by the time a
* listener's `await next()` returns, so a wrapper never sees a raw throw from
* the tool body. This is the seam a timeout/retry/metrics plugin wraps: it can
* mutate `exec` (e.g. replace `exec.signal` with a per-call deadline) BEFORE
* `next()` and inspect the result AFTER. (Cordis `next()` ignores any passed
* arguments and re-invokes downstream with the shared payload, so a wrapper
* mutates `exec` in place rather than passing a new object to `next()`.)
* Multiple listeners compose by registration order — an outer one wraps the
* inner ones plus dispatch.
* @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal).
* @mode waterfall
*/
'tools/execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult>
/**
* Waterfall AFTER a tool runs — where hook plugins inspect the result and
* accept it (optionally REPLACING the model-facing content, and/or attaching
* `additionalContext` for the next request) or block it with corrective
* `feedback` (Claude Code's `PostToolUse`). Listeners receive
* `(exec, result, next)`: call `next()` to delegate to the default (accept
* unchanged), or return a {@link PostToolDecision} to override. The core tool
* dispatch sits between the two waterfalls as plain code, all inside
* `execute`'s outer try/catch (and the tool body keeps its own inner
* try/catch, so a thrown tool still reaches `post-execute` as an `isError`
* result).
* unchanged), or return a {@link PostToolDecision} to override. Core tool
* dispatch runs earlier as the base `next()` of the `tools/execute`
* waterfall, all inside `execute`'s outer try/catch (and the tool body keeps
* its own inner try/catch, so a thrown tool still reaches `post-execute` as an
* `isError` result).
* @param exec - the call that just ran (name, parsed arguments, caller agent).
* @param result - the dispatch outcome a listener may accept, replace, or block.
* @mode waterfall
@@ -117,6 +138,14 @@ export type ToolExecuteReturn = ContentBlock[] | { content: ContentBlock[]; meta
/** A registered tool: its schema plus the execution function. */
export interface ToolDefinition extends ToolSchema {
execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn>
/**
* Cooperative tool-call timeout budget in milliseconds. Omit for no deadline.
* Enforced by `@deepseek-ai/dsh-timeout-policy` (a `tools/execute` wrapper); it
* is NEVER sent to the model — `schemas()` whitelists only name/description/
* parameters. Declaring it asserts this tool forwards `exec.signal` to a
* cooperative implementation that can reach quiescence when the signal aborts.
*/
timeoutMs?: number
/**
* 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
@@ -271,7 +300,7 @@ function errorInfo(error: unknown): ToolErrorInfo | undefined {
/**
* Tool registry (`ctx.tools`): tool plugins register definitions; the agent
* loop executes calls through the `tools/pre-execute` → dispatch
* loop executes calls through the `tools/pre-execute` → `tools/execute`
* `tools/post-execute` pipeline. The registry contributes its schemas into the
* system-prompt assembly.
*/
@@ -345,18 +374,20 @@ export class ToolRegistry extends Service {
}
/**
* Execute one tool call through the `tools/pre-execute` → dispatch →
* `tools/post-execute` pipeline. The two waterfalls are the gate (allow/deny)
* and the inspect/transform seam; core dispatch sits between them as plain
* code. The whole thing is wrapped in one outer try/catch so a throwing
* listener (in either waterfall) becomes an `isError` result instead of
* failing the turn; the tool body ALSO keeps its own inner try/catch, so a
* thrown tool becomes an `isError` result that `post-execute` listeners can
* still inspect. If the tool is not registered, the result is an `isError`
* carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError}
* surfaces its `{ name, code }` on the result.
* Execute one tool call through the `tools/pre-execute` → `tools/execute`
* (around dispatch) → `tools/post-execute` pipeline. `pre-execute` is the gate
* (allow/deny), `tools/execute` wraps core dispatch (a timeout/retry/metrics
* seam), and `post-execute` is the inspect/transform seam; core dispatch sits
* as the base `next()` of the `tools/execute` waterfall. The whole thing is
* wrapped in one outer try/catch so a throwing listener (in any waterfall)
* becomes an `isError` result instead of failing the turn; the tool body ALSO
* keeps its own inner try/catch, so a thrown tool becomes an `isError` result
* that `tools/execute` and `post-execute` listeners can still inspect. If the
* tool is not registered, the result is an `isError` carrying a `UNKNOWN_TOOL`
* structured error. A thrown {@link HarnessError} surfaces its `{ name, code }`
* on the result.
* @param exec - the call to run (name, parsed arguments, caller agent, signal).
* @returns the final result after both waterfalls; failures resolve as
* @returns the final result after every waterfall; failures resolve as
* `isError` results, never rejections.
*/
async execute(exec: ToolExecution): Promise<ToolExecutionResult> {
@@ -382,23 +413,30 @@ export class ToolRegistry extends Service {
return await this.postExecute(exec, denied)
}
// --- Core dispatch (plain code between the waterfalls). The tool body's
// own try/catch turns a throw into an isError result so post-execute can
// inspect it; an unknown tool routes through the same catch. ---
let result: ToolExecutionResult
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
result = { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
result = toolErrorResult(exec.callId, error)
}
// --- Around-dispatch: tools/execute. The base `next` is the dispatch-
// with-normalization thunk — the tool body's own try/catch turns a throw
// into an isError result so a wrapper (and post-execute) can inspect it;
// an unknown tool routes through the same catch. A `tools/execute` listener
// (e.g. a timeout plugin) wraps this thunk: it may mutate `exec` before
// delegating and inspect the normalized result after. ---
const result = await this.ctx.waterfall(
this, 'tools/execute', exec,
async (): Promise<ToolExecutionResult> => {
try {
const tool = this.store.get(exec.name)
if (!tool) throw new ToolNotFoundError(exec.name)
// Normalize the two `execute` return shapes: a bare ContentBlock[] (no
// meta) or a { content, meta } object (a tool attaching a private
// presentation payload). An array IS the content; the object carries it.
const returned = await tool.execute(exec.arguments, exec)
const content = Array.isArray(returned) ? returned : returned.content
const meta = Array.isArray(returned) ? undefined : returned.meta
return { callId: exec.callId, content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
}
},
)
return await this.postExecute(exec, result)
} catch (error: unknown) {

View File

@@ -295,6 +295,13 @@ export interface DefineToolOptions<S extends SchemaSpec> {
* standard JSON Schema at runtime.
*/
parameters: S
/**
* Optional cooperative tool-call timeout budget in milliseconds. When given it
* must be a positive finite number; it is attached to the produced
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
* is never sent to the model.
*/
timeoutMs?: number
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
@@ -362,10 +369,14 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
const userPresentCall = options.presentCall
// eslint-disable-next-line @typescript-eslint/unbound-method
const userPresentResult = options.presentResult
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an

View File

@@ -35,7 +35,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
const catalog = await collectToolCatalog()
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
expect(names).toEqual(['bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
expect(names).toEqual(['ask_user_question', 'bash', 'bash_kill', 'bash_output', 'edit', 'read', 'subagent', 'todo_write', 'web_fetch', 'web_search', 'workflow', 'write'])
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
for (const entry of catalog) {
for (const schema of entry.schemas) {

View File

@@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
async function setup() {
@@ -62,6 +63,17 @@ describe('ToolRegistry', () => {
expect(schema.execute).toBeUndefined()
})
it('schemas() excludes timeoutMs — the budget must never reach the model', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({
name: 'budgeted', description: 'has a budget', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
}))
const schema = ctx.tools.schemas().find(s => s.name === 'budgeted')
expect(schema).toBeDefined()
expect('timeoutMs' in (schema as object)).toBe(false)
})
it('executes a tool and returns its content', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -272,6 +284,151 @@ describe('ToolRegistry', () => {
expect(order).toEqual(['pre:before', 'pre:after', 'post:before', 'post:after'])
})
it('runs tools/execute after an allowed pre-execute, around dispatch, and before post-execute', async () => {
const ctx = await setup()
const order: string[] = []
ctx.tools.register(defineTool({
name: 'traced',
description: 'echo',
parameters: { text: { type: 'string' } },
async execute(args) {
order.push('dispatch')
return [{ type: 'text' as const, text: args.text ?? '' }]
},
}))
ctx.on('tools/pre-execute', async (_exec, next) => { order.push('pre'); return next() })
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
order.push('execute:before')
const result = await next()
order.push('execute:after')
return result
})
ctx.on('tools/post-execute', async (_exec, _result, next) => { order.push('post'); return next() })
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'traced', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
// The around seam wraps dispatch; pre gates before it, post runs over its result.
expect(order).toEqual(['pre', 'execute:before', 'dispatch', 'execute:after', 'post'])
})
it('a pre-execute deny short-circuits before tools/execute (the seam never runs)', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
let entered = false
ctx.on('tools/pre-execute', async (_exec, _next): Promise<PreToolDecision> => ({ kind: 'deny', reason: 'nope' }))
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
entered = true
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: nope' })
expect(entered).toBe(false) // a denied call never enters the around-dispatch seam
})
it('a thrown tool is normalized to an isError result BEFORE a tools/execute listener sees next()', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'boom',
async execute() { throw new HarnessError('kaboom', 'BOOM') },
})
let seen: { isError: boolean; error?: unknown } | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
const result = await next()
// The base next() IS dispatch-with-normalization: the wrapper sees the
// normalized isError result, never a raw throw from the tool body.
seen = { isError: result.isError, error: result.error }
return result
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(seen).toEqual({ isError: true, error: { name: 'HarnessError', code: 'BOOM' } })
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: kaboom' })
})
it('a thrown tool normalized inside tools/execute still reaches post-execute', async () => {
const ctx = await setup()
ctx.tools.register({
...echoTool,
name: 'boom',
async execute() { throw new Error('exploded') },
})
let postSaw: boolean | undefined
ctx.on('tools/execute', async (_exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => next())
ctx.on('tools/post-execute', async (_exec, result, next) => {
postSaw = result.isError
return next()
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'boom', arguments: {} })
expect(postSaw).toBe(true) // the normalized isError still flows through post-execute
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({ text: 'Error: exploded' })
})
it('a tools/execute listener can replace exec.signal for the dispatched tool (deadline pattern)', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register({
...echoTool,
name: 'signal-probe',
async execute(_args, exec) {
seenSignal = exec.signal
return [{ type: 'text' as const, text: 'ok' }]
},
})
const upstream = new AbortController().signal
const replacement = new AbortController().signal
ctx.on('tools/execute', async (exec: ToolExecution, next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> => {
expect(exec.signal).toBe(upstream)
// Cordis next() ignores passed arguments, so a wrapper mutates exec in
// place (the documented "mutate the shared object, then delegate" idiom).
exec.signal = replacement
return next()
})
await ctx.tools.execute({ callId: CallId('c1'), name: 'signal-probe', arguments: {}, signal: upstream })
expect(seenSignal).toBe(replacement) // dispatch saw the wrapper's replacement, not the upstream
})
it('a tools/execute listener can short-circuit dispatch by returning a result without next()', async () => {
const ctx = await setup()
let dispatched = false
ctx.tools.register({
...echoTool,
name: 'never-runs',
async execute() { dispatched = true; return [] },
})
ctx.on('tools/execute', async (exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ callId: exec.callId, content: [{ type: 'text', text: 'short-circuited' }], isError: false }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'never-runs', arguments: {} })
expect(dispatched).toBe(false) // returning without next() skips core dispatch
expect(result.content[0]).toMatchObject({ text: 'short-circuited' })
})
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('wrapper 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: wrapper broke' }],
isError: true,
})
})
it('returns an isError result when a tools/pre-execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -989,6 +1146,38 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'raw', arguments: {} })
expect(result.isError).toBe(false)
})
it('attaches a positive-finite timeoutMs to the definition', () => {
const tool = defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: 30_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(tool.timeoutMs).toBe(30_000)
})
it('omits timeoutMs when not declared', () => {
const tool = defineTool({
name: 'x', description: 'd', parameters: {},
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(tool.timeoutMs).toBeUndefined()
})
it('throws when timeoutMs is zero or negative', () => {
const make = (ms: number) => defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: ms,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})
expect(() => make(0)).toThrow('timeoutMs must be a positive finite number')
expect(() => make(-5)).toThrow('positive finite number')
})
it('throws when timeoutMs is non-finite', () => {
expect(() => defineTool({
name: 'x', description: 'd', parameters: {}, timeoutMs: Infinity,
async execute() { return [{ type: 'text' as const, text: 'ok' }] },
})).toThrow('positive finite number')
})
})
describe('defineTool presentation (presentCall / presentResult)', () => {

View File

@@ -10,3 +10,7 @@ The filesystem stack: a provider seam (text IO + atomic mutation with an optiona
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) |
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas. The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it.
## No timeouts on file IO
`read`/`write`/`edit` take **no** `timeoutMs`, and the provider seam arms no deadline — unlike bash and web, which consume [`@deepseek-ai/dsh-timeout`](../util/timeout/README.md). A local syscall is best-effort-abortable at most: a timeout could not force an in-progress `fsync`/`rename` to stop, so a deadline here would be a knob that cannot deliver on its promise. Adding one would also be an implicit default in the exact place explicit-over-implicit forbids. Both reference agents (Claude Code, Codex) leave file IO untimed for the same reason; cancellation still propagates through the tool-execution signal for best-effort abort at syscall boundaries.

9
packages/guard/README.md Normal file
View File

@@ -0,0 +1,9 @@
# guard/ — loop-hygiene guard family
Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability.
| Package | Role | ctx key |
|---|---|---|
| `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) |
Reminders travel as `additionalContext` on the `tools/post-execute` decision; the agent loop appends them as logged `context/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log.

View File

@@ -0,0 +1,37 @@
# @deepseek-ai/dsh-repeat-tool-guard
An advisory loop-breaker, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The decision (retry differently, gather more evidence, or finish) stays entirely with the model: a legitimately repeated call is delayed by nothing and blocked by nothing. Decision record: [the repeat-tool-guard RFC](../../../docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md).
## Config
```yaml
- id: repeat-tool-guard
name: '@deepseek-ai/dsh-repeat-tool-guard'
config:
thresholds: [3, 5, 8] # default; consecutive counts that trigger a reminder
include: [] # tool-name patterns to track; empty ⇒ all tools
exclude: [todo_write] # tool-name patterns transparent to the chain
argumentsPreviewChars: 500 # default; cap on arguments quoted in the detailed reminder
```
`thresholds` fails loud at plugin load: an empty list, a non-integer, a value below 2, or a duplicate throws, never a silent fall-back to defaults; `argumentsPreviewChars` equally rejects anything but an integer >= 1. The list is normalized to ascending order; the FIRST threshold delivers a short generic nudge, every later threshold delivers the detailed form naming the tool, the run length, and the canonical arguments — head-truncated at `argumentsPreviewChars` with an omitted-count marker, so a looping `write`/`edit` payload cannot ride into the next request unbounded (the chain key always compares the FULL canonical string; the cap bounds the reminder, never the detection).
`include`/`exclude` entries support `*` wildcards and are predicates over whatever tools exist at call time, not references to registry entries — a pattern matching no currently registered tool is NOT an error (`exclude: [mcp_*]` stays valid in a deployment that loads no MCP tools), unlike `toolOrder`'s referent check.
## Chain semantics
The chain key is `(tool name, canonical arguments)` — canonicalization is a deep key-sort plus `JSON.stringify`, so argument objects differing only in property order count as identical. A call identical to the previous tracked call increments the agent's consecutive counter; a different tracked call resets it to 1.
- **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it.
- **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking.
- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on.
- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state.
- **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost.
## Reminder delivery
Reminders ride the post-execute decision's `additionalContext` (source `{kind: 'plugin', plugin: 'repeat-tool-guard'}`), never a `content` replacement: the `tool/result` event stays the tool's own output for audit. The loop buffers the context and appends it as a `context/message` after the step's tool results, which the session renders as the tagged synthetic-user envelope — so the reminder is model-visible, source-attributed, and reconstructable from the session log with no new session event. The guard always delegates via `next()` and folds its reminder onto the downstream decision (both variants — a blocked call still gets the nudge); when a downstream listener attached its own `additionalContext`, the fold concatenates content and carries the guard's `source` (a `HookContext` holds one `MessageSource`; `source.kind` is what framing depends on).
## Testing
Unit suites drive a real agent loop against a mock adapter (no network) and cover the chain semantics above to per-file 100%. The snapshot tier owns the transcript surface: a scripted-replay scenario repeats a call five times and pins both reminder tiers (gentle at 3, detailed at 5) as `context/message`s in the ACP transcript.

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-repeat-tool-guard",
"description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"schemastery": "^3.18.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,268 @@
/**
* Repeat-tool-call guard: advisory loop-breaker for agents stuck re-issuing
* the same tool call with identical arguments.
*
* Not a model-facing tool — it registers no tool, never vetoes or rewrites a
* call, and adds exactly one behavior: watch each agent's stream of tool calls
* through the `tools/post-execute` waterfall, count runs of consecutive calls
* to the same tool with identical canonicalized arguments, and at configured
* run lengths fold an escalating advisory reminder onto the decision's
* `additionalContext`. The loop appends that context as a logged
* `context/message` after the step's tool results, so the reminder is
* model-visible, source-attributed, and reconstructable from the session log
* with no new session event. Decision record:
* docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md.
*
* ```yaml
* - id: repeat-tool-guard
* name: '@deepseek-ai/dsh-repeat-tool-guard'
* config:
* thresholds: [3, 5, 8] # consecutive counts that trigger a reminder
* include: [] # tool-name patterns to track; empty = all tools
* exclude: [todo_write] # tool-name patterns transparent to the chain
* ```
*
* Chain state is keyed per {@link AgentId} — the tool registry is a
* context-level singleton whose waterfalls interleave every agent's calls, so
* a shared counter would let one agent's repetition trip another's reminder.
* State is in-memory only: a session resumed from persistence starts with a
* fresh chain (the guard is a heuristic nudge, not a logged invariant).
*
* Plugin export shape: named exports, NO default. The cordis Loader's
* `unwrapExports` does `exports.default ?? exports`, so a stray default would
* collapse the module to the bare `apply` (see docs/postmortem/0001).
*
* @module @deepseek-ai/dsh-repeat-tool-guard
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import type { AgentId, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent'
import type { MessageSource } from '@deepseek-ai/dsh-llm'
import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools'
export const name = 'repeat-tool-guard'
/**
* Plugin config, validated by the same-named schemastery schema plus the
* load-time checks in `apply` (misconfiguration fails loud: an empty
* `thresholds` list, a non-integer, a value below 2, or a duplicate throws at
* plugin load, never a silent fall-back). `include`/`exclude` entries are
* `*`-wildcard predicates over tool names at call time, not references to
* registry entries — a pattern matching no currently registered tool is valid
* (`exclude: [mcp_*]` must stay legal in a deployment that loads no MCP tools).
*/
export interface Config {
/** Consecutive-repeat counts that trigger a reminder (default `[3, 5, 8]`). */
thresholds?: number[]
/** Tool-name patterns to track; empty means every tool is tracked. */
include?: string[]
/** Tool-name patterns transparent to the chain (neither count nor reset). */
exclude?: string[]
/**
* Maximum characters of canonical arguments quoted in the DETAILED reminder
* (default 500). Large payloads (a `write` body, a long command) would
* otherwise ride into the next request unbounded — precisely in a loop
* scenario; the cap bounds the reminder, never the detection (the chain key
* always compares the FULL canonical string).
*/
argumentsPreviewChars?: number
}
export const Config: z<Config> = z.object({
thresholds: z.array(z.number()).default([3, 5, 8]),
include: z.array(z.string()).default([]),
exclude: z.array(z.string()).default([]),
argumentsPreviewChars: z.number().default(500),
})
/**
* The `{kind:'plugin'}` source stamped on every reminder this guard injects —
* the label is load-bearing (an unlabeled context would render as a user
* prompt in derived history).
*/
const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'repeat-tool-guard' }
/**
* The gentle first-threshold reminder. Keyed to `thresholds[0]`, not a literal
* count, so a custom first threshold keeps the gentle-then-detailed escalation.
*/
const GENTLE_REMINDER =
'You are repeating the exact same tool call with identical arguments. '
+ 'Carefully analyze the previous result before calling again: if the task is '
+ 'not complete, try a different approach or different arguments instead of '
+ 'repeating the call.'
/** The detailed later-threshold reminder naming the tool, the run length, and the canonical arguments. */
function detailedReminder(toolName: string, count: number, canonicalArguments: string): string {
return 'Repeated tool call detected:\n'
+ `- tool: ${toolName}\n`
+ `- consecutive_calls: ${count}\n`
+ `- arguments: ${canonicalArguments}\n`
+ 'The repeated calls are not making progress. Do not call this tool with '
+ 'these exact arguments again. Inspect the latest result and choose a '
+ 'different action, different arguments, or finish the task if enough '
+ 'evidence has been gathered.'
}
/**
* Deep key-sort of a parsed-JSON value so two argument objects that differ
* only in property order canonicalize identically. Arguments reach the guard
* as the loop's `JSON.parse` output (or its raw-string fallback for malformed
* argument JSON), so JSON's value domain is the whole input domain — no
* bigint, cycle, or `undefined` handling exists because no input path can
* produce them.
*/
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) return value.map(sortJsonValue)
if (value !== null && typeof value === 'object') {
const record = value as Record<string, unknown>
const sorted: Record<string, unknown> = {}
for (const key of Object.keys(record).sort()) {
sorted[key] = sortJsonValue(record[key])
}
return sorted
}
return value
}
/** Canonical string form of a call's arguments: deep key-sort, then stringify. */
function canonicalize(argumentsValue: unknown): string {
return JSON.stringify(sortJsonValue(argumentsValue))
}
/** Compile one `*`-wildcard pattern to an anchored RegExp (every other regex metacharacter is matched literally). */
function wildcardToRegExp(pattern: string): RegExp {
const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, String.raw`\$&`)
return new RegExp(`^${escaped.replaceAll('*', '.*')}$`)
}
/**
* Head-truncate the canonical arguments for quoting in the detailed reminder,
* marking how much was omitted. Bounds only the model-visible text — the
* chain key always uses the full canonical string.
*/
function previewArguments(canonical: string, cap: number): string {
if (canonical.length <= cap) return canonical
return `${canonical.slice(0, cap)}… (+${canonical.length - cap} more chars)`
}
/**
* Validate `thresholds` per the fail-loud contract and return them sorted
* ascending (the escalation rule reads `thresholds[0]` as the gentle tier, so
* order is normalized here, once).
*/
function validateThresholds(values: number[]): number[] {
if (values.length === 0) {
throw new Error('repeat-tool-guard: `thresholds` must not be empty')
}
for (const value of values) {
if (!Number.isInteger(value) || value < 2) {
throw new Error(`repeat-tool-guard: invalid threshold ${value} — every threshold must be an integer >= 2`)
}
}
if (new Set(values).size !== values.length) {
throw new Error('repeat-tool-guard: `thresholds` must not contain duplicates')
}
return [...values].sort((a, b) => a - b)
}
/**
* Concatenate the guard's reminder context with a downstream listener's
* optional one so folding drops neither. The merged block carries the guard's
* `source` — a `HookContext` holds one `MessageSource` and the seam cannot
* represent mixed provenance; the rendered `context/message` only
* distinguishes by `source.kind`, so a downstream plugin's text is still
* correctly framed as plugin context.
*/
function concatContext(ours: HookContext, theirs: HookContext | undefined): HookContext {
if (!theirs) return ours
return { content: [...ours.content, ...theirs.content], source: ours.source }
}
/** One agent's consecutive-repeat chain: the last tracked call's identity key and its run length. */
interface Chain {
key: string
count: number
}
/**
* Install the guard's listeners.
* @param ctx - plugin context; listeners are scoped to it and disposed with it.
* @param config - validated {@link Config}; `thresholds` is re-checked fail-loud here.
*/
export function apply(ctx: Context, config: Config): void {
// schemastery's .default() guarantees the fields are set after validation.
const thresholds = validateThresholds(config.thresholds as number[])
const thresholdSet = new Set(thresholds)
const includePatterns = (config.include as string[]).map(wildcardToRegExp)
const excludePatterns = (config.exclude as string[]).map(wildcardToRegExp)
const argumentsPreviewChars = config.argumentsPreviewChars as number
if (!Number.isInteger(argumentsPreviewChars) || argumentsPreviewChars < 1) {
throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`)
}
const chains = new Map<AgentId, Chain>()
/** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */
function tracked(toolName: string): boolean {
if (includePatterns.length > 0 && !includePatterns.some(pattern => pattern.test(toolName))) return false
return !excludePatterns.some(pattern => pattern.test(toolName))
}
/**
* Advance the calling agent's chain for one attempt and return the reminder
* to deliver, if this attempt's run length hits a configured threshold.
* Counting happens here — in post-execute — because denied calls also flow
* through this waterfall (`ToolRegistry.execute` routes a deny through the
* same pipeline), and a model hammering a denied call is exactly the loop
* worth breaking.
*/
function observe(exec: ToolExecution): HookContext | undefined {
// A direct `ctx.tools.execute()` caller has no model to remind and no id
// to key on; only agent-loop calls participate.
if (!exec.agent) return undefined
if (!tracked(exec.name)) return undefined
const canonical = canonicalize(exec.arguments)
const key = JSON.stringify([exec.name, canonical])
const chain = chains.get(exec.agent.id)
const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1
chains.set(exec.agent.id, { key, count })
if (!thresholdSet.has(count)) return undefined
const text = count === thresholds[0]
? GENTLE_REMINDER
: detailedReminder(exec.name, count, previewArguments(canonical, argumentsPreviewChars))
return { content: [{ type: 'text', text }], source: PLUGIN_SOURCE }
}
// Observe-and-enrich, never veto: count first (state advances regardless of
// the downstream outcome), DELEGATE so a later listener can still block or
// replace, then fold the reminder onto whatever came back — additionalContext
// rides both decision variants, so a blocked call still gets the nudge.
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => {
const reminder = observe(exec)
const downstream = await next()
if (!reminder) return downstream
if (downstream.kind === 'block') {
return { kind: 'block', feedback: downstream.feedback, additionalContext: concatContext(reminder, downstream.additionalContext) }
}
return {
kind: 'accept',
...downstream.content !== undefined ? { content: downstream.content } : {},
additionalContext: concatContext(reminder, downstream.additionalContext),
}
})
// A user interjection changes the context; repetition across it is not a
// loop. Pure reset hook: always delegates (attaching nothing, vetoing
// nothing).
ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise<PromptDecision> => {
chains.delete(agent.id)
return next()
})
// Drop state when an agent goes away, bounding the map over harness lifetime.
ctx.on('agent/status', (agent, status) => {
if (status === 'disposed') chains.delete(agent.id)
})
}

View File

@@ -0,0 +1,401 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools'
import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent'
import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop'
import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard'
import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
/**
* Behavior suite for the repeat-tool-call guard: chain semantics (identical /
* different-tracked / untracked-transparent / per-agent / resets), threshold
* escalation incl. the `thresholds[0]` gentle-text rule, canonicalization,
* fold-onto-downstream-decision, and fail-loud config validation — all driven
* through a real agent loop against a scripted mock adapter (no network).
*/
/** Boot the core spine + the guard; the caller registers adapters and extra listeners. */
async function harness(config: Config = {}): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(RepeatToolGuard, config)
ctx.tools.register(defineTool({ name: 'probe', description: 'p', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
ctx.tools.register(defineTool({ name: 'other', description: 'o', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } }))
return ctx
}
function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise<void> {
return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) })
}
/** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */
function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] {
return [...agent.session.events]
.filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message')
.map(e => ({
text: e.data.content.map(block => block.type === 'text' ? block.text : '').join('|'),
source: e.data.source,
}))
}
const GUARD_SOURCE = { kind: 'plugin', plugin: 'repeat-tool-guard' }
describe('threshold escalation', () => {
it('reminds gently at the first default threshold (3) and in detail at the second (5)', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
...Array.from({ length: 5 }, (_, i) => toolCallResponse(`c${i}`, 'probe', { q: 'same' })),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
expect(found[0]!.text).toContain('repeating the exact same tool call')
expect(found[0]!.source).toEqual(GUARD_SOURCE)
expect(found[1]!.text).toContain('consecutive_calls: 5')
expect(found[1]!.text).toContain('- tool: probe')
expect(found[1]!.text).toContain('{"q":"same"}')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
})
it('keys the gentle text to thresholds[0], not the literal 3', async () => {
const ctx = await harness({ thresholds: [4, 2] }) // unsorted on purpose: normalized ascending
const adapter = new MockAdapter([
...Array.from({ length: 4 }, (_, i) => toolCallResponse(`c${i}`, 'probe', {})),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
expect(found[0]!.text).toContain('repeating the exact same tool call') // gentle at 2
expect(found[1]!.text).toContain('consecutive_calls: 4') // detailed at 4
})
})
describe('chain semantics', () => {
it('caps the detailed reminder arguments at argumentsPreviewChars (detection still keys on the full string)', async () => {
const ctx = await harness({ thresholds: [2, 3], argumentsPreviewChars: 24 })
const bigPayload = 'x'.repeat(400)
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { body: bigPayload }),
toolCallResponse('c2', 'probe', { body: bigPayload }),
toolCallResponse('c3', 'probe', { body: bigPayload }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2) // gentle at 2, detailed at 3 — full-key matching survived the cap
const detailed = found[1]!.text
expect(detailed).toContain('- arguments: {"body":"xxxxxxxxxxxxxx') // 24-char head
expect(detailed).toContain('… (+387 more chars)')
expect(detailed).not.toContain(bigPayload)
})
it('a different tracked call resets the chain', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
toolCallResponse('c3', 'other', {}), // tracked, different → reset
toolCallResponse('c4', 'probe', { q: 1 }),
toolCallResponse('c5', 'probe', { q: 1 }),
toolCallResponse('c6', 'probe', { q: 1 }), // 3rd consecutive AFTER the reset
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1)
})
it('excluded calls are transparent: they neither count nor reset', async () => {
const ctx = await harness({ exclude: ['other'] })
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'other', {}), // excluded → invisible to the chain
toolCallResponse('c3', 'probe', { q: 1 }),
toolCallResponse('c4', 'other', {}),
toolCallResponse('c5', 'probe', { q: 1 }), // 3rd consecutive probe
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(1)
expect(found[0]!.text).toContain('repeating the exact same tool call')
})
it('include patterns track only matching tools (wildcard star)', async () => {
const ctx = await harness({ include: ['pro*'] })
const adapter = new MockAdapter([
toolCallResponse('c1', 'other', {}),
toolCallResponse('c2', 'other', {}),
toolCallResponse('c3', 'other', {}), // 3 identical, but untracked
toolCallResponse('c4', 'probe', {}),
toolCallResponse('c5', 'probe', {}),
toolCallResponse('c6', 'probe', {}), // 3 identical, tracked
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(1)
expect(found[0]!.text).toContain('repeating the exact same tool call')
})
it('escapes regex metacharacters in patterns (a dot matches only a literal dot)', async () => {
const ctx = await harness({ exclude: ['pr.be'] }) // would match 'probe' as a regex; must not as a wildcard
const adapter = new MockAdapter([
...Array.from({ length: 3 }, (_, i) => toolCallResponse(`c${i}`, 'probe', {})),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) // probe was NOT excluded
})
it('canonicalization ignores property order, deeply', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { a: 1, nested: { x: [1, 2], y: null } }),
toolCallResponse('c2', 'probe', { nested: { y: null, x: [1, 2] }, a: 1 }),
toolCallResponse('c3', 'probe', { a: 1, nested: { x: [1, 2], y: null } }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1) // all three canonicalize identically
})
it('keys chains per agent: one agent repeating never trips another', async () => {
const ctx = await harness()
ctx.llm.registerAdapter(['mock-a'], new MockAdapter([
toolCallResponse('a1', 'probe', { q: 1 }),
toolCallResponse('a2', 'probe', { q: 1 }),
textResponse('done'),
]))
ctx.llm.registerAdapter(['mock-b'], new MockAdapter([
toolCallResponse('b1', 'probe', { q: 1 }),
toolCallResponse('b2', 'probe', { q: 1 }),
toolCallResponse('b3', 'probe', { q: 1 }),
textResponse('done'),
]))
const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' })
const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' })
agentA.send([{ type: 'text', text: 'go' }])
agentB.send([{ type: 'text', text: 'go' }])
await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)])
expect(reminders(agentA)).toHaveLength(0) // 2 repeats < 3, despite B's 3 in the same registry
expect(reminders(agentB)).toHaveLength(1)
})
it('a new user prompt resets the chain', async () => {
const ctx = await harness()
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('turn one done'),
toolCallResponse('c3', 'probe', { q: 1 }), // without the reset this would be the 3rd
textResponse('turn two done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
agent.send([{ type: 'text', text: 'again' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(0)
})
it('drops an agent chain on disposal', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.llm.registerAdapter(['mock'], new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
textResponse('done'),
toolCallResponse('c2', 'probe', { q: 1 }), // same id, fresh agent: count 1, not 2
textResponse('done'),
]))
// Loop agents are torn down by disposing the scope that created them
// (the loop.spec pattern): a child plugin fiber owns `first`.
let first!: ReactLoopAgent
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
first = inner.agentLoop.create(AgentId('reused'), { model: 'mock' })
}, { inject: ['agentLoop'] }))
first.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, first)
await fiber.dispose()
await first.done
const second = ctx.agentLoop.create(AgentId('reused'), { model: 'mock' })
second.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, second)
expect(reminders(second)).toHaveLength(0)
})
it('counts denied calls: hammering a denied tool still draws the reminder', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/pre-execute', async () => ({ kind: 'deny' as const, reason: 'sealed' }))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(1)
})
it('ignores direct executes with no agent (they neither crash nor advance any chain)', async () => {
const ctx = await harness({ thresholds: [2] })
const direct = await ctx.tools.execute({ callId: CallId('d1'), name: 'probe', arguments: { q: 1 } })
expect(direct.isError).toBe(false)
ctx.llm.registerAdapter(['mock'], new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2
textResponse('done'),
]))
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
expect(reminders(agent)).toHaveLength(0)
})
})
describe('fold onto the downstream decision', () => {
it('folds the reminder onto a downstream block and keeps its feedback', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/post-execute', async () => ({
kind: 'block' as const,
feedback: [{ type: 'text' as const, text: 'nope' }],
additionalContext: { content: [{ type: 'text' as const, text: 'downstream-ctx' }], source: { kind: 'plugin' as const, plugin: 'test' } },
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(2)
// Call 1: below threshold — the downstream context passes through untouched.
expect(found[0]!.text).toBe('downstream-ctx')
expect(found[0]!.source).toEqual({ kind: 'plugin', plugin: 'test' })
// Call 2: reminder folded in front, single merged context, the guard's source.
expect(found[1]!.text).toContain('repeating the exact same tool call')
expect(found[1]!.text).toContain('|downstream-ctx')
expect(found[1]!.source).toEqual(GUARD_SOURCE)
// The block's feedback reached the tool result unchanged.
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
expect(results.every(r => r.data.isError)).toBe(true)
expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'nope' }])
})
it('preserves a downstream accept content replacement while folding', async () => {
const ctx = await harness({ thresholds: [2] })
ctx.on('tools/post-execute', async () => ({
kind: 'accept' as const,
content: [{ type: 'text' as const, text: 'replaced' }],
}))
const adapter = new MockAdapter([
toolCallResponse('c1', 'probe', { q: 1 }),
toolCallResponse('c2', 'probe', { q: 1 }),
textResponse('done'),
])
ctx.llm.registerAdapter(['mock'], adapter)
const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' })
agent.send([{ type: 'text', text: 'go' }])
await waitForIdle(ctx, agent)
const found = reminders(agent)
expect(found).toHaveLength(1)
expect(found[0]!.text).toContain('repeating the exact same tool call')
const results = [...agent.session.events].filter((e): e is SessionEvent<'tool/result'> => e.type === 'tool/result')
expect(results[1]!.data.content).toEqual([{ type: 'text', text: 'replaced' }])
})
})
describe('config validation fails loud', () => {
async function spine(): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(AgentRegistry)
await ctx.plugin(AgentLoop, { agents: [] })
return ctx
}
it('rejects an empty thresholds list', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [] })).rejects.toThrow(/must not be empty/)
})
it('rejects a threshold below 2', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [1, 3] })).rejects.toThrow(/integer >= 2/)
})
it('rejects a non-integer threshold', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [2.5] })).rejects.toThrow(/integer >= 2/)
})
it('rejects duplicate thresholds', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { thresholds: [3, 3] })).rejects.toThrow(/duplicates/)
})
it('rejects a non-positive or fractional argumentsPreviewChars', async () => {
const ctx = await spine()
await expect(ctx.plugin(RepeatToolGuard, { argumentsPreviewChars: 0 })).rejects.toThrow(/argumentsPreviewChars/)
const ctx2 = await spine()
await expect(ctx2.plugin(RepeatToolGuard, { argumentsPreviewChars: 12.5 })).rejects.toThrow(/argumentsPreviewChars/)
})
})

View File

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

View File

@@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i
Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)``data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed.
The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
The repo's `engines.node` is `^22.19.0 || >=24.0.0` (Node 22.19+ or 24+), matching the LTS floor required by the installed Pi adapter dependency; `node:sqlite` itself ships without the `--experimental-sqlite` flag from Node 22.13 (LTS) and 23.4 / 24 (Current) on. The range deliberately excludes Node 23 because that line is non-LTS/EOL and still has flagged runtime features before 23.6. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software).
## Contract semantics over rows

View File

@@ -4,8 +4,9 @@ Packages that exist to serve development, testing, and the examples rather than
| Package | Role | ctx key |
|---|---|---|
| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
| `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) |
`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the snapshot tier's harness/normalizer/suite machinery so every example's suite is a scenario table over one shared, gate-covered implementation. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.

View File

@@ -0,0 +1,36 @@
# `@deepseek-ai/dsh-acp-snapshot`
The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). An example gets a full snapshot suite from a scenario table plus a fixtures directory; every compare/guard mechanic lives here, under the per-file coverage gate, instead of being copied per example.
Three layers, importable separately:
- **`runScenario` (harness)** — boots the real agent bin as a subprocess via tsx (unbuilt, Loader path), drives it over ACP JSON-RPC stdio from a deterministic `input.json` script, tees raw stdout for the golden + purity check, and harvests every persisted session JSONL (parent + subagent children, primary-first) after a graceful stdin-EOF shutdown. Parameterized by `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath` — absolute paths; the subprocess cwd is a temp dir outside the repo).
- **Normalizers** — pure functions turning the two captured surfaces into stable text: `normalizeStdout` (JSON-RPC ids → first-seen sequence; UUIDs/cwd → tokens; doubles as the stdout-purity check), `normalizeSessionLog` (times zeroed, `seq` kept), and the composable `scrubRequestHeaders` (header bulk → `{{system}}`/`{{tools}}`, structure kept — [pinned-header RFC](../../../docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)).
- **`defineAcpSnapshotSuite` (factory)** — registers the whole describe/it tree for a scenario table: per-scenario golden + re-persisted-log compares, record-mode fixture write-back, the per-suite header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin, non-pinning fixtures header-scrubbed). Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
```ts
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { defineAcpSnapshotSuite, type Scenario } from '@deepseek-ai/dsh-acp-snapshot'
const SCENARIOS: Scenario[] = [
{ name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
]
defineAcpSnapshotSuite({
agent: { // absolute paths, resolved from the suite's own location
binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)),
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)),
},
snapshotsDir: join(dirname(fileURLToPath(import.meta.url)), 'snapshots'),
scenarios: SCENARIOS, // exactly one entry sets pinsHeader
mode: process.env.DSH_SNAPSHOT === 'record' ? 'record' : 'replay',
})
```
The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. Fixture roles, record/replay semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug).

View File

@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-acp-snapshot",
"description": "ACP snapshot suite kit: real-subprocess scenario harness, golden normalizers, and the suite factory behind the keyless snapshot tier",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
"tsx": "^4.22.4",
"vitest": "^4.1.8"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,469 @@
/**
* Shared subprocess harness for ACP snapshot suites. A library module driven by
* the suite factory in ./suite.ts (and directly by harness-level specs); each
* example's `*.snapshot.ts` names its own agent-under-test paths.
*
* It boots the REAL agent bin subprocess via the cordis Loader (so the
* export-shape bug class stays guarded — see docs/postmortem/0001), drives it
* over real ACP JSON-RPC stdio with a deterministic input script, tees raw
* stdout (for the golden + a purity check) into an SDK `ClientSideConnection`,
* and — in record mode — harvests the persisted session JSONL after a graceful
* shutdown flush. The pure normalizers in ./normalize.ts turn the captured
* stdout frames and the session-log events into stable, snapshot-able text.
*
* See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md.
*
* @module @deepseek-ai/dsh-acp-snapshot/harness
*/
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, delimiter } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Readable, Writable } from 'node:stream'
import {
ClientSideConnection,
ndJsonStream,
PROTOCOL_VERSION,
type Agent as AcpAgent,
type Client,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
} from '@agentclientprotocol/sdk'
// Resolve tsx's ESM loader to an ABSOLUTE path once: the child runs with its
// cwd in a temp dir OUTSIDE the repo, where a bare `--import tsx` would not
// resolve from node_modules. import.meta.resolve gives this package's tsx
// regardless of the child cwd.
const tsxLoader = fileURLToPath(import.meta.resolve('tsx'))
/**
* The agent composition a scenario runs against: which bin to boot and which
* leaf config it loads. All paths are ABSOLUTE — the subprocess cwd is a temp
* dir outside the repo, so relative resolution would miss; a suite resolves
* them from its own `import.meta.url`.
*/
export interface AgentUnderTest {
/** The agent bin entry (e.g. `packages/ui/acp-agent/src/bin.ts`), run unbuilt via tsx. */
binScript: string
/**
* The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps
* it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so
* one path serves both modes.
*/
configPath: string
/**
* The repo-root tsconfig whose `paths` map resolves the unbuilt workspace
* imports. Passed to the child as `TSX_TSCONFIG_PATH`: tsx finds a tsconfig
* by searching UP from the child's cwd — a temp dir outside the repo — so
* without the explicit pin the dsh-* imports fail before the bin writes a
* byte.
*/
tsconfigPath: string
}
/**
* One step of a scenario's deterministic input script (`input.json`). The
* harness interprets these in order. `newSession` captures the server-issued
* (random) session id into a `{{sessionId}}` variable that later steps
* reference, since a committed file cannot know the id in advance.
*
* `promptAndCancel` sends a prompt WITHOUT awaiting its response, waits until
* the client observes the first streamed `agent_message_chunk` (so the emitted
* frames deterministically precede the cancellation), then cancels the turn —
* the only way to exercise a cancel deterministically (a plain `prompt` step
* awaits the response, which a cancel/hang scenario would block on forever).
*/
export type InputStep =
| { op: 'initialize'; terminalOutput?: boolean }
| { op: 'newSession' }
| { op: 'newSessionExpectError'; additionalDirectories?: string[] }
| { op: 'prompt'; text: string }
| { op: 'promptExpectError'; text: string }
| { op: 'promptAndCancel'; text: string }
| { op: 'cancel' }
/** A scenario's `input.json`: an ordered list of input steps. */
export interface InputScript {
steps: InputStep[]
/**
* Ordered answers for the agent's `session/request_permission` round-trips,
* consumed FIFO — the Nth request gets the Nth answer. Each answer selects
* by option KIND: option ids are agent-issued randoms a committed script
* cannot know, while kinds are the ACP-stable vocabulary, so the client maps
* kind → the offered `optionId` at answer time. A request beyond the queue
* (or with no queue at all) is answered `cancelled` — the stub behavior a
* scenario without approvals relies on. A scripted kind the request does
* not offer REJECTS the run: the scenario scripted an impossible click,
* and {@link runScenario} throws once the in-flight step settles (the
* agent itself just sees `cancelled`, so it cannot absorb the bug).
*/
permissionAnswers?: PermissionAnswer[]
}
/** One scripted answer to a permission request: which offered option kind to select. */
export interface PermissionAnswer {
/** The `PermissionOption.kind` to select (`allow_once`, `reject_always`, …). */
kind: 'allow_once' | 'allow_always' | 'reject_once' | 'reject_always'
}
/** One harvested session log plus the identifying facts off its header line. */
export interface HarvestedLog {
/** The recorded session id (header `id`). */
id: string
/** Session creation time (header `createdAt`) — the child-ordering key. */
createdAt: number
/** The parent session id, if this log is a subagent child (header `parentSession`). */
parentSession?: string
/** The full `.jsonl` file content. */
content: string
}
/** The result of running a scenario: raw stdout + the harvested session log(s). */
export interface RunResult {
/** Raw stdout bytes (decoded utf8), every newline-delimited JSON-RPC frame. */
rawStdout: string
/** stderr (for diagnostics on failure). */
stderr: string
/** The session id the server issued (undefined if no session was created). */
sessionId?: string
/** The temp cwd the session ran in (the bash workspace). */
cwd: string
/**
* Every persisted session log harvested after the run, ordered primary-first:
* the top-level (parent) session — the one with no `parentSession` — then each
* subagent child by ascending `createdAt`. A single-session scenario harvests
* exactly one; a nested-agent scenario harvests the parent plus one per child.
*/
sessionLogs: HarvestedLog[]
}
/** How to run one scenario: the agent to boot, the mode, and the fixture wiring. */
export interface RunOptions {
/** The agent composition to boot. */
agent: AgentUnderTest
/** `replay` (default, keyless) or `record` (real API, harvests the log). */
mode: 'replay' | 'record'
/** The recorded session JSONL fixture path (replay reads it; record writes near it). */
fixtureFile: string
/** Optional sidecar override path (replay). */
overrideFile?: string
/**
* Recorded SUBAGENT child-session fixture paths (replay). A nested-agent
* scenario ships one per child (`session.1.jsonl`, …); the harness forwards
* them to `dsh-llm-replay` via `$DSH_SNAPSHOT_CHILD_FILES` so each child
* session replays from its own recorded script. Empty for single-session
* scenarios. Ignored in record mode (children are harvested, not replayed).
*/
childFiles?: string[]
/**
* Optional `<scenario>/workspace/` directory whose contents are copied into
* the temp cwd BEFORE the run — the standard way to seed files the agent
* operates on (a file to read, edit, or grep). Absent for scenarios that
* start from an empty workspace.
*/
workspaceDir?: string
}
/**
* Run a scenario end-to-end against a freshly-spawned subprocess. Owns the
* child and its temp dirs; always tears them down. Returns the captured stdout
* and (record mode) the harvested session-log path.
*
* @param input The scenario's input script (steps + optional permission answers).
* @param opts The agent to boot, the mode, and the fixture wiring.
* @returns The captured stdout/stderr, session id, temp cwd, and harvested logs.
*/
export async function runScenario(input: InputScript, opts: RunOptions): Promise<RunResult> {
const cwd = await mkdtemp(join(tmpdir(), 'acp-snap-cwd-'))
const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-snap-sessions-'))
// Everything past the temp-dir creation runs under a try/finally that always
// removes both dirs — so a failure in workspace seeding, spawn, or any step
// never leaks them (the "e2e tests own their resources" rule).
let child: ChildProcessWithoutNullStreams | undefined
let sessionId: string | undefined
let sessionLogs: HarvestedLog[] = []
const rawBuffers: Buffer[] = []
const stderrChunks: string[] = []
try {
// Seed the workspace if the scenario ships one (a file the agent reads/edits).
// Copied into the temp cwd so the agent's bash tools see it; the goldens
// normalize the cwd, so the seeded paths stay stable across runs.
if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) {
await cp(opts.workspaceDir, cwd, { recursive: true })
}
const env: NodeJS.ProcessEnv = {
...process.env,
TSX_TSCONFIG_PATH: opts.agent.tsconfigPath,
DSH_SNAPSHOT: opts.mode,
DSH_SNAPSHOT_FILE: opts.fixtureFile,
DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot,
...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {},
...opts.childFiles !== undefined && opts.childFiles.length > 0
? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) }
: {},
}
child = spawn(
process.execPath,
['--import', tsxLoader, opts.agent.binScript, opts.agent.configPath],
{ cwd, env, stdio: ['pipe', 'pipe', 'pipe'] },
)
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => stderrChunks.push(c))
// Tee raw stdout: accumulate the bytes for the golden + purity check, and ALSO
// feed the same bytes to the SDK client through a passthrough. Buffer the raw
// bytes (not per-chunk utf8 strings) and decode once at the end, so a
// multibyte sequence split across two 'data' events can't corrupt the golden.
const passthrough = new Readable({ read() {} })
child.stdout.on('data', (buf: Buffer) => {
rawBuffers.push(buf)
passthrough.push(buf)
})
child.stdout.on('end', () => passthrough.push(null))
const stream = ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(passthrough) as ReadableStream<Uint8Array>,
)
// Watcher so a step can block until the client OBSERVES a particular
// session/update — used by promptAndCancel to pin frame order (send cancel
// only after the streamed agent_message_chunk has arrived, so those frames
// deterministically precede the cancelled prompt response).
const updateWaiters: { match: (u: SessionNotification['update']) => boolean; resolve: () => void }[] = []
const waitForUpdate = (match: (u: SessionNotification['update']) => boolean): Promise<void> =>
new Promise<void>(resolve => updateWaiters.push({ match, resolve }))
// Permission answers are consumed FIFO across the whole run; exhaustion
// falls back to `cancelled` so approval-free scenarios keep the plain stub.
const permissionQueue = [...input.permissionAnswers ?? []]
// A scenario bug detected inside a client callback (a scripted permission
// kind the agent never offered). It cannot fail the run from in there: a
// callback throw only becomes a JSON-RPC error RESPONSE to the agent, and
// a tolerant agent treats that as a denial and carries on — the run (or
// worse, a record) would absorb the impossible click silently. So the
// callback answers `cancelled` (a well-defined path for the agent),
// captures the error here, and the step loop fails the run on it.
let scriptError: Error | undefined
const makeClient = (_agent: AcpAgent): Client => ({
sessionUpdate(params: SessionNotification): Promise<void> {
for (let i = updateWaiters.length - 1; i >= 0; i--) {
const waiter = updateWaiters[i]
// The index is always in-bounds (i only decreases; splice removes at
// i, so lower entries stay valid); the guard satisfies
// noUncheckedIndexedAccess.
/* v8 ignore next 1 -- unreachable in-bounds guard, see above */
if (waiter === undefined) continue
if (waiter.match(params.update)) {
updateWaiters.splice(i, 1)
waiter.resolve()
}
}
return Promise.resolve()
},
requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const answer = permissionQueue.shift()
if (answer === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } })
const option = params.options.find(o => o.kind === answer.kind)
if (option === undefined) {
// The scenario scripted a click the agent never offered — a scenario
// bug. Captured (last one wins; same bug class either way) and
// answered `cancelled`; the step loop rejects the run on it.
scriptError = new Error(
`snapshot-harness: scripted permission answer ${answer.kind} not among `
+ `the offered options [${params.options.map(o => o.kind).join(', ')}]`,
)
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
}
return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } })
},
})
const client = new ClientSideConnection(makeClient, stream)
for (const step of input.steps) {
await runStep(client, step, cwd, waitForUpdate, () => sessionId, (id) => { sessionId = id })
// A permission exchange happens while a step's request is in flight, so
// by the time the step settles any script bug it exposed is captured —
// fail the run HERE, as a harness error, rather than hoping the agent's
// reaction to the answer perturbs the transcript.
if (scriptError !== undefined) throw scriptError
}
// Done driving: close stdin so the server disposes gracefully (flushing
// persistence) and exits. Then await exit so the harvested log is complete.
child.stdin.end()
await waitForExit(child)
// Harvest EVERY persisted log (parent + any subagent children) while the
// temp dirs still exist, ordered primary-first.
sessionLogs = await harvestSessionLogs(sessionsRoot)
} finally {
// Failure-safe teardown: kill a still-running child and drop the temp dirs
// even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a
// process or dir. `child` is undefined only if spawn itself threw.
if (child !== undefined && child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL')
await waitForExit(child)
}
await rm(cwd, { recursive: true, force: true })
await rm(sessionsRoot, { recursive: true, force: true })
}
return {
rawStdout: Buffer.concat(rawBuffers).toString('utf8'),
stderr: stderrChunks.join(''),
cwd,
...sessionId !== undefined ? { sessionId } : {},
sessionLogs,
}
}
/** Drive one input step over the client connection. */
async function runStep(
client: ClientSideConnection,
step: InputStep,
cwd: string,
waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise<void>,
getSessionId: () => string | undefined,
setSessionId: (id: string) => void,
): Promise<void> {
switch (step.op) {
case 'initialize':
await client.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: step.terminalOutput === true ? { _meta: { terminal_output: true } } : {},
})
return
case 'newSession': {
const { sessionId } = await client.newSession({ cwd, mcpServers: [] })
setSessionId(sessionId)
return
}
case 'newSessionExpectError': {
// The bridge rejects a session/new that widens the workspace scope
// (non-empty additionalDirectories / mcpServers — unimplemented). The SDK
// surfaces that as a rejected RPC; swallow it so the run completes and the
// error frame is captured in the transcript.
await client.newSession({
cwd,
mcpServers: [],
...step.additionalDirectories !== undefined ? { additionalDirectories: step.additionalDirectories } : {},
}).then(
() => { throw new Error('snapshot-harness: expected session/new to be rejected but it succeeded') },
() => { /* expected: the bridge rejected the unsupported workspace scope */ },
)
return
}
case 'prompt': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: prompt before newSession')
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
return
}
case 'promptExpectError': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptExpectError before newSession')
// The model fails this turn (a recorded provider error), so the bridge
// answers the prompt with a JSON-RPC error and the SDK rejects. That
// rejection IS the expected editor experience — swallow it so the run
// completes and the stdout transcript (the error frame) is captured.
await client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
.then(() => { throw new Error('snapshot-harness: expected the prompt to fail but it succeeded') },
() => { /* expected: the turn failed and the bridge returned an error */ })
return
}
case 'promptAndCancel': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: promptAndCancel before newSession')
// Dispatch the prompt WITHOUT awaiting (a hang fixture never resolves on
// its own). To pin frame order deterministically, wait until the client
// has OBSERVED the hang's streamed agent_message_chunk before cancelling —
// so those update frames always precede the cancelled prompt response in
// the transcript (without this, the late chunk and the response race).
// Then cancel and await the prompt, which the bridge settles as
// `cancelled` once the abort propagates.
const promptDone = client.prompt({ sessionId, prompt: [{ type: 'text', text: step.text }] })
await waitForUpdate(u => u.sessionUpdate === 'agent_message_chunk')
await client.cancel({ sessionId })
await promptDone
return
}
case 'cancel': {
const sessionId = getSessionId()
if (sessionId === undefined) throw new Error('snapshot-harness: cancel before newSession')
await client.cancel({ sessionId })
return
}
default:
throw new Error(`snapshot-harness: unknown input op ${JSON.stringify(step)}`)
}
}
/** Resolve once the child process exits (any code/signal). */
function waitForExit(child: ChildProcessWithoutNullStreams): Promise<void> {
// Race guard: both call sites run within one synchronous frame of
// stdin.end()/kill(), so the exit event cannot have been delivered yet;
// kept for any future caller that awaits in between.
/* v8 ignore next 1 -- unreachable race guard, see above */
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve()
return new Promise<void>(resolve => child.once('exit', () => { resolve() }))
}
/**
* Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each
* header line, and return them ordered primary-first: the top-level session (no
* `parentSession`) leads, then each subagent child by ascending `createdAt`.
*
* The JSONL backend lays sessions out as `<root>/<cwd-bucket>/<encoded-id>.jsonl`
* (one bucket per cwd), so a parent and its same-cwd in-process child land in
* the SAME bucket — collecting all files across all buckets catches both (a
* first-match short-circuit would silently drop the child). Returns `[]` if no
* log was produced (a no-session scenario).
*/
async function harvestSessionLogs(root: string): Promise<HarvestedLog[]> {
let cwdDirs: string[]
try {
cwdDirs = await readdir(root)
} catch {
return []
}
const logs: HarvestedLog[] = []
for (const dir of cwdDirs) {
const sub = join(root, dir)
let files: string[]
try {
files = await readdir(sub)
} catch {
continue
}
for (const f of files) {
if (!f.endsWith('.jsonl')) continue
const content = await readFile(join(sub, f), 'utf8')
const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}'
const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown }
logs.push({
id: typeof header.id === 'string' ? header.id : '',
createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0,
...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {},
content,
})
}
}
// Primary (no parentSession) first, then children by ascending createdAt. A
// scenario has exactly one top-level session. In the synchronous cut sibling
// children are created strictly sequentially, so their createdAt values are
// strictly ordered; the recordedId tiebreak only keeps a degenerate
// same-millisecond collision (unreachable here) deterministic. This harvest
// order must match the replay load order in dsh-llm-replay's loadSessionScripts
// so session.<n>.jsonl maps to the same child on record and replay — replay
// re-sorts childFiles by the same key, so the two stay consistent.
logs.sort((a, b) => {
const ap = a.parentSession === undefined ? 0 : 1
const bp = b.parentSession === undefined ? 0 : 1
return ap - bp || a.createdAt - b.createdAt || a.id.localeCompare(b.id)
})
return logs
}

View File

@@ -0,0 +1,38 @@
/**
* ACP snapshot suite kit — the shared machinery behind the keyless snapshot
* tier (`pnpm run test:snapshot`). Three layers, composable per example:
* the subprocess scenario harness ({@link runScenario}), the pure golden
* normalizers ({@link normalizeStdout} / {@link normalizeSessionLog} /
* {@link scrubRequestHeaders}), and the suite factory
* ({@link defineAcpSnapshotSuite}) that registers a scenario table as a full
* describe/it tree. An example's `*.snapshot.ts` supplies only its
* {@link AgentUnderTest} paths, its snapshots directory, and its
* {@link Scenario} table.
*
* NOTE: ./suite.ts imports vitest, so this package is importable only inside a
* vitest run — a support-tier constraint stated in the README.
*
* @module @deepseek-ai/dsh-acp-snapshot
*/
export {
runScenario,
type AgentUnderTest,
type HarvestedLog,
type InputScript,
type InputStep,
type PermissionAnswer,
type RunOptions,
type RunResult,
} from './harness.ts'
export {
normalizeSessionLog,
normalizeStdout,
scrubRequestHeaders,
type NormalizeContext,
} from './normalize.ts'
export {
defineAcpSnapshotSuite,
type Scenario,
type SnapshotSuiteOptions,
} from './suite.ts'

View File

@@ -0,0 +1,199 @@
/**
* Pure normalizers for the ACP snapshot goldens. They replace the
* non-deterministic values in the two captured surfaces — the stdout JSON-RPC
* transcript and the persisted session JSONL — with stable tokens, so a golden
* compare reflects behavior, not run-to-run noise. Kept dependency-free and
* side-effect-free so they unit-test trivially.
*
* Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp`
* cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header);
* JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event
* `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's
* `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq`
* (deterministic — `seq = log.length`, part of the event-log contract).
*
* A separate, composable normalizer — {@link scrubRequestHeaders} — replaces
* the bulky request-header CONTENT (the composed system prompt and the tool
* schema list) with `{{system}}`/`{{tools}}` tokens. It is deliberately NOT
* folded into {@link normalizeSessionLog}: each suite's one header-pinning
* scenario compares that content verbatim, every other scenario composes the
* scrub in (the `pinsHeader` flag on the scenario table, consumed by the suite
* factory in ./suite.ts; see the pinned-header RFC,
* docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md).
*
* See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md.
*
* @module @deepseek-ai/dsh-acp-snapshot/normalize
*/
const SESSION_ID = '{{sessionId}}'
const CWD = '{{cwd}}'
const SYSTEM = '{{system}}'
const TOOLS = '{{tools}}'
/** A UUID v4 string, the shape `randomUUID()` produces for session ids. */
const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi
/** Inputs the normalizers need to recognize a run's volatile values. */
export interface NormalizeContext {
/** The session id(s) the run issued — replaced with `{{sessionId}}`. */
sessionIds: string[]
/** The temp cwd the run used — replaced with `{{cwd}}`. */
cwd: string
}
/** Replace cwd, session ids, and any stray UUID with stable tokens in a string. */
function scrubString(value: string, ctx: NormalizeContext): string {
let out = value
// cwd first (longest, most specific), then explicit session ids, then any
// residual UUID (covers ids that appear in places we didn't enumerate).
out = out.split(ctx.cwd).join(CWD)
for (const id of ctx.sessionIds) out = out.split(id).join(SESSION_ID)
out = out.replace(UUID_RE, SESSION_ID)
return out
}
/** Recursively scrub a parsed JSON value (strings replaced; structure kept). */
function scrubValue(value: unknown, ctx: NormalizeContext): unknown {
if (typeof value === 'string') return scrubString(value, ctx)
if (Array.isArray(value)) return value.map(v => scrubValue(v, ctx))
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = scrubValue(v, ctx)
return out
}
return value
}
/**
* Normalize a raw stdout transcript (newline-delimited JSON-RPC frames) into a
* stable golden in the SAME shape as the wire: one compact JSON frame per line
* (NDJSON), with the JSON-RPC `id` rewritten to a per-transcript sequence
* (1, 2, 3, …) and all volatile strings scrubbed. Throws if any non-empty line
* is not valid JSON — that doubles as the stdout-purity check (no logger leaked
* onto the protocol).
*
* @param rawStdout The captured stdout bytes, decoded utf8.
* @param ctx The run's volatile values to scrub.
* @returns The normalized NDJSON transcript, one frame per line.
*/
export function normalizeStdout(rawStdout: string, ctx: NormalizeContext): string {
const lines = rawStdout.split('\n').filter(line => line.trim().length > 0)
// Map each distinct JSON-RPC id (request/response correlate by id) to a stable
// sequence number, in first-seen order, so id churn doesn't perturb the golden.
const idSeq = new Map<string, number>()
const stableId = (id: unknown): number => {
const key = JSON.stringify(id)
let n = idSeq.get(key)
if (n === undefined) { n = idSeq.size + 1; idSeq.set(key, n) }
return n
}
const frames = lines.map((line) => {
const frame = JSON.parse(line) as Record<string, unknown>
if ('id' in frame && frame.id !== undefined && frame.id !== null) {
frame.id = stableId(frame.id)
}
return scrubValue(frame, ctx) as Record<string, unknown>
})
return frames.map(f => JSON.stringify(f)).join('\n') + '\n'
}
/**
* Normalize a session JSONL log into a stable golden: the header line's
* volatile fields (`createdAt`, `id`, `cwd`) and every event's `time` are
* zeroed/scrubbed, all volatile strings scrubbed, and `seq` is LEFT INTACT
* (deterministic by contract). Output is JSONL in the same shape as the input —
* one compact record per line.
*
* @param rawLog The raw session `.jsonl` content.
* @param ctx The run's volatile values to scrub.
* @returns The normalized JSONL log, one record per line.
*/
export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): string {
const lines = rawLog.split('\n').filter(line => line.trim().length > 0)
const records = lines.map((line) => {
const record = JSON.parse(line) as Record<string, unknown>
// Header line: { type: 'session', createdAt, id, cwd, … }.
if (record.type === 'session') {
if ('createdAt' in record) record.createdAt = 0
} else if ('time' in record) {
// Event line: zero the epoch-ms timestamp; keep seq (deterministic).
record.time = 0
// A hook/result carries the hook's wall-clock runtime (`data.durationMs`),
// which is run-to-run noise like `time` — zero it so the golden reflects
// the hook's decision/exit, not how long the shell took.
if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') {
const data = record.data as Record<string, unknown>
if ('durationMs' in data) data.durationMs = 0
}
}
return scrubValue(record, ctx) as Record<string, unknown>
})
return records.map(r => JSON.stringify(r)).join('\n') + '\n'
}
/**
* Replace request-header CONTENT in a session JSONL with stable tokens,
* keeping its structure: a `request/header` event's `data.header.system` →
* `{{system}}` and `data.header.tools` → `{{tools}}`; a
* `request/header-delta` event keeps every structural fact — the system
* delta's `keepStart`/`keepEnd` line positions and inserted-line COUNT (one
* `{{system}}` token per inserted line), the tools delta's
* added/removed/changed tool NAMES — and tokenizes only the bulk (prompt
* text; each added/changed schema's fields other than `name` → `{{tools}}`),
* so two different deltas still compare different.
* Absent fields stay absent — WHETHER a header carried a system prompt or
* tools is behavior and stays visible; `config` and `reason` are small and
* stable, so they stay verbatim (a model swap churns every fixture by design
* — it invalidates the recorded responses; a prompt/schema edit churns none —
* replay never reads this content, see dsh-llm-replay).
*
* Only lines with something to scrub are re-serialized; every other line
* passes through byte-for-byte, so the transform is idempotent and applying
* it to an already-scrubbed fixture is a no-op — the on-disk-fixtures guard
* in ./suite.ts relies on exactly that.
*
* @param rawLog The raw session `.jsonl` content.
* @returns The JSONL with header content tokenized, other lines byte-identical.
*/
export function scrubRequestHeaders(rawLog: string): string {
const lines = rawLog.split('\n')
const out = lines.map((line) => {
if (line.trim().length === 0) return line
const record = JSON.parse(line) as Record<string, unknown>
const data = record.data as Record<string, unknown> | null | undefined
if (data === null || typeof data !== 'object') return line
if (record.type === 'request/header') {
const header = data.header as Record<string, unknown> | null | undefined
if (header === null || typeof header !== 'object') return line
if (!('system' in header) && !('tools' in header)) return line
if ('system' in header) header.system = SYSTEM
if ('tools' in header) header.tools = TOOLS
return JSON.stringify(record)
}
if (record.type === 'request/header-delta') {
let touched = false
const system = data.system as Record<string, unknown> | null | undefined
if (system !== null && typeof system === 'object' && Array.isArray(system.insert)) {
system.insert = system.insert.map(() => SYSTEM)
touched = true
}
const tools = data.tools as Record<string, unknown> | null | undefined
if (tools !== null && typeof tools === 'object') {
if (Array.isArray(tools.added)) { tools.added = tools.added.map(scrubToolSchema); touched = true }
if (Array.isArray(tools.changed)) { tools.changed = tools.changed.map(scrubToolSchema); touched = true }
}
return touched ? JSON.stringify(record) : line
}
return line
})
return out.join('\n')
}
/** Tokenize one tool schema's bulk (description, parameters, anything else), keeping its identifying `name`. */
function scrubToolSchema(tool: unknown): unknown {
if (tool === null || typeof tool !== 'object' || Array.isArray(tool)) return tool
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(tool)) out[k] = k === 'name' ? v : TOOLS
return out
}

View File

@@ -0,0 +1,384 @@
/**
* The ACP snapshot suite factory (REPLAY by default, keyless). A suite is a
* scenario table plus a snapshots directory: each scenario under
* `<snapshotsDir>/<name>/` ships an `input.json` (the client stdin script) and
* a `session.jsonl` fixture; replay boots the real agent subprocess
* (./harness.ts), drives it, and diffs the normalized stdout transcript
* against the committed `stdout.golden.jsonl`. For model scenarios it ALSO
* checks the re-persisted session log — against the `session.jsonl` fixture
* itself, not a separate golden: the fixture doubles as the replay source
* (recorded scenarios) and the expected produced log (both sides normalized
* before comparing).
*
* Request-header content (the composed system prompt + tool schemas riding on
* `request/header` events) is pinned by exactly ONE scenario per suite — the
* one with `pinsHeader` — and scrubbed to `{{system}}`/`{{tools}}` tokens in
* every other fixture and compare, so a prompt or tool-schema edit churns one
* committed line instead of every fixture. A per-run uniformity guard keeps
* the single pin sound: every live header must equal the pinned one, and no
* header-delta may appear outside the pinning scenario (see the
* pinned-header RFC,
* docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md).
*
* `pnpm run test:snapshot:record` (DSH_SNAPSHOT=record + -u) re-records the
* `session.jsonl` fixtures against the real API and refreshes the stdout golden
* in one pass; the caller resolves that env into {@link SnapshotSuiteOptions}
* (env reading stays at the suite edge, not in this library).
*
* @module @deepseek-ai/dsh-acp-snapshot/suite
*/
import { readFile, readdir, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { type AgentUnderTest, type HarvestedLog, type InputScript, runScenario } from './harness.ts'
import { type NormalizeContext, normalizeSessionLog, normalizeStdout, scrubRequestHeaders } from './normalize.ts'
/** A snapshot scenario and how its fixtures are produced. */
export interface Scenario {
name: string
/** Whether the scenario drives at least one model turn (so a JSONL golden applies). */
hasModelTurn: boolean
/**
* Whether the run persists a comparable session log to diff against the
* `session.jsonl` fixture. Defaults to {@link hasModelTurn} (a model turn
* always produces a log worth comparing). Set it independently for a scenario
* that produces a non-trivial log WITHOUT a model turn — e.g. a prompt blocked
* by a `UserPromptSubmit` hook, which opens a `rejected` turn carrying `hook/*`
* events but never calls the model.
*/
comparesLog?: boolean
/**
* Whether `test:snapshot:record` regenerates this scenario's `session.jsonl`
* from the LIVE API. `recorded` scenarios are model-driven and reproducible;
* `authored` scenarios (fixtures hand-written or hand-harvested — e.g. a
* provider error or a cancel the live API can't be coaxed into
* deterministically, a deterministic hook scenario, or a scripted repetition
* a live model won't reproduce) are NEVER re-recorded.
*/
recorded: boolean
/**
* Whether replay is driven by a hand-written `replay.override.json` sidecar
* (a `ReplayEntry[]` that REPLACES the script derived from `session.jsonl`)
* — the throw/hang cases chunks cannot express. The fixture guard requires
* the sidecar exactly when this is set: the harness forwards the file purely
* on existence, so an unregistered stray sidecar would silently replace the
* derived script — the guard fails loud on either mismatch. Defaults to
* false (replay derives from the fixture's `assistant/chunk` events).
*/
overridden?: boolean
/**
* How many SUBAGENT child sessions this scenario records beyond the top-level
* one (0 for a single-session scenario). Each child rides in a sibling fixture
* `session.<n>.jsonl` (1-based); replay forwards them to `dsh-llm-replay` so
* each child session replays from its own script, and record mode writes the
* harvested child logs back to those files. Defaults to 0.
*/
childSessions?: number
/**
* Whether THIS scenario's fixtures keep the full request-header content (the
* composed system prompt and tool schema list on `request/header` /
* `request/header-delta` events) and compare it verbatim. Exactly one
* scenario per suite pins it; every other scenario stores and compares that
* content as `{{system}}`/`{{tools}}` tokens ({@link scrubRequestHeaders}),
* so a system prompt or tool-schema change shows up as ONE committed-fixture
* diff, not one per scenario. One pin suffices because header composition is
* suite-uniform (parent, spawn child, and fork child all compose the same
* prompt-modulo-cwd and the same tools) — and that premise is ASSERTED, not
* assumed: every non-pinning run's live headers must equal the pinned
* fixture's (normalized), so a session-dependent header (say, a restricted
* subagent toolset) fails loud until it gets its own pinning scenario.
* Defaults to false.
*/
pinsHeader?: boolean
}
/** One suite's inputs: the agent to boot, where its fixtures live, and its scenario table. */
export interface SnapshotSuiteOptions {
/** The agent composition every scenario boots. */
agent: AgentUnderTest
/** Absolute path of the suite's `snapshots/` directory (one subdir per scenario). */
snapshotsDir: string
/** The scenario table; exactly one entry must set `pinsHeader`. */
scenarios: Scenario[]
/**
* `replay` (keyless, the default tier) or `record` (live API; re-records the
* `recorded` scenarios' fixtures and refreshes the vitest goldens under
* `--update`). The caller derives this from `$DSH_SNAPSHOT` — env reading
* stays outside this library.
*/
mode: 'replay' | 'record'
}
/**
* The sibling child-fixture paths for a scenario (`session.1.jsonl` …).
*
* @param dir The scenario's snapshots directory (`<snapshotsDir>/<name>`).
* @param childSessions How many subagent child sessions the scenario records.
* @returns One path per child, 1-based, in fixture order.
*/
export function childFixturePaths(dir: string, childSessions: number): string[] {
return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`))
}
/**
* Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own
* header line (`{ type: 'session', id, cwd }`). A committed fixture carries the
* session id and cwd of the run that harvested it — different from the live
* replay run — so normalizing it against the live run's ctx would leave those
* recorded values unscrubbed. Reading them from the header scrubs the fixture's
* own id/cwd to the same `{{sessionId}}`/`{{cwd}}` tokens the replay output gets.
* An authored fixture whose header is already normalized (`id:'{{sessionId}}'`,
* `cwd:'{{cwd}}'`) yields those tokens as the volatile values, so scrubbing them
* is an idempotent no-op. A header with no `cwd` falls back to a sentinel that
* cannot occur in a log (NOT `''`, which `String.split` would match on every
* character boundary and corrupt the output).
*
* @param fixture The committed `session.jsonl` content.
* @returns The fixture's own volatile values, ready for {@link normalizeSessionLog}.
*/
export function fixtureContext(fixture: string): NormalizeContext {
const firstLine = fixture.split('\n').find(line => line.trim().length > 0) ?? '{}'
const header = JSON.parse(firstLine) as { id?: unknown; cwd?: unknown }
return {
sessionIds: typeof header.id === 'string' ? [header.id] : [],
cwd: typeof header.cwd === 'string' ? header.cwd : '\0no-cwd\0',
}
}
/**
* The `data.header` payload of every `request/header` event in a session
* JSONL, in log order, with the log's volatile values scrubbed first
* ({@link normalizeSessionLog}) so headers harvested from different runs —
* each embedding its own temp cwd in the composed prompt — compare on equal
* footing.
*
* @param rawLog The session `.jsonl` content to extract headers from.
* @param ctx The volatile values of the run that produced it.
* @returns The normalized `data.header` payloads, in log order.
*/
export function normalizedHeaders(rawLog: string, ctx: NormalizeContext): unknown[] {
return normalizeSessionLog(rawLog, ctx)
.split('\n')
.filter(line => line.trim().length > 0)
.map(line => JSON.parse(line) as { type?: unknown; data?: { header?: unknown } })
.filter(record => record.type === 'request/header')
.map(record => record.data?.header)
}
/**
* Count the `request/header-delta` events in a session JSONL.
*
* @param rawLog The session `.jsonl` content.
* @returns How many `request/header-delta` events the log carries.
*/
export function headerDeltaCount(rawLog: string): number {
return rawLog.split('\n')
.filter(line => line.trim().length > 0)
.filter(line => (JSON.parse(line) as { type?: unknown }).type === 'request/header-delta')
.length
}
/**
* Register the suite: one `describe` per scenario (the golden/log compares and
* the header-uniformity guard) plus the fixture guard block (no orphan
* scenario dirs, required files present, exactly one pin, non-pinning fixtures
* header-scrubbed). Must run at vitest collection time — it calls
* `describe`/`it`. Throws immediately if no scenario pins the header (the
* uniformity guard would have nothing to compare against).
*
* @param options The agent, snapshots directory, scenario table, and mode.
*/
export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const { agent, snapshotsDir, scenarios, mode } = options
const RECORDING = mode === 'record'
/** The suite's single header-pinning scenario. Guarded here (and by a meta-test) so the pin cannot silently vanish. */
const pinningScenario = scenarios.find(s => s.pinsHeader === true)
if (pinningScenario === undefined) throw new Error('acp-snapshot: no scenario pins the request-header content')
for (const scenario of scenarios) {
describe(`snapshot: ${scenario.name}`, () => {
// In RECORD mode, only re-run the `recorded` (live-API) scenarios; the
// `authored` ones (sidecar-driven errors/cancel) are never re-recorded.
it.skipIf(RECORDING && !scenario.recorded)('matches the goldens', async () => {
const dir = join(snapshotsDir, scenario.name)
const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript
const overrideFile = join(dir, 'replay.override.json')
const workspaceDir = join(dir, 'workspace')
const childSessions = scenario.childSessions ?? 0
const result = await runScenario(input, {
agent,
mode,
fixtureFile: join(dir, 'session.jsonl'),
...existsSync(overrideFile) ? { overrideFile } : {},
// In REPLAY, forward the recorded child fixtures so each subagent session
// replays from its own script. In RECORD they are harvested, not read.
...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {},
...existsSync(workspaceDir) ? { workspaceDir } : {},
})
// Scrub every volatile id the run produced: the ACP server-issued session
// id plus every harvested log's recorded id (a subagent child id never
// surfaces over ACP, but it appears in the child's own log header). The
// normalizer's UUID catch-all covers any we don't enumerate.
const ctx: NormalizeContext = {
sessionIds: [
...result.sessionId !== undefined ? [result.sessionId] : [],
...result.sessionLogs.map(l => l.id),
],
cwd: result.cwd,
}
// RECORD mode (recorded model scenarios only): persist the freshly-harvested
// logs back to their fixtures — the primary to session.jsonl, each child to
// session.<n>.jsonl in harvest order. `--update` refreshes the Vitest
// goldens but NOT these fixtures, so write them here. A non-pinning
// scenario's fixtures are written header-scrubbed, so a re-record can
// never smuggle the full prompt/schema content back into every fixture.
const scrub = scenario.pinsHeader === true
? (log: string): string => log
: scrubRequestHeaders
if (RECORDING && scenario.recorded && scenario.hasModelTurn) {
expect(result.sessionLogs.length, 'record produced no session log to harvest').toBeGreaterThan(0)
expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`)
.toBe(childSessions + 1)
await writeFile(join(dir, 'session.jsonl'), scrub((result.sessionLogs[0] as HarvestedLog).content))
for (let i = 1; i < result.sessionLogs.length; i++) {
await writeFile(join(dir, `session.${i}.jsonl`), scrub((result.sessionLogs[i] as HarvestedLog).content))
}
}
await expect(normalizeStdout(result.rawStdout, ctx))
.toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl'))
// A model turn always produces a log worth comparing; a hook scenario can
// produce one without a model turn (a `rejected` turn carrying `hook/*`).
const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn
if (comparesLog) {
// The harvested logs (primary-first) must match their committed fixtures
// 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS
// OWN volatile values — the live run's via `ctx`, the committed fixture's
// via its own header (a committed file cannot share the live run's ids).
// Unless this scenario pins the header, both sides ALSO pass through
// scrubRequestHeaders: the live log carries the real prompt/schemas, the
// fixture carries the `{{system}}`/`{{tools}}` tokens, and the scrub is
// idempotent — so the compare checks the header's presence, position,
// reason, and config, but not its bulk content (pinned once, in the
// `pinsHeader` scenario).
expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1)
const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)]
for (let i = 0; i < fixtureFiles.length; i++) {
const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content)
const fixture = scrub(await readFile(join(dir, fixtureFiles[i] as string), 'utf8'))
expect(normalizeSessionLog(harvested, ctx), `${fixtureFiles[i]} mismatch`)
.toEqual(normalizeSessionLog(fixture, fixtureContext(fixture)))
}
}
// Header-uniformity guard: the single pin is sound only while every
// session in the suite composes the SAME header and keeps it for the
// whole run. Assert both halves live. (1) Every request/header the run
// produced (parent, spawn child, fork child, initial or resume) must
// equal the pinned fixture's header after each side is normalized
// against its own volatile values. (2) No request/header-delta may
// appear at all — a mid-run header change diverges from the pin by
// construction, and its content would be invisible under the scrub. If
// either fails, either the header changed (update the pin: re-record or
// hand-edit the pinning scenario's fixture) or composition became
// session-dependent by design (give the divergent shape its own
// pinning scenario).
if (scenario.pinsHeader !== true) {
const pinnedFixture = await readFile(join(snapshotsDir, pinningScenario.name, 'session.jsonl'), 'utf8')
const pinned = normalizedHeaders(pinnedFixture, fixtureContext(pinnedFixture))
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) must carry exactly one request/header`)
.toBe(1)
for (const log of result.sessionLogs) {
expect(headerDeltaCount(log.content), `session ${log.id}: a request/header-delta in a non-pinning scenario`)
.toBe(0)
const headers = normalizedHeaders(log.content, ctx)
for (const [k, header] of headers.entries()) {
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(pinned[0])
}
}
}
})
})
}
describe('snapshot fixtures', () => {
it('every scenario directory is registered (no orphans)', async () => {
// toMatchFileSnapshot does not prune orphaned golden/fixture files, so a
// renamed/removed scenario could leave a stale dir that nothing exercises.
// Fail loud on any snapshots/<dir> not present in the scenario table.
const entries = await readdir(snapshotsDir, { withFileTypes: true })
const onDisk = entries.filter(e => e.isDirectory()).map(e => e.name).sort()
const registered = scenarios.map(s => s.name).sort()
expect(onDisk).toEqual(registered)
})
it('every registered scenario has its required fixture files', () => {
// Every scenario has an input script and an stdout golden. EVERY scenario
// also needs `session.jsonl`: the suite boots `llm-replay` with that path
// as the replay source for ALL scenarios (the factory passes
// `fixtureFile: <dir>/session.jsonl` unconditionally), and `loadReplayScript`
// throws "fixture not found" when it is absent and no override replaces it.
// A no-model scenario ships a header-only `session.jsonl` (it derives to an
// empty script — no model call is made); a model scenario's fixture also
// doubles as the expected-log artifact the run is diffed against. The
// `replay.override.json` sidecar is matched BOTH ways against the table's
// `overridden` flag: required when set, forbidden when not — the harness
// forwards the file purely on existence, so an unregistered stray sidecar
// would silently replace the derived script.
for (const { name, overridden, childSessions } of scenarios) {
const dir = join(snapshotsDir, name)
expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true)
expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true)
expect(existsSync(join(dir, 'session.jsonl')), `${name}/session.jsonl`).toBe(true)
expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json presence must match \`overridden\``)
.toBe(overridden === true)
// A nested-agent scenario ships one child fixture per recorded subagent
// session (`session.1.jsonl` …), the replay source for that child session.
for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) {
expect(existsSync(childFixture), childFixture).toBe(true)
}
}
})
it('exactly one scenario pins the request-header content', () => {
// Zero pins would drop the prompt/schema surface from the suite entirely;
// two would split it. One pin per suite is the design (pinned-header RFC);
// WHICH scenario pins is the scenario table's reviewable choice.
expect(scenarios.filter(s => s.pinsHeader === true).map(s => s.name)).toEqual([pinningScenario.name])
})
it('committed fixtures carry request-header content ONLY in the pinning scenario', async () => {
// The whole point of the pin: a system-prompt or tool-schema change must
// churn exactly one committed line. A non-pinning fixture that carries the
// full header (a hand-recorded file, or a header line hand-edited out of
// its canonical JSON form) silently reopens the suite-wide churn, so fail
// loud here: every non-pinning session*.jsonl must be a fixed point of
// scrubRequestHeaders (apply the scrub to fix a violation), and the
// pinning scenario's fixtures must NOT be (their content IS the pin).
for (const scenario of scenarios) {
const dir = join(snapshotsDir, scenario.name)
const files = [
'session.jsonl',
...Array.from({ length: scenario.childSessions ?? 0 }, (_, i) => `session.${i + 1}.jsonl`),
]
for (const file of files) {
const fixture = await readFile(join(dir, file), 'utf8')
if (scenario.pinsHeader === true) {
expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} must PIN the full header content`)
.not.toEqual(fixture)
} else {
expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} carries unscrubbed header content`)
.toEqual(fixture)
}
}
}
})
})
}

View File

@@ -0,0 +1,232 @@
/**
* Scripted fake ACP agent bin for `dsh-acp-snapshot`'s unit specs. Speaks
* newline-delimited JSON-RPC on stdio like the real `dsh-acp-agent` bin, but
* every behavior — how prompts settle, whether session/new rejects, which
* session logs get persisted, what filesystem noise to leave — comes from a
* `behavior.json` sitting NEXT to the `$DSH_SNAPSHOT_FILE` fixture, so a spec
* scripts a whole subprocess run from data. The specs launch it through the
* REAL `runScenario` spawn path (tsx loader, temp cwd, env plumbing), so the
* harness plumbing is exercised for real; only the agent behind the protocol
* is scripted.
*
* The specs (not the golden tier) own this bin: it asserts nothing, echoes
* observable facts into `session/update` text chunks (env probe, permission
* outcome, seeded-workspace listing) for the spec to read off `rawStdout`, and
* exits 0 on stdin EOF after writing the scripted logs — mirroring the real
* bin's dispose-flush-exit shape.
*/
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { createInterface } from 'node:readline'
/** One scripted session log: a file path under the sessions root plus its JSONL lines. */
interface ScriptedLog {
/** Path relative to `$DSH_SNAPSHOT_SESSIONS_ROOT`, e.g. `bucket/a.jsonl` (an empty dir segment is invalid). */
file: string
/**
* The JSONL records. String templates `{{CWD}}` and `{{SID}}` are replaced
* with the run's real cwd and the ACP session id this bin issued, so a
* written log carries genuine volatile values for the normalizers to scrub.
*/
lines: unknown[]
}
/** The whole scripted behavior for one run. Every field defaults to the least surprising choice. */
interface Behavior {
/** Reject every `session/new` (exercises the expect-error step without extra dirs). */
rejectNewSession?: boolean
/** Reject `session/new` only when `additionalDirectories` is non-empty (the real bridge's rule). */
rejectExtraDirs?: boolean
/** How `session/prompt` settles: a clean response, a JSON-RPC error, or a hang until `session/cancel`. */
prompt?: 'respond' | 'error' | 'hang-until-cancel'
/** Before responding to a prompt, send a `session/request_permission` request and echo its outcome as a chunk. */
permissionProbe?: boolean
/** Echo the `DSH_SNAPSHOT_*` env the harness set as a chunk (spec-side env-plumbing assertions). */
echoEnv?: boolean
/** Echo the sorted cwd listing as a chunk (spec-side workspace-seeding assertions). */
echoWorkspace?: boolean
/** Write a line to stderr on boot (spec-side stderr-capture assertions). */
stderrNote?: string
/** Session logs to persist on stdin EOF. */
logs?: ScriptedLog[]
/** Leave a stray FILE directly under the sessions root (harvest must skip it). */
strayRootFile?: boolean
/** Leave a stray non-`.jsonl` file inside a bucket (harvest must skip it). */
strayBucketFile?: boolean
/** Delete the sessions root entirely (harvest must yield no logs). */
deleteSessionsRoot?: boolean
}
const sessionsRoot = process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? ''
const fixtureFile = process.env.DSH_SNAPSHOT_FILE ?? ''
const behavior: Behavior = fixtureFile === ''
? {}
: JSON.parse(readFileSync(join(dirname(fixtureFile), 'behavior.json'), 'utf8')) as Behavior
if (behavior.stderrNote !== undefined) process.stderr.write(`${behavior.stderrNote}\n`)
let nextOutboundId = 1000
let sessionId = ''
/**
* The cwd the client passed to `session/new` — used verbatim for `{{CWD}}`
* substitution, mirroring the real bin (whose persisted header carries the
* session cwd as given, NOT `process.cwd()`, which the OS realpaths — on
* macOS `/var/folders/…` vs `/private/var/folders/…`).
*/
let sessionCwd = ''
/** The parked prompt request id while `hang-until-cancel` waits for the cancel notification. */
let parkedPromptId: number | string | null = null
/** Resolvers for permission-probe responses, keyed by outbound request id. */
const pendingPermission = new Map<number, (outcome: unknown) => void>()
function send(frame: Record<string, unknown>): void {
process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', ...frame })}\n`)
}
function respond(id: number | string, result: unknown): void {
send({ id, result })
}
function respondError(id: number | string, message: string): void {
send({ id, error: { code: -32603, message } })
}
function chunk(text: string): void {
send({
method: 'session/update',
params: { sessionId, update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } } },
})
}
/** Substitute the `{{CWD}}`/`{{SID}}` templates through a scripted log record. */
function instantiate(value: unknown): unknown {
if (typeof value === 'string') return value.split('{{CWD}}').join(sessionCwd).split('{{SID}}').join(sessionId)
if (Array.isArray(value)) return value.map(instantiate)
if (value !== null && typeof value === 'object') {
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(value)) out[k] = instantiate(v)
return out
}
return value
}
async function handlePrompt(id: number | string): Promise<void> {
if ((behavior.prompt ?? 'respond') === 'hang-until-cancel') {
// A thought chunk BEFORE any message chunk: a promptAndCancel waiter
// watches for agent_message_chunk, so this exercises its non-matching
// update path while the waiter is armed.
send({
method: 'session/update',
params: { sessionId, update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'mulling' } } },
})
}
chunk('thinking about it')
if (behavior.echoEnv === true) {
chunk(`env:${JSON.stringify({
mode: process.env.DSH_SNAPSHOT,
override: process.env.DSH_SNAPSHOT_OVERRIDE ?? null,
childFiles: process.env.DSH_SNAPSHOT_CHILD_FILES ?? null,
})}`)
}
if (behavior.echoWorkspace === true) {
chunk(`workspace:${readdirSync(process.cwd()).sort().join(',')}`)
}
if (behavior.permissionProbe === true) {
const requestId = nextOutboundId++
const outcome = await new Promise<unknown>((resolve) => {
pendingPermission.set(requestId, resolve)
send({
id: requestId,
method: 'session/request_permission',
params: {
sessionId,
toolCall: { toolCallId: 'call_fake_1', title: 'fake tool', kind: 'execute', status: 'pending' },
options: [
{ optionId: 'opt-allow', name: 'Allow once', kind: 'allow_once' },
{ optionId: 'opt-reject', name: 'Reject once', kind: 'reject_once' },
],
},
})
})
chunk(`permission:${JSON.stringify(outcome)}`)
}
switch (behavior.prompt ?? 'respond') {
case 'respond':
respond(id, { stopReason: 'end_turn' })
return
case 'error':
respondError(id, 'model exploded')
return
case 'hang-until-cancel':
parkedPromptId = id
return
}
}
function handleFrame(frame: Record<string, unknown>): void {
const id = frame.id as number | string | undefined
const method = frame.method as string | undefined
const params = (frame.params ?? {}) as Record<string, unknown>
// A response to one of OUR outbound requests (the permission probe).
if (method === undefined && id !== undefined && typeof id === 'number' && pendingPermission.has(id)) {
const resolve = pendingPermission.get(id) as (outcome: unknown) => void
pendingPermission.delete(id)
resolve((frame.result as { outcome?: unknown } | undefined)?.outcome ?? null)
return
}
switch (method) {
case 'initialize':
respond(id as number | string, { protocolVersion: 1, agentCapabilities: { loadSession: false } })
return
case 'session/new': {
const extra = params.additionalDirectories as unknown[] | undefined
if (behavior.rejectNewSession === true || (behavior.rejectExtraDirs === true && extra !== undefined && extra.length > 0)) {
respondError(id as number | string, 'unsupported workspace scope')
return
}
sessionId = randomUUID()
sessionCwd = typeof params.cwd === 'string' ? params.cwd : process.cwd()
respond(id as number | string, { sessionId })
return
}
case 'session/prompt':
void handlePrompt(id as number | string)
return
case 'session/cancel':
if (parkedPromptId !== null) {
const parked = parkedPromptId
parkedPromptId = null
respond(parked, { stopReason: 'cancelled' })
}
return
default:
// Unknown method: a notification is ignored; a request gets an error so
// the SDK never waits forever on a frame this fake doesn't model.
if (id !== undefined) respondError(id, `unhandled method ${String(method)}`)
}
}
function flushLogsAndExit(): void {
for (const log of behavior.logs ?? []) {
const target = join(sessionsRoot, log.file)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, log.lines.map(l => JSON.stringify(instantiate(l))).join('\n') + '\n')
}
if (behavior.strayRootFile === true) writeFileSync(join(sessionsRoot, 'stray.txt'), 'not a bucket\n')
if (behavior.strayBucketFile === true) {
mkdirSync(join(sessionsRoot, 'bucket-noise'), { recursive: true })
writeFileSync(join(sessionsRoot, 'bucket-noise', 'notes.txt'), 'not a session log\n')
}
if (behavior.deleteSessionsRoot === true) rmSync(sessionsRoot, { recursive: true, force: true })
process.exit(0)
}
const rl = createInterface({ input: process.stdin })
rl.on('line', (line) => {
if (line.trim().length === 0) return
handleFrame(JSON.parse(line) as Record<string, unknown>)
})
rl.on('close', () => { flushLogsAndExit() })

View File

@@ -0,0 +1,13 @@
{
"prompt": "respond",
"logs": [
{ "file": "b/parent.jsonl", "lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 700, "cwd": "{{CWD}}" },
{ "type": "request/header", "seq": 0, "time": 3, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
]},
{ "file": "b/child.jsonl", "lines": [
{ "type": "session", "id": "abababab-cdcd-4efe-8ada-badabadabada", "createdAt": 800, "cwd": "{{CWD}}", "parentSession": "{{SID}}" },
{ "type": "request/header", "seq": 0, "time": 2, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
]}
]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "rec child" }] }

View File

@@ -0,0 +1,2 @@
{"type":"session","id":"abababab-cdcd-4efe-8ada-badabadabada","createdAt":800,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW","parentSession":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88"}
{"type":"request/header","seq":0,"time":2,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}

View File

@@ -0,0 +1,2 @@
{"type":"session","id":"f6fa7fcf-dd9c-4b39-8815-b25ddcebfd88","createdAt":700,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-KBQJbW"}
{"type":"request/header","seq":0,"time":3,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1,10 @@
{
"prompt": "respond",
"logs": [{
"file": "b/main.jsonl",
"lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 600, "cwd": "{{CWD}}" },
{ "type": "request/header", "seq": 0, "time": 4, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
]
}]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "rec pin" }] }

View File

@@ -0,0 +1,2 @@
{"type":"session","id":"ccdc749f-56f3-4267-9750-598b5c60b7b2","createdAt":600,"cwd":"/var/folders/2g/b32ct0qn1d728l_v6tdkjytr0000gn/T/acp-snap-cwd-nOQ4Gy"}
{"type":"request/header","seq":0,"time":4,"data":{"header":{"config":{"model":"fake"},"system":"SYS PROMPT","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"initial"}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }] }

View File

@@ -0,0 +1 @@
[{ "kind": "hang" }]

View File

@@ -0,0 +1 @@
{"type":"session","id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}

View File

@@ -0,0 +1,10 @@
{
"prompt": "error",
"logs": [{
"file": "b/main.jsonl",
"lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 500, "cwd": "{{CWD}}" },
{ "type": "turn/end", "seq": 1, "time": 9, "data": { "error": "model exploded" } }
]
}]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "promptExpectError", "text": "boom" }] }

View File

@@ -0,0 +1 @@
[{ "kind": "throw", "chunks": [], "message": "model exploded", "code": "PROVIDER" }]

View File

@@ -0,0 +1,2 @@
{"type":"session","id":"44444444-3333-4222-8111-000000000000","createdAt":17,"cwd":"/rec/authored-cwd"}
{"type":"turn/end","seq":1,"time":17,"data":{"error":"model exploded"}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"model exploded"}}

View File

@@ -0,0 +1,10 @@
{
"prompt": "error",
"logs": [{
"file": "b/main.jsonl",
"lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 400, "cwd": "{{CWD}}" },
{ "type": "hook/result", "seq": 1, "time": 8, "data": { "decision": "block", "durationMs": 37 } }
]
}]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "promptExpectError", "text": "blocked" }] }

View File

@@ -0,0 +1,2 @@
{"type":"session","id":"99999999-8888-4777-8666-555555555555","createdAt":13,"cwd":"/rec/blocked-cwd"}
{"type":"hook/result","seq":1,"time":13,"data":{"decision":"block","durationMs":99}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"model exploded"}}

View File

@@ -0,0 +1 @@
{}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }] }

View File

@@ -0,0 +1 @@
{"type":"session","id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"}

View File

@@ -0,0 +1 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}

View File

@@ -0,0 +1,11 @@
{
"prompt": "respond",
"logs": [{
"file": "b/main.jsonl",
"lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 100, "cwd": "{{CWD}}" },
{ "type": "request/header", "seq": 0, "time": 100, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
{ "type": "turn/start", "seq": 1, "time": 100, "data": { "turn": 1 } }
]
}]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "pin" }] }

View File

@@ -0,0 +1,3 @@
{"type":"session","id":"12121212-3434-4545-8686-787878787878","createdAt":7,"cwd":"/rec/pin-cwd"}
{"type":"request/header","seq":0,"time":7,"data":{"header":{"config":{"model":"fake"},"system":"SYS PROMPT","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"initial"}}
{"type":"turn/start","seq":1,"time":7,"data":{"turn":1}}

View File

@@ -0,0 +1,4 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1,15 @@
{
"prompt": "respond",
"echoWorkspace": true,
"logs": [
{ "file": "b/parent.jsonl", "lines": [
{ "type": "session", "id": "{{SID}}", "createdAt": 200, "cwd": "{{CWD}}" },
{ "type": "request/header", "seq": 0, "time": 5, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } },
{ "type": "assistant/chunk", "seq": 1, "time": 5, "data": { "turn": 1, "step": 1, "chunk": { "type": "text-delta", "index": 0, "text": "hi" } } }
]},
{ "file": "b/child.jsonl", "lines": [
{ "type": "session", "id": "eeeeeeee-1111-4222-8333-444444444444", "createdAt": 300, "cwd": "{{CWD}}", "parentSession": "{{SID}}" },
{ "type": "request/header", "seq": 0, "time": 6, "data": { "header": { "config": { "model": "fake" }, "system": "SYS PROMPT", "tools": [{ "name": "t1", "description": "D1", "parameters": { "type": "object" } }] }, "reason": "initial" } }
]}
]
}

View File

@@ -0,0 +1 @@
{ "steps": [{ "op": "initialize" }, { "op": "newSession" }, { "op": "prompt", "text": "plain" }] }

View File

@@ -0,0 +1,2 @@
{"type":"session","id":"eeeeeeee-1111-4222-8333-444444444444","createdAt":12,"cwd":"/rec/plain-cwd","parentSession":"56565656-7878-4989-8a9a-9b9b9b9b9b9b"}
{"type":"request/header","seq":0,"time":12,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}

View File

@@ -0,0 +1,3 @@
{"type":"session","id":"56565656-7878-4989-8a9a-9b9b9b9b9b9b","createdAt":11,"cwd":"/rec/plain-cwd"}
{"type":"request/header","seq":0,"time":11,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}
{"type":"assistant/chunk","seq":1,"time":11,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"hi"}}}

View File

@@ -0,0 +1,5 @@
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}}
{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}}
{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}}
{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}}

View File

@@ -0,0 +1 @@
seeded

View File

@@ -0,0 +1,272 @@
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts'
/**
* Unit tests for the subprocess harness, driven through the REAL spawn path
* (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in
* ./fixtures/fake-acp-agent.ts. Each case writes a `behavior.json` next to a
* throwaway fixture path; the fake bin echoes observable facts (env, seeded
* workspace, permission outcomes) into `agent_message_chunk` text, so the
* assertions read plain `rawStdout`.
*/
const AGENT: AgentUnderTest = {
binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
// The fake bin ignores its config argv; any real path documents the shape.
configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
}
/** Temp scenario dirs to drop after the suite. */
const tempDirs: string[] = []
afterAll(async () => {
for (const dir of tempDirs) await rm(dir, { recursive: true, force: true })
})
/** Write a behavior.json into a fresh temp dir; return the sibling fixture path the harness points the bin at. */
async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: string }> {
const dir = await mkdtemp(join(tmpdir(), 'acp-snap-spec-'))
tempDirs.push(dir)
await writeFile(join(dir, 'behavior.json'), JSON.stringify(behavior))
return { dir, fixtureFile: join(dir, 'session.jsonl') }
}
const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }]
describe('runScenario', () => {
it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
permissionProbe: true,
logs: [{
file: 'bucket/main.jsonl',
lines: [
{ type: 'session', id: '{{SID}}', createdAt: 42, cwd: '{{CWD}}' },
{ type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } },
],
}],
})
const result = await runScenario(
{ steps: [{ op: 'initialize', terminalOutput: true }, { op: 'newSession' }, { op: 'prompt', text: 'go' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionId).toBeDefined()
// The harness's client answers a permission request with `cancelled`; the
// fake bin echoes the outcome it received back as a chunk.
expect(result.rawStdout).toContain('permission:{\\"outcome\\":\\"cancelled\\"}')
expect(result.sessionLogs).toHaveLength(1)
expect(result.sessionLogs[0]?.id).toBe(result.sessionId)
expect(result.sessionLogs[0]?.createdAt).toBe(42)
expect(result.sessionLogs[0]?.content).toContain('turn/start')
// The harvested log embeds the run's REAL temp cwd (template-substituted).
expect(result.sessionLogs[0]?.content).toContain(result.cwd)
})
it('forwards override/child fixture paths into the child env and captures stderr', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ echoEnv: true, stderrNote: 'fake bin booted' })
const childFiles = [join(dir, 'session.1.jsonl'), join(dir, 'session.2.jsonl')]
const result = await runScenario(
{ steps: [...boot, { op: 'prompt', text: 'env?' }] },
{
agent: AGENT,
mode: 'replay',
fixtureFile,
overrideFile: join(dir, 'replay.override.json'),
childFiles,
// A workspaceDir that does not exist is skipped, not an error.
workspaceDir: join(dir, 'no-such-workspace'),
},
)
expect(result.stderr).toContain('fake bin booted')
expect(result.rawStdout).toContain('replay.override.json')
// Child paths ride one env var, joined with the platform delimiter.
expect(result.rawStdout).toContain(JSON.stringify(childFiles.join(delimiter)).slice(1, -1))
})
it('seeds the workspace dir into the temp cwd before the run', { timeout: 20_000 }, async () => {
const { dir, fixtureFile } = await scenario({ echoWorkspace: true })
const workspaceDir = join(dir, 'workspace')
await writeFile(join(dir, 'behavior.json'), JSON.stringify({ echoWorkspace: true }))
const { mkdir } = await import('node:fs/promises')
await mkdir(workspaceDir, { recursive: true })
await writeFile(join(workspaceDir, 'seeded.txt'), 'hello')
const result = await runScenario(
{ steps: [...boot, { op: 'prompt', text: 'ls' }] },
{ agent: AGENT, mode: 'replay', fixtureFile, workspaceDir },
)
expect(result.rawStdout).toContain('workspace:seeded.txt')
})
it('promptAndCancel waits for the streamed chunk, cancels, and settles the prompt', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'hang-until-cancel' })
const result = await runScenario(
{ steps: [...boot, { op: 'promptAndCancel', text: 'hang' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('"stopReason":"cancelled"')
// The streamed chunk deterministically precedes the cancelled response.
expect(result.rawStdout.indexOf('thinking about it')).toBeLessThan(result.rawStdout.indexOf('cancelled'))
})
it('promptExpectError swallows a model-error response as the expected outcome', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'error' })
const result = await runScenario(
{ steps: [...boot, { op: 'promptExpectError', text: 'boom' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('model exploded')
})
it('promptExpectError throws when the prompt unexpectedly succeeds (and teardown kills the live child)', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ prompt: 'respond' })
await expect(runScenario(
{ steps: [...boot, { op: 'promptExpectError', text: 'fine' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/expected the prompt to fail/)
})
it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ rejectExtraDirs: true })
const result = await runScenario(
{ steps: [{ op: 'initialize' }, { op: 'newSessionExpectError', additionalDirectories: ['/elsewhere'] }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
// No session was created, so no id and no logs.
expect(result.sessionId).toBeUndefined()
expect(result.sessionLogs).toHaveLength(0)
const rejectAll = await scenario({ rejectNewSession: true })
const second = await runScenario(
{ steps: [{ op: 'initialize' }, { op: 'newSessionExpectError' }] },
{ agent: AGENT, mode: 'replay', fixtureFile: rejectAll.fixtureFile },
)
expect(second.rawStdout).toContain('unsupported workspace scope')
})
it('newSessionExpectError throws when session/new unexpectedly succeeds', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
await expect(runScenario(
{ steps: [{ op: 'initialize' }, { op: 'newSessionExpectError' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/expected session\/new to be rejected/)
})
it('a plain cancel step is forwarded (and ignored by an idle agent)', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
const result = await runScenario(
{ steps: [...boot, { op: 'cancel' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionId).toBeDefined()
})
it.each([
[{ op: 'prompt', text: 'x' }, /prompt before newSession/],
[{ op: 'promptExpectError', text: 'x' }, /promptExpectError before newSession/],
[{ op: 'promptAndCancel', text: 'x' }, /promptAndCancel before newSession/],
[{ op: 'cancel' }, /cancel before newSession/],
] as [InputStep, RegExp][])('rejects %j before newSession', { timeout: 20_000 }, async (step, message) => {
const { fixtureFile } = await scenario({})
await expect(runScenario(
{ steps: [{ op: 'initialize' }, step] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(message)
})
it('rejects an unknown input op', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({})
const bogus = { op: 'reticulate' } as unknown as InputStep
await expect(runScenario(
{ steps: [bogus] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/unknown input op/)
})
it('harvests all logs primary-first, children by createdAt then id, skipping filesystem noise', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({
strayRootFile: true,
strayBucketFile: true,
logs: [
// File names chosen so readdir feeds the sort children-first AND
// parent-in-the-middle: the comparator then sees a parent on both
// sides of a pair, plus the same-createdAt (localeCompare) tiebreak.
{ file: 'b1/aa-child-c.jsonl', lines: [{ type: 'session', id: 'cccccccc-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
{ file: 'b1/bb-parent.jsonl', lines: [{ type: 'session', id: '{{SID}}', createdAt: 900 }] },
{ file: 'b1/cc-child-a.jsonl', lines: [{ type: 'session', id: 'aaaaaaaa-0000-4000-8000-000000000000', createdAt: 500, parentSession: '{{SID}}' }] },
// Missing id/createdAt fall back to ''/0; earliest child by createdAt.
{ file: 'b2/orphan-fields.jsonl', lines: [{ type: 'session', parentSession: '{{SID}}' }] },
],
})
const result = await runScenario(
{ steps: [...boot, { op: 'prompt', text: 'go' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionLogs.map(l => [l.id, l.createdAt])).toEqual([
[result.sessionId, 900],
['', 0],
['aaaaaaaa-0000-4000-8000-000000000000', 500],
['cccccccc-0000-4000-8000-000000000000', 500],
])
expect(result.sessionLogs[1]?.parentSession).toBe(result.sessionId)
})
it('treats an empty log file as a header-less primary with default fields', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ logs: [{ file: 'b/empty.jsonl', lines: [] }] })
const result = await runScenario(
{ steps: boot },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionLogs.map(l => [l.id, l.createdAt, l.parentSession])).toEqual([['', 0, undefined]])
})
it('yields no logs when the sessions root vanished', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ deleteSessionsRoot: true })
const result = await runScenario(
{ steps: boot },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.sessionLogs).toHaveLength(0)
})
it('answers permission requests from the scripted queue by option kind, falling back to cancelled', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ permissionProbe: true })
// Two prompts → two permission round-trips; one scripted answer, so the
// second request exercises the exhausted-queue fallback.
const result = await runScenario(
{
steps: [...boot, { op: 'prompt', text: 'one' }, { op: 'prompt', text: 'two' }],
permissionAnswers: [{ kind: 'allow_once' }],
},
{ agent: AGENT, mode: 'replay', fixtureFile },
)
const first = result.rawStdout.indexOf('permission:{\\"outcome\\":\\"selected\\",\\"optionId\\":\\"opt-allow\\"}')
const second = result.rawStdout.indexOf('permission:{\\"outcome\\":\\"cancelled\\"}')
expect(first).toBeGreaterThanOrEqual(0)
expect(second).toBeGreaterThan(first)
})
it('selects a non-first offered option by kind', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ permissionProbe: true })
const result = await runScenario(
{ steps: [...boot, { op: 'prompt', text: 'deny it' }], permissionAnswers: [{ kind: 'reject_once' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)
expect(result.rawStdout).toContain('permission:{\\"outcome\\":\\"selected\\",\\"optionId\\":\\"opt-reject\\"}')
})
it('rejects the run on a scripted permission kind the agent never offered', { timeout: 20_000 }, async () => {
const { fixtureFile } = await scenario({ permissionProbe: true })
// The fake bin offers allow_once/reject_once; scripting allow_always is a
// scenario bug. The agent is answered `cancelled` (it must not be able to
// absorb the bug as an error-means-denial), and the RUN fails: a callback
// throw would only reach the agent as a JSON-RPC error response, letting
// a tolerant agent carry on and the scenario pass — or record.
await expect(runScenario(
{ steps: [...boot, { op: 'prompt', text: 'impossible click' }], permissionAnswers: [{ kind: 'allow_always' }] },
{ agent: AGENT, mode: 'replay', fixtureFile },
)).rejects.toThrow(/allow_always not among the offered options \[allow_once, reject_once\]/)
})
})

View File

@@ -0,0 +1,230 @@
import { describe, expect, it } from 'vitest'
import { type NormalizeContext, normalizeSessionLog, normalizeStdout, scrubRequestHeaders } from '../src/normalize.ts'
/**
* Unit tests for the pure snapshot normalizers. Live as a *.spec.ts (runs in
* the default unit gate) and import the normalizers directly.
*/
const ctx: NormalizeContext = {
sessionIds: ['11111111-2222-3333-4444-555555555555'],
cwd: '/tmp/acp-snap-cwd-abc123',
}
describe('normalizeStdout', () => {
it('rewrites JSON-RPC ids to a stable first-seen sequence', () => {
const raw = [
JSON.stringify({ jsonrpc: '2.0', id: 42, method: 'initialize' }),
JSON.stringify({ jsonrpc: '2.0', id: 42, result: {} }),
JSON.stringify({ jsonrpc: '2.0', id: 99, method: 'session/new' }),
].join('\n')
const out = normalizeStdout(raw, ctx)
expect(out).toContain('"id":1')
expect(out).toContain('"id":2')
expect(out).not.toContain('42')
expect(out).not.toContain('99')
})
it('scrubs the cwd and session id anywhere they appear', () => {
const raw = JSON.stringify({
jsonrpc: '2.0', method: 'session/update',
params: { sessionId: ctx.sessionIds[0], cwd: ctx.cwd, note: `at ${ctx.cwd}/x` },
})
const out = normalizeStdout(raw, ctx)
expect(out).toContain('{{sessionId}}')
expect(out).toContain('{{cwd}}')
expect(out).not.toContain(ctx.cwd)
expect(out).not.toContain(ctx.sessionIds[0] as string)
})
it('scrubs a stray UUID not in the known list', () => {
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'x', params: { id: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } })
expect(normalizeStdout(raw, ctx)).toContain('{{sessionId}}')
})
it('leaves notification frames without an id untouched in id-space', () => {
const raw = JSON.stringify({ jsonrpc: '2.0', method: 'session/update', params: {} })
const out = normalizeStdout(raw, ctx)
expect(out).not.toContain('"id"')
})
it('throws on a non-JSON stdout line (the purity check)', () => {
const raw = `${JSON.stringify({ jsonrpc: '2.0', id: 1 })}\noops a log leaked\n`
expect(() => normalizeStdout(raw, ctx)).toThrow()
})
it('ignores blank lines', () => {
const raw = `\n${JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'm' })}\n\n`
expect(() => normalizeStdout(raw, ctx)).not.toThrow()
})
})
describe('normalizeSessionLog', () => {
const header = (over: object) => JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 123, ...over })
const event = (over: object) => JSON.stringify({ type: 'turn/start', seq: 1, time: 999, data: { turn: 1 }, ...over })
it('zeroes the header createdAt', () => {
const out = normalizeSessionLog(`${header({})}\n`, ctx)
expect(out).toContain('"createdAt":0')
expect(out).not.toContain('123')
})
it('zeroes each event time but keeps seq', () => {
const out = normalizeSessionLog(`${header({})}\n${event({ seq: 7, time: 999 })}\n`, ctx)
expect(out).toContain('"time":0')
expect(out).toContain('"seq":7') // seq is deterministic — NOT scrubbed
expect(out).not.toContain('999')
})
it('scrubs cwd and session id deep inside event data', () => {
const ev = JSON.stringify({
type: 'tool/result', seq: 2, time: 5,
data: { content: [{ type: 'text', text: `wrote ${ctx.cwd}/proof.txt` }] },
})
const out = normalizeSessionLog(`${header({ cwd: ctx.cwd })}\n${ev}\n`, ctx)
expect(out).toContain('{{cwd}}')
expect(out).not.toContain(ctx.cwd)
})
it('scrubs the session id in the header', () => {
const out = normalizeSessionLog(`${header({ id: ctx.sessionIds[0] })}\n`, ctx)
expect(out).toContain('{{sessionId}}')
})
it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => {
const ev = JSON.stringify({
type: 'hook/result', seq: 2, time: 5,
data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 },
})
const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
expect(out).toContain('"durationMs":0')
expect(out).not.toContain('37')
expect(out).toContain('"decision":"block"') // the decision is the behavior — kept
})
it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => {
const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } })
const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx)
expect(out).toContain('"durationMs":88')
})
it('tolerates records missing the volatile fields it would zero', () => {
const bareHeader = JSON.stringify({ type: 'session', id: 's' })
const timeless = JSON.stringify({ type: 'note', seq: 1 })
const bareHook = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, data: { decision: 'allow' } })
const nullDataHook = JSON.stringify({ type: 'hook/result', seq: 3, time: 6, data: null })
const out = normalizeSessionLog(`${bareHeader}\n${timeless}\n${bareHook}\n${nullDataHook}\n`, ctx)
expect(out).toContain('"type":"note","seq":1')
expect(out).toContain('"decision":"allow"')
expect(out).not.toContain('durationMs')
})
})
describe('scrubRequestHeaders', () => {
const headerLine = JSON.stringify({ type: 'session', version: 0, id: 's', createdAt: 1, cwd: '/w' })
const headerEvent = (header: object) =>
JSON.stringify({ type: 'request/header', seq: 3, time: 9, data: { header, reason: 'initial' } })
it('replaces header system and tools with tokens, keeping config and reason', () => {
const ev = headerEvent({
config: { model: 'm' },
system: 'You are an agent.\nBe brief.',
tools: [{ name: 'read', description: 'Read a file.', parameters: { type: 'object' } }],
})
const out = scrubRequestHeaders(`${headerLine}\n${ev}\n`)
expect(out).toContain('"system":"{{system}}"')
expect(out).toContain('"tools":"{{tools}}"')
expect(out).toContain('"config":{"model":"m"}')
expect(out).toContain('"reason":"initial"')
expect(out).not.toContain('You are an agent')
expect(out).not.toContain('Read a file')
})
it('keeps an absent system/tools absent (presence is behavior)', () => {
const out = scrubRequestHeaders(`${headerLine}\n${headerEvent({ config: { model: 'm' } })}\n`)
expect(out).not.toContain('{{system}}')
expect(out).not.toContain('{{tools}}')
})
it('scrubs a header carrying only one of system/tools, leaving the other absent', () => {
const systemOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ system: 'secret prompt' })}\n`)
expect(systemOnly).toContain('"system":"{{system}}"')
expect(systemOnly).not.toContain('{{tools}}')
const toolsOnly = scrubRequestHeaders(`${headerLine}\n${headerEvent({ tools: [{ name: 't' }] })}\n`)
expect(toolsOnly).toContain('"tools":"{{tools}}"')
expect(toolsOnly).not.toContain('{{system}}')
})
it('leaves a delta with no scrubbable payload byte-identical (config-only, or non-array shapes)', () => {
const configOnly = JSON.stringify({ type: 'request/header-delta', seq: 8, time: 9, data: { config: { model: 'm2' } } })
const oddShapes = JSON.stringify({ type: 'request/header-delta', seq: 9, time: 9, data: { system: { insert: 'not-an-array' }, tools: null } })
const headerless = JSON.stringify({ type: 'request/header', seq: 10, time: 9, data: { reason: 'initial' } })
const nullData = JSON.stringify({ type: 'request/header', seq: 11, time: 9, data: null })
const raw = `${headerLine}\n${configOnly}\n${oddShapes}\n${headerless}\n${nullData}\n`
expect(scrubRequestHeaders(raw)).toBe(raw)
})
it('scrubs a one-sided tools delta and passes non-object schema entries through', () => {
const addedOnly = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { tools: { added: [null, 'weird', { name: 'x', description: 'D' }] } },
})
const out = scrubRequestHeaders(`${headerLine}\n${addedOnly}\n`)
// Non-object entries survive untouched; the object entry keeps only name.
expect(out).toContain('"added":[null,"weird",{"name":"x","description":"{{tools}}"}]')
const changedOnly = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { tools: { changed: [{ name: 'y', parameters: {} }] } },
})
expect(scrubRequestHeaders(`${headerLine}\n${changedOnly}\n`))
.toContain('"changed":[{"name":"y","parameters":"{{tools}}"}]')
})
it('scrubs a header-delta system payload but keeps its line positions and arity', () => {
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { system: { keepStart: 1, keepEnd: 4, insert: ['leaked prompt line', 'second line'] }, config: { model: 'm2' } },
})
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
// One token PER inserted line: the edit's position AND extent survive.
expect(out).toContain('"insert":["{{system}}","{{system}}"]')
expect(out).toContain('"keepStart":1')
expect(out).toContain('"keepEnd":4')
expect(out).toContain('"config":{"model":"m2"}')
expect(out).not.toContain('leaked prompt line')
expect(out).not.toContain('{{tools}}') // no tools delta → none invented
})
it('scrubs a header-delta tools payload but keeps the added/removed/changed names', () => {
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: {
tools: {
added: [{ name: 'grep', description: 'Search files.', parameters: { type: 'object' } }],
removed: ['bash_kill'],
changed: [{ name: 'read', description: 'Read v2.', parameters: { type: 'object' } }],
},
},
})
const out = scrubRequestHeaders(`${headerLine}\n${delta}\n`)
// WHICH tools changed is behavior and survives; their bulk does not.
expect(out).toContain('"added":[{"name":"grep","description":"{{tools}}","parameters":"{{tools}}"}]')
expect(out).toContain('"removed":["bash_kill"]')
expect(out).toContain('"changed":[{"name":"read","description":"{{tools}}","parameters":"{{tools}}"}]')
expect(out).not.toContain('Search files')
expect(out).not.toContain('Read v2')
})
it('passes every other line through byte-for-byte and is idempotent', () => {
const other = JSON.stringify({ type: 'assistant/chunk', seq: 4, time: 9, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } })
const delta = JSON.stringify({
type: 'request/header-delta', seq: 8, time: 9,
data: { system: { keepStart: 0, keepEnd: 0, insert: ['x'] }, tools: { added: [{ name: 't', description: 'd', parameters: {} }], removed: [], changed: [] } },
})
const raw = `${headerLine}\n${headerEvent({ config: { model: 'm' }, system: 's', tools: [] })}\n${delta}\n${other}\n`
const once = scrubRequestHeaders(raw)
expect(once.split('\n')[0]).toBe(headerLine)
expect(once.split('\n')[3]).toBe(other)
expect(scrubRequestHeaders(once)).toBe(once)
})
})

View File

@@ -0,0 +1,145 @@
import { cpSync, mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { defineAcpSnapshotSuite, type Scenario } from '../src/index.ts'
import { childFixturePaths, fixtureContext, headerDeltaCount, normalizedHeaders } from '../src/suite.ts'
/**
* Unit tests for the suite factory, by running it: two synthetic suites over
* the scripted fake ACP bin (./fixtures/fake-acp-agent.ts) register REAL
* describe/it trees at collection time, so every factory path — golden and log
* compares, the per-suite header pin and its uniformity guard, record-mode
* fixture write-back, skip semantics, and the fixture guard block — executes
* as an ordinary green test. The pure helpers get direct cases below.
*
* The replay suite runs against the committed fixtures in ./fixtures/suite.
* The record suite runs against a TEMP COPY of ./fixtures/record-suite
* (record mode writes session fixtures back into its snapshots dir; a run must
* never touch the committed tree). To re-bootstrap the record tree's goldens
* after changing the fake bin's output, run this spec once with
* `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` (points the record suite at the committed
* tree so vitest creates/updates the goldens and the write-back lands there),
* then commit the result.
*/
const AGENT = {
binScript: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
configPath: fileURLToPath(new URL('./fixtures/fake-acp-agent.ts', import.meta.url)),
tsconfigPath: fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)),
}
const REPLAY_DIR = fileURLToPath(new URL('./fixtures/suite', import.meta.url))
const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta.url))
const REPLAY_SCENARIOS: Scenario[] = [
{ name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1 },
{ name: 'no-model', hasModelTurn: false, recorded: false },
{ name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false },
{ name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true },
]
const RECORD_SCENARIOS: Scenario[] = [
{ name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true },
{ name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 },
// recorded:false in record mode → registered but skipped (never re-recorded).
{ name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true },
]
// Record mode mutates its snapshots dir, so run it on a throwaway copy —
// except under the documented bootstrap knob, which regenerates the committed
// fixtures/goldens in place.
const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1'
const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-'))
if (!BOOTSTRAP) cpSync(RECORD_SRC, recordDir, { recursive: true })
afterAll(async () => {
if (!BOOTSTRAP) await rm(recordDir, { recursive: true, force: true })
})
describe('defineAcpSnapshotSuite: replay mode', () => {
defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: REPLAY_DIR, scenarios: REPLAY_SCENARIOS, mode: 'replay' })
})
// The record suite's tests run in registration order: rec-pin re-records the
// pinned fixture FIRST, so rec-child's uniformity guard reads the fresh pin.
describe('defineAcpSnapshotSuite: record mode', () => {
defineAcpSnapshotSuite({ agent: AGENT, snapshotsDir: recordDir, scenarios: RECORD_SCENARIOS, mode: 'record' })
})
describe('defineAcpSnapshotSuite: registration contract', () => {
it('throws when no scenario pins the request-header content', () => {
expect(() => {
defineAcpSnapshotSuite({
agent: AGENT,
snapshotsDir: REPLAY_DIR,
scenarios: [{ name: 'pinless', hasModelTurn: true, recorded: true }],
mode: 'replay',
})
}).toThrow(/no scenario pins/)
})
})
describe('childFixturePaths', () => {
it('yields one sibling path per child, 1-based', () => {
expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl'])
})
it('yields nothing for a single-session scenario', () => {
expect(childFixturePaths('/snap/s', 0)).toEqual([])
})
})
describe('fixtureContext', () => {
it('reads the fixture header id and cwd', () => {
const ctx = fixtureContext('{"type":"session","id":"abc","cwd":"/rec"}\n{"type":"turn/start"}\n')
expect(ctx).toEqual({ sessionIds: ['abc'], cwd: '/rec' })
})
it('yields no session ids for a header without a string id', () => {
expect(fixtureContext('{"type":"session","cwd":"/rec"}\n').sessionIds).toEqual([])
})
it('falls back to an impossible sentinel cwd (never the empty string)', () => {
const ctx = fixtureContext('{"type":"session","id":"abc"}\n')
expect(ctx.cwd).toBe('\0no-cwd\0')
expect(ctx.cwd).not.toBe('')
})
it('treats an empty fixture as an empty header', () => {
expect(fixtureContext('')).toEqual({ sessionIds: [], cwd: '\0no-cwd\0' })
})
})
describe('normalizedHeaders', () => {
const header = (system: string): string => JSON.stringify({
type: 'request/header', seq: 0, time: 9, data: { header: { config: { model: 'm' }, system }, reason: 'initial' },
})
it('extracts every request/header payload in log order, normalized', () => {
const id = '11111111-2222-4333-8444-555555555555'
const log = `${JSON.stringify({ type: 'session', id, createdAt: 5, cwd: '/w' })}\n${header('one')}\n`
+ `${JSON.stringify({ type: 'turn/start', seq: 1, time: 9, data: { turn: 1 } })}\n${header('two')}\n`
const headers = normalizedHeaders(log, { sessionIds: [id], cwd: '/w' })
expect(headers).toEqual([
{ config: { model: 'm' }, system: 'one' },
{ config: { model: 'm' }, system: 'two' },
])
})
it('yields nothing for a log without header events', () => {
const log = `${JSON.stringify({ type: 'session', id: 'a', createdAt: 5 })}\n`
expect(normalizedHeaders(log, { sessionIds: [], cwd: '/w' })).toEqual([])
})
})
describe('headerDeltaCount', () => {
it('counts request/header-delta events, ignoring blanks and other lines', () => {
const delta = JSON.stringify({ type: 'request/header-delta', seq: 2, time: 9, data: {} })
const other = JSON.stringify({ type: 'request/header', seq: 0, time: 9, data: {} })
expect(headerDeltaCount(`${other}\n\n${delta}\n${delta}\n`)).toBe(2)
expect(headerDeltaCount(`${other}\n`)).toBe(0)
})
})

View File

@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}

View File

@@ -0,0 +1,9 @@
# timeout/ — tool-call timeout policy
The tool-call timeout policy plugin. A single **product** package: it is a deployment-policy consumer of the `tools/execute` around-dispatch seam (owned by [`dsh-tools`](../core/tools)) and the pure [`dsh-timeout`](../util/timeout) library — not a swappable capability with an interface/implementation split, so it needs no seam trio.
| Package | Role | ctx key |
|---|---|---|
| `timeout-policy/` | A `tools/execute` wrapper: for each configured tool it arms a per-call deadline on `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline wins | (registers a `tools/execute` listener; injects nothing) |
Timeout is split across three layers: [`dsh-timeout`](../util/timeout) owns the pure timing/classification primitive (`deadline`/`timeoutOf`), each capability owns termination (bash kills its process group, the fetch provider tears down its socket), and this package owns the *model-facing tool-call budget as deployment policy* — no model-facing timeout argument, no global default. It is the middleware the [timeout-library RFC](../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md) foresaw. `bash` and hook command execution keep their own `BASH_TIMEOUT` backend timeout and do not route through this policy.

View File

@@ -0,0 +1,34 @@
# dsh-timeout-policy
Tool-call timeout enforcer: a single `tools/execute` around-dispatch listener that arms a per-call cooperative deadline on `exec.signal` for a tool declaring `timeoutMs` on its `ToolDefinition` and returns a structured `TOOL_TIMEOUT` result when that deadline wins. The budget is read from the tool's own declaration (`ToolDefinition.timeoutMs`, set by the owning tool plugin), so this plugin is **zero-config**. It is the reference `tools/execute` wrapper and the enforcement home for model-facing tool-call budgets (the timeout-library RFC's foreseen middleware).
## Plugin (namespace: `timeout-policy`)
A function/namespace plugin (`name` / `inject` / `apply`), not a service. It registers no tool and takes no config — it consumes `ctx.tools`'s `tools/execute` waterfall (which the `dsh-tools` registry always provides) and reads each dispatched tool's declared `timeoutMs` from the registry (`ctx.tools.get(exec.name)`).
```yaml
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
```
The per-tool budget is declared by the tool plugin (e.g. `dsh-tool-web`'s `fetchTimeoutMs`/`searchTimeoutMs` config, attached as `ToolDefinition.timeoutMs`); this plugin only enforces it, so a mistyped tool name is not possible.
### Behavior
For a tool that **declares a `timeoutMs`** the listener:
1. Reads the budget from the tool's own declaration in the registry (`ctx.tools.get(exec.name)?.timeoutMs`) and arms `deadline(exec.signal, timeoutMs, 'TOOL_TIMEOUT')` — one signal fusing the caller's abort with this plugin's timer (`@deepseek-ai/dsh-timeout`).
2. Swaps that derived signal onto `exec` for the downstream dispatch, then restores the caller's own signal afterward (cordis `next()` ignores passed arguments, so the wrapper mutates the shared `exec` in place; restoring keeps `tools/post-execute` seeing the caller's signal).
3. After dispatch, if `timeoutOf(d.signal, 'TOOL_TIMEOUT')` matches — this plugin's own timer fired — replaces the result with a structured `TOOL_TIMEOUT` tool result: `{ isError: true, error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' }, content: 'Error: tool call timed out after <ms>ms' }`.
A tool that **declares no budget** delegates untouched (no deadline).
The base `next()` of `tools/execute` is the registry's dispatch-with-normalization thunk, so when the timeout signal reaches a provider that throws its own upstream-abort error, dispatch first turns it into a normal error result, and this wrapper then replaces that with `TOOL_TIMEOUT`. That ordering is why the replacement is keyed off the signal (`timeoutOf`), not off the dispatched result's shape.
### Cooperative, not a hard kill
The derived signal only **notifies**; termination stays with the tool and the capability it forwards `exec.signal` to (the `dsh-timeout` library owns no kill). **Declaring `timeoutMs` therefore means "cooperative with `exec.signal`"**: a tool that ignores the signal will not stop on timeout. Only signal-forwarding tools should declare it — the shipped `web_fetch`/`web_search` (which forward through `ctx.web` to providers) are the reference. `TOOL_TIMEOUT` needs no session event for reconstructability: it is the final model-facing `tool/result`, already logged by the loop.
### Composing with other `tools/execute` wrappers
Multiple `tools/execute` listeners compose by cordis registration order. Combined with a future retry/sandbox/metrics wrapper, registration order chooses the semantics — "timeout covers the whole retry operation" (timeout registered outer) versus "timeout covers each attempt" (timeout registered inner).

View File

@@ -0,0 +1,36 @@
{
"name": "@deepseek-ai/dsh-timeout-policy",
"description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
}
}

View File

@@ -0,0 +1,115 @@
/**
* `@deepseek-ai/dsh-timeout-policy`: the tool-call timeout ENFORCER. It registers
* ONE `tools/execute` around-dispatch listener that, for a tool declaring a
* `timeoutMs` on its {@link ToolDefinition}, arms a per-call deadline on
* `exec.signal` and returns a structured `TOOL_TIMEOUT` result when that deadline
* wins. The budget is DECLARED by the tool (see `ToolDefinition.timeoutMs`, set
* by the owning tool plugin from its own config); this plugin only enforces it,
* so it is zero-config and there is no tool-name map to mistype.
*
* This is a COOPERATIVE deadline, not a hard kill: the derived signal only
* NOTIFIES. A tool that declares `timeoutMs` (and the capability it forwards
* `exec.signal` to) must honor that signal and reach quiescence — the plugin
* never races the tool promise or terminates work itself (see the timeout-library
* RFC's rejection of `Promise.race`). Declaring `timeoutMs` therefore MEANS "this
* tool is cooperative with `exec.signal`": a tool that ignores the signal will
* not stop on timeout, so only signal-forwarding tools should declare it (the
* shipped web tools are the reference).
*
* Ownership of the `TOOL_TIMEOUT` code is entirely here: it is both the internal
* {@link deadline} code (so {@link timeoutOf} scopes the classification to THIS
* plugin's own timer, reading a foreign/nested outer deadline as an ordinary
* cancel) and the structured `{ name, code }` on the replacement tool result.
* No new session event is needed for reconstructability: the `TOOL_TIMEOUT`
* result IS the final model-facing `tool/result`, already logged by the loop.
*
* Why a `tools/execute` around seam and not a `pre`/`post` pair: the deadline
* needs ONE lexical scope — arm on `exec.signal`, delegate to dispatch, classify
* the result, dispose the timer — which the around seam gives directly. A
* pre/post split would spread one deadline's lifetime across two independent
* waterfalls (a call-id map, cleanup on every deny/throw/dispose path).
*
* @module @deepseek-ai/dsh-timeout-policy
*/
import type { Context } from 'cordis'
import type { CallId } from '@deepseek-ai/dsh-llm'
import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools'
/**
* The code owned by this plugin, used BOTH as the internal {@link deadline}
* classification code AND as the structured error `code` on the replacement
* tool result. Scoping {@link timeoutOf} to it keeps a nested outer deadline
* (another `tools/execute` wrapper's timer that fired first) from being misread
* as this plugin's own timeout — it reads as an ordinary upstream cancel.
*/
export const TOOL_TIMEOUT = 'TOOL_TIMEOUT'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'timeout-policy'
/** The tool registry seam this plugin wraps (`tools/execute`) and reads (`get`). */
export const inject = ['tools']
/**
* The structured result substituted when this plugin's deadline wins. `content`
* is the model-facing message; `error.code` is the same {@link TOOL_TIMEOUT}
* this plugin owns, so a retry/sandbox plugin (and replay) can route on it.
*
* @param callId - the timed-out call's id, carried onto the replacement result.
* @param timeoutMs - the elapsed budget, rendered into the model-facing message.
* @returns the `isError` {@link ToolExecutionResult} with a `TOOL_TIMEOUT` error.
*/
export function toolTimeoutResult(callId: CallId, timeoutMs: number): ToolExecutionResult {
return {
callId,
content: [{ type: 'text', text: `Error: tool call timed out after ${timeoutMs}ms` }],
isError: true,
error: { name: 'ToolTimeoutError', code: TOOL_TIMEOUT },
}
}
/**
* Register the tool-call timeout enforcer. For a tool whose {@link ToolDefinition}
* declares `timeoutMs`, the listener arms a {@link deadline} on the caller's
* `exec.signal`, swaps it onto `exec` for the downstream dispatch (cordis
* `next()` ignores passed arguments, so a wrapper mutates the shared `exec` in
* place), restores the original signal afterward so `tools/post-execute` sees the
* caller's own signal, and replaces the result with {@link toolTimeoutResult}
* when its own timer fired. A tool that declares no budget delegates untouched.
*
* The budget source is the tool's own declaration read from the registry
* (`ctx.tools.get(exec.name)?.timeoutMs`), NOT a plugin config map — `exec.name`
* is the tool being dispatched, so the lookup always resolves and there is no
* mistypable tool name and no unknown-name path to warn or throw about.
*/
export function apply(ctx: Context): void {
ctx.on('tools/execute', async (exec, next): Promise<ToolExecutionResult> => {
const timeoutMs = ctx.tools.get(exec.name)?.timeoutMs
// A tool that declares no budget: no deadline, delegate unchanged.
if (timeoutMs === undefined) return next()
using d = deadline(exec.signal, timeoutMs, TOOL_TIMEOUT)
// Swap the derived deadline onto exec for dispatch, then restore the
// caller's own signal so post-execute listeners never see this plugin's
// (possibly already-aborted) timeout signal. `undefined` is not assignable to
// the optional `signal` under exactOptionalPropertyTypes, so branch on it.
const upstream = exec.signal
exec.signal = d.signal
try {
const result = await next()
// If OUR timer fired (scoped by code — a nested outer deadline reads as
// undefined here), the tool/capability saw the abort and reached
// quiescence; replace whatever it returned (its own abort result) with the
// structured TOOL_TIMEOUT the model sees.
if (timeoutOf(d.signal, TOOL_TIMEOUT) !== undefined) {
return toolTimeoutResult(exec.callId, timeoutMs)
}
return result
} finally {
if (upstream === undefined) delete exec.signal
else exec.signal = upstream
}
})
}

View File

@@ -0,0 +1,200 @@
/**
* Unit + real-load-path coverage for @deepseek-ai/dsh-timeout-policy. The
* timeout-wins cases drive the deadline under fake timers (deterministic — no
* wall-clock race) and use a COOPERATIVE tool that settles only when its
* `exec.signal` aborts, mirroring how a real capability forwards the signal and
* reaches quiescence.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import { CallId, HarnessError } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { defineTool, type ToolExecution, type ToolExecutionResult, type PostToolDecision } from '@deepseek-ai/dsh-tools'
import * as timeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
import { TOOL_TIMEOUT, toolTimeoutResult } from '@deepseek-ai/dsh-timeout-policy'
/** Mount the registry + the zero-config timeout-policy enforcer. */
async function setup() {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(timeoutPolicy)
return ctx
}
/** A cooperative tool that settles ONLY when its exec.signal aborts (returns text). */
const cooperativeTool = defineTool({
name: 'slow', description: 'stops when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<{ type: 'text'; text: string }[]> {
const done = [{ type: 'text' as const, text: 'stopped cooperatively' }]
if (exec.signal?.aborted) return Promise.resolve(done)
return new Promise((resolve) => { exec.signal?.addEventListener('abort', () => { resolve(done) }) })
},
})
/** A cooperative tool that THROWS its own upstream-abort error when aborted (web-provider shape). */
const abortThrowingTool = defineTool({
name: 'aborter', description: 'throws WEB_ABORTED when aborted', parameters: {}, timeoutMs: 100,
execute(_args, exec): Promise<never> {
if (exec.signal?.aborted) return Promise.reject(new HarnessError('web fetch aborted', 'WEB_ABORTED'))
return new Promise((_resolve, reject) => { exec.signal?.addEventListener('abort', () => { reject(new HarnessError('web fetch aborted', 'WEB_ABORTED')) }) })
},
})
describe('timeout-policy delegation (unconfigured / fast)', () => {
it('delegates a tool with NO declared budget unchanged and does not touch exec.signal', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {},
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const upstream = new AbortController().signal
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
expect(result.isError).toBe(false)
expect(seenSignal).toBe(upstream)
})
it('a tool with a budget that returns fast keeps its own result (no timeout)', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
})
it('a budgeted tool receives the DERIVED deadline signal (not the caller signal) during dispatch', async () => {
const ctx = await setup()
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
expect(seenSignal).toBeDefined()
expect(seenSignal).not.toBe(upstream)
})
})
describe('timeout-policy signal restoration', () => {
it('restores the caller signal for post-execute after wrapping', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let postSignal: AbortSignal | undefined | 'unset' = 'unset'
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { postSignal = exec.signal; return next() })
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {}, signal: upstream })
expect(postSignal).toBe(upstream)
})
it('deletes exec.signal again when the caller passed none', async () => {
const ctx = await setup()
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
let hadSignal: boolean | undefined
ctx.on('tools/post-execute', async (exec, _result, next): Promise<PostToolDecision> => { hadSignal = 'signal' in exec && exec.signal !== undefined; return next() })
await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} })
expect(hadSignal).toBe(false)
})
})
describe('timeout-policy TOOL_TIMEOUT replacement (deadline wins)', () => {
beforeEach(() => { vi.useFakeTimers() })
afterEach(() => { vi.useRealTimers() })
it('replaces a cooperative tool result with TOOL_TIMEOUT when its own deadline fires', async () => {
const ctx = await setup()
ctx.tools.register(cooperativeTool)
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {} })
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: tool call timed out after 100ms' }],
isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
})
})
it('replaces a provider-owned abort ERROR result with TOOL_TIMEOUT when the signal was ours', async () => {
const ctx = await setup()
ctx.tools.register(abortThrowingTool)
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'aborter', arguments: {} })
await vi.advanceTimersByTimeAsync(150)
const result = await pending
expect(result.isError).toBe(true)
expect(result.error).toEqual({ name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' })
expect(result.content[0]).toMatchObject({ text: 'Error: tool call timed out after 100ms' })
})
it('does NOT replace when the caller aborts first (upstream cancel, not our timeout)', async () => {
const ctx = await setup()
ctx.tools.register(cooperativeTool)
const upstream = new AbortController()
const pending = ctx.tools.execute({ callId: CallId('c1'), name: 'slow', arguments: {}, signal: upstream.signal })
upstream.abort('user cancelled')
await vi.advanceTimersByTimeAsync(0)
const result = await pending
expect(result.isError).toBe(false)
expect(result.content[0]).toMatchObject({ text: 'stopped cooperatively' })
})
})
describe('toolTimeoutResult', () => {
it('builds the structured TOOL_TIMEOUT result', () => {
expect(toolTimeoutResult(CallId('c9'), 250)).toEqual({
callId: CallId('c9'),
content: [{ type: 'text', text: 'Error: tool call timed out after 250ms' }],
isError: true,
error: { name: 'ToolTimeoutError', code: 'TOOL_TIMEOUT' },
} satisfies ToolExecutionResult)
})
it('exposes the owned code constant', () => {
expect(TOOL_TIMEOUT).toBe('TOOL_TIMEOUT')
})
})
describe('timeout-policy disposal (HMR safety)', () => {
it('removes its tools/execute listener when the plugin fiber disposes', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
let seenSignal: AbortSignal | undefined
ctx.tools.register(defineTool({ name: 'probe', description: 'd', parameters: {}, timeoutMs: 10_000,
async execute(_a, exec) { seenSignal = exec.signal; return [{ type: 'text' as const, text: 'ok' }] } }))
const fiber = await ctx.plugin(timeoutPolicy)
const upstream = new AbortController().signal
await ctx.tools.execute({ callId: CallId('c1'), name: 'probe', arguments: {}, signal: upstream })
expect(seenSignal).not.toBe(upstream)
await fiber.dispose()
await ctx.tools.execute({ callId: CallId('c2'), name: 'probe', arguments: {}, signal: upstream })
expect(seenSignal).toBe(upstream)
})
})
describe('dsh-timeout-policy real-load-path guard', () => {
it('has no default export and keeps name/inject through unwrapExports', () => {
expect('default' in timeoutPolicy).toBe(false)
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Record<string, unknown>
expect(unwrapped).toBe(timeoutPolicy)
expect(unwrapped.name).toBe('timeout-policy')
expect(unwrapped.inject).toEqual(['tools'])
expect(typeof unwrapped.apply).toBe('function')
})
it('boots over ctx.tools through the unwrapped module and wraps a budgeted tool', async () => {
const ctx = new Context()
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
ctx.tools.register(defineTool({ name: 'fast', description: 'd', parameters: {}, timeoutMs: 5_000,
async execute() { return [{ type: 'text' as const, text: 'ok' }] } }))
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(timeoutPolicy) as Parameters<Context['plugin']>[0]
const fiber = await ctx.plugin(unwrapped)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'fast', arguments: {} } satisfies ToolExecution)
expect(result.isError).toBe(false)
await fiber.dispose()
})
})

View File

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

View File

@@ -5,10 +5,14 @@ Integrations that expose the agent to an external editor or client. These are **
| Package | Role | ctx key |
|---|---|---|
| `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) |
| `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` |
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
| `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) |
| `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) |
| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) |
A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own.
`user-interaction` and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. The seam remains provider-neutral (`ctx.userInteraction`), while the tool is the model-facing consumer and the app/bridge packages provide concrete providers.
`stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention.

View File

@@ -11,8 +11,10 @@ stdout is the ACP JSON-RPC channel, so the cluster is defined as much by what it
| Plugin | Why |
|---|---|
| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating **no** agents (ACP `session/new` creates them on demand) |
| `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by clients that can complete ACP elicitation requests |
| `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log (the bridge advertises `loadSession`) |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC |
| `@deepseek-ai/dsh-acp` | the bridge that owns stdout for JSON-RPC and provides ACP-backed user answers when a leaf explicitly exposes a user-question tool |
| ~~`@deepseek-ai/dsh-tool-ask-user`~~ | **omitted by default** — ACP elicitation support is still client-dependent, so leaves must opt in deliberately |
| ~~console logger~~ | **omitted** — it writes to stdout and would corrupt the protocol frames ([the stdout-purity footgun](../acp/README.md)) |
| ~~`hmr`~~ | **omitted** — the editor owns the subprocess |

View File

@@ -36,6 +36,7 @@
"@deepseek-ai/dsh-acp": "^0.0.1",
"@deepseek-ai/dsh-agent-core": "^0.0.1",
"@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
},
@@ -47,6 +48,7 @@
"@deepseek-ai/dsh-agent-core": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"cordis": "^4.0.0-rc.6",
"schemastery": "^3.17.0"
}

View File

@@ -35,6 +35,7 @@ import z from 'schemastery'
import * as acp from '@deepseek-ai/dsh-acp'
import * as agentCore from '@deepseek-ai/dsh-agent-core'
import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
export const name = 'acp-agent'
@@ -79,6 +80,7 @@ export function apply(ctx: Context, config: Config): void {
...config.persona !== undefined ? { persona: config.persona } : {},
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
})
ctx.plugin(UserInteractionService)
ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' })
ctx.plugin(acp, { model: config.model })
}

View File

@@ -30,6 +30,8 @@ describe('dsh-acp-agent composition', () => {
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionPersistence')).toBeDefined()
expect(ctx.get('agentLoop')).toBeDefined()
expect(ctx.get('userInteraction')).toBeDefined()
expect(ctx.get('tools')?.get('ask_user_question')).toBeUndefined()
// No pre-created agents — ACP session/new creates them on demand.
expect(ctx.get('agents')!.list()).toHaveLength(0)
await ctx.fiber.dispose()

View File

@@ -26,6 +26,12 @@
{
"path": "../../core/agent-core"
},
{
"path": "../user-interaction"
},
{
"path": "../tool-ask-user"
},
{
"path": "../../session-persistence/session-persistence-jsonl"
}

View File

@@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline `
`apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface.
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation).
`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session.
### Config
@@ -30,6 +30,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name:
| `session/prompt` | `agent.send()` | supports ACP `text` and `resource_link` blocks; rejects image/audio/embedded resource and empty prompts; one in-flight prompt PER session (independent); settles on the OWNING turn's end (a turn that ends in `error` rejects the RPC) |
| `session/cancel` | `agent.cancel()` | the queue-aware cancel: aborts a running step, clears queued + steering work, and drops a turn about to start, then settles the prompt `cancelled` — for ONLY that session (a cancel never touches another session's stream or prompt) |
| `session/update` | `session/event` | `agent_message_chunk` (text-delta), `agent_thought_chunk` (reasoning-delta), `user_message_chunk` (load replay), `tool_call`/`tool_call_update` (the render intent — a `card`-tagged `ToolCallView`/`ToolResultView` — owned by the TOOL via `presentCall`/`presentResult`, which the bridge switches on to build the wire shape — see Tool-call presentation) |
| `elicitation/create` | `ctx.userInteraction.ask()` | maps `ask_user_question` questions to ACP form elicitations; option descriptions are shown in enum titles, `multi_select` uses ACP array enums, optionless requests use a required `custom` field, and a non-empty custom answer overrides any selected choice |
## Multi-session

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