Merge remote-tracking branch 'origin/master' into codex/simp-session-log-representation

# Conflicts:
#	docs/cordis-catalog/services.md
#	docs/rfc/INDEX.md
#	docs/rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md
#	docs/rfc/proposed/simplification/2026-07-12-simplify-session-log-representation.md
#	packages/support/acp-snapshot/README.md
#	packages/support/acp-snapshot/src/normalize.ts
#	packages/support/acp-snapshot/src/suite.ts
#	packages/support/acp-snapshot/tests/fixtures/suite/pin-turn/session.jsonl
#	packages/support/acp-snapshot/tests/suite.spec.ts
This commit is contained in:
Tianyi Cui
2026-07-15 15:53:56 +08:00
290 changed files with 5428 additions and 2039 deletions

View File

@@ -28,7 +28,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, surface records, and bounded exact reads | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, app packages, user-approval and user-interaction seams, ask-user tool | Product — stable surface |
| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations |
| [`support/`](support/README.md) | Support infrastructure (invariants, replay, Loader smokes) | Support — lower compatibility expectations |
| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free |
The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and this table).

View File

@@ -2,6 +2,8 @@
Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `LocalBashExecutor` spawns `bash -c <command>` per call in its own process group, collects bounded output with full-stream spill files, and escalates kills SIGTERM→SIGKILL across the whole group.
The package root exports the default and named `LocalBashExecutor` plugin plus its `Config`; subprocess plumbing stays internal to the implementation package.
## Config
```yaml

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -32,6 +32,6 @@
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -14,9 +14,6 @@ import { clampTimeout, deadline, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { DEFAULT_GRACE_MS, runBash } from './run.ts'
import type { RunInternals, RunningBash } from './run.ts'
export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts'
export type { RunInternals, RunningBash, SpawnOutcome, SpawnSpec } from './run.ts'
/** Plugin config (all optional — `static Config` supplies the defaults). */
export interface Config {
/** Default working directory for commands (default: process.cwd()). */
@@ -170,7 +167,6 @@ export class LocalBashExecutor extends BashExecutor {
const id = BashTaskId(`bash-${this.nextTaskId++}`)
const task: TrackedTask = {
id,
command: spec.command,
status: 'running',
exitCode: null,
signal: null,

View File

@@ -184,27 +184,6 @@ export class OutputCollector {
writeSync(this.spillFd, chunk)
}
// TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
// the bottom of this file) and `totalBytes` is read only by a test. The live
// background-poll path goes through `readFrom()`, so inline snapshot() into
// finalize() and drop or privatize the totalBytes getter.
/**
* Read the collected tail without finalizing (the final-result snapshot).
* @returns the retained tail text, the truncation flag, and the spill path when one was created.
*/
snapshot(): CollectedOutput {
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
/** Total bytes ever pushed (including bytes dropped from memory). */
get totalBytes(): number {
return this.total
}
/**
* Incremental read in whole-stream byte coordinates: returns everything
* pushed since `fromByte`. When `fromByte` has already slid out of the
@@ -243,7 +222,11 @@ export class OutputCollector {
}
this.spillFd = undefined
}
return this.snapshot()
return {
text: Buffer.concat(this.chunks).toString('utf8'),
truncated: this.dropped,
...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
}
}
}

View File

@@ -2,8 +2,8 @@ import { mkdtempSync, readFileSync, statSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { killGroup, OutputCollector, runBash } from '@deepseek-ai/dsh-bash-local'
import type { RunningBash } from '@deepseek-ai/dsh-bash-local'
import { killGroup, OutputCollector, runBash } from '../src/run.ts'
import type { RunningBash } from '../src/run.ts'
const { failNextClose } = vi.hoisted(() => ({ failNextClose: { value: false } }))
vi.mock('node:fs', async (importOriginal) => {
@@ -49,7 +49,7 @@ async function waitGone(pid: number, timeoutMs = 5_000): Promise<void> {
async function waitForStdout(running: RunningBash, expected: string, timeoutMs = 5_000): Promise<void> {
const deadline = Date.now() + timeoutMs
while (Date.now() < deadline) {
if (running.stdout.snapshot().text.includes(expected)) return
if (running.stdout.readFrom(0).text.includes(expected)) return
await new Promise(resolve => setTimeout(resolve, 20))
}
throw new Error(`stdout did not include ${JSON.stringify(expected)} after ${timeoutMs}ms`)
@@ -295,19 +295,11 @@ describe('OutputCollector', () => {
expect(third.spillPath).toBeDefined()
})
it('tracks totalBytes across drops', () => {
const collector = new OutputCollector(4, 'test', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.totalBytes).toBe(8)
expect(collector.finalize().text).toBe('bbbb')
})
it('contains close failures and drops the spill path', () => {
const collector = new OutputCollector(4, 'closefail', spillDir)
collector.push(Buffer.from('aaaa'))
collector.push(Buffer.from('bbbb'))
expect(collector.snapshot().spillPath).toBeDefined()
expect(collector.readFrom(0).spillPath).toBeDefined()
failNextClose.value = true
let out: ReturnType<typeof collector.finalize>

View File

@@ -25,7 +25,7 @@
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-bash-local": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -36,6 +36,6 @@
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-local": "workspace:^",
"node-addon-landlock-run": "0.0.0-test.0",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -25,12 +25,12 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -215,7 +215,6 @@ export type BashTaskStatus = 'running' | 'completed' | 'killed'
/** A tracked background task handle. */
export interface BashTask {
readonly id: BashTaskId
readonly command: string
status: BashTaskStatus
/** Exit code once finished (null = killed by signal / still running). */
exitCode: number | null

View File

@@ -34,7 +34,6 @@ class StubExecutor extends BashExecutor {
start(spec: BashExecSpec): BashTask {
const task: BashTask = {
id: BashTaskId(`stub-${this.tasks.size + 1}`),
command: spec.command,
status: 'running',
exitCode: null,
signal: null,

View File

@@ -4,6 +4,8 @@ The model-facing bash tools — `bash`, `bash_output`, `bash_kill` — registere
Requires a loaded executor implementation (e.g. `@deepseek-ai/dsh-bash-local`); the plugin stays pending until `ctx.bash` exists (`inject: ['tools', 'bash', 'systemPrompt']`).
The package root exposes only the Cordis plugin contract (`name`, `inject`, `apply`); result rendering remains an implementation detail covered by same-package tests.
The plugin also contributes the `tool:bash` prompt section (order 105) — the cross-call habit the per-tool descriptions cannot carry: check the `[exit code: N]` marker on every result and investigate failures before moving on. A sandboxing executor changes the `bash` schema and result markers but adds no mode statement or switch notice; see [Per-session mode](#per-session-mode-switching).
## Tools

View File

@@ -29,7 +29,7 @@
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -44,6 +44,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -21,7 +21,8 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-user-approval'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import { BashTaskId, OwnerToken, effectiveSandboxMode } from '@deepseek-ai/dsh-bash'
import type { BashRunResult, BashTask, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { BashTask } from '@deepseek-ai/dsh-bash'
import { parseExitStatus, renderResult } from './render.ts'
export const name = 'tool-bash'
export const inject = ['tools', 'bash', 'systemPrompt']
@@ -118,65 +119,6 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string {
+ 'it — but it does not forbid attempting or escalating other commands later.'
}
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
/**
* Shape one finished run into model-visible stdout, marked stderr, and status
* facts. Non-zero exits and sandbox denials remain ordinary results; only
* infrastructure failure or abort makes the tool call itself fail.
*
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises; non-empty
* adds the same-turn escalation hint after a denial marker (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any
* timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: BashRunResult,
escalationModes: readonly SandboxMode[] = [],
): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
let body = out
if (err.length > 0) {
// Single newline between sections (stdout usually ends with one already).
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
body += `[stderr]\n${err}`
}
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// Keep `[exit code: N]` last so parseExitStatus() can recover it. A denial,
// like a timeout, remains a reported fact for the model to handle.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
// Add the retry hint only when the schema advertises escalation, before
// the final exit marker.
if (escalationModes.length > 0) {
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
}
}
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
// signal:null — the model must still see that the command was cut short.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
if (markers.length === 0) return body
if (!body.endsWith('\n')) body += '\n'
return body + markers.join('\n')
}
// Pure tool-owned presentation used for both live events and replay.
/**
@@ -224,18 +166,6 @@ function presentBashResult(args: unknown, result: ToolResult): ToolResultView |
return { card: 'terminal', output: raw, ...parseExitStatus(raw) }
}
/**
* Recover exit status from the final marked line emitted by {@link renderResult}.
* A program whose own final line exactly mimics a marker remains ambiguous for UI display.
*/
function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}
/** Pending-state presentation for `bash_output`/`bash_kill` (background-task tools). */
function presentTaskCall(verb: string, args: { task_id: string }): GenericCallView {
return { card: 'generic', title: `${verb} background task ${args.task_id}`, kind: 'execute', rawInput: args.task_id }

View File

@@ -0,0 +1,92 @@
/**
* Model-facing result rendering for the bash tool.
*
* @module @deepseek-ai/dsh-tool-bash/render
*/
import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
/** Append the truncation notice (with the full-output spill path) to a stream's text. */
function streamText(output: CollectedOutput): string {
if (!output.truncated) return output.text
return `${output.text}\n[output truncated; full output: ${output.spillPath ?? '(unavailable)'}]`
}
/**
* Shape one finished run into the text the model sees: stdout, then a marked
* stderr section, then exit-status markers. Non-zero exits are REPORTED, not
* errored — the model decides how to react; only infrastructure failures
* (spawn errors, aborts) surface as isError results.
* @param result - the completed foreground run from the executor.
* @param escalationModes - the escalation targets this composition advertises;
* non-empty adds the same-turn escalation hint after a denial marker
* (default `[]`: no hint).
* @returns the model-facing text: output body (or `(no output)`), then any timeout/signal/exit markers, each on its own line.
*/
export function renderResult(
result: BashRunResult,
escalationModes: readonly SandboxMode[] = [],
): string {
const out = streamText(result.stdout)
const err = streamText(result.stderr)
let body = out
if (err.length > 0) {
// Single newline between sections (stdout usually ends with one already).
if (body.length > 0 && !body.endsWith('\n')) body += '\n'
body += `[stderr]\n${err}`
}
if (body.length === 0) body = '(no output)'
const markers: string[] = []
// The sandbox marker precedes the exit-status markers so `[exit code: N]`
// stays the LAST line (exitStatus() anchors its parse there). Denial is a
// reported fact like timeout: the model decides how to react.
if (result.sandbox?.denied) {
markers.push(`[sandbox: file access denied under ${result.sandbox.mode} mode]`)
// The same-turn nudge lives at the decision point: only when this
// composition advertises the fields (a lever is never hinted that the
// schema does not offer), and inside the sandbox marker family so the
// exit-code marker stays the last line.
if (escalationModes.length > 0) {
markers.push('[sandbox: escalation available — retry this exact command once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]')
}
}
// Timeout is reported independently of how the process actually ended: a
// command can trap SIGTERM and exit 0 after our timer fired (e.g.
// `trap "exit 0" TERM; sleep 60`), giving timedOut:true / exitCode:0 /
// signal:null — the model must still see that the command was cut short.
if (result.timedOut) markers.push(`[timed out after ${result.timeoutMs}ms]`)
if (result.signal !== null) {
markers.push(`[killed by signal: ${result.signal}]`)
} else if (result.exitCode !== 0) {
markers.push(`[exit code: ${result.exitCode}]`)
}
if (markers.length === 0) return body
if (!body.endsWith('\n')) body += '\n'
return body + markers.join('\n')
}
/**
* Recover the structured exit status from a rendered {@link renderResult}
* string — the inverse of the status markers it appends. A killed marker
* yields `signal`; otherwise a non-zero marker yields `exitCode`; absent both
* means a clean exit 0.
*
* Replay only retains the rendered content text, not the original
* `BashRunResult`, so terminal presentation must recover the exit pill here.
* Requiring a leading newline and the end of the string keeps ordinary output
* that merely ends with marker-like text from matching unless the final line
* is indistinguishable from a real marker.
* @param text - rendered model-facing bash result.
* @returns the recovered terminal exit code or signal.
*/
export function parseExitStatus(text: string): { exitCode: number } | { signal: string } {
const signal = /\n\[killed by signal: ([^\]\n]+)\]$/.exec(text)
if (signal?.[1] !== undefined) return { signal: signal[1] }
const exit = /\n\[exit code: (\d+)\]$/.exec(text)
if (exit?.[1] !== undefined) return { exitCode: Number(exit[1]) }
return { exitCode: 0 }
}

View File

@@ -19,7 +19,7 @@ import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local'
import ApprovalService from '@deepseek-ai/dsh-user-approval'
import type { ApprovalOutcome } from '@deepseek-ai/dsh-user-approval'
import * as ToolBash from '@deepseek-ai/dsh-tool-bash'
import { renderResult } from '@deepseek-ai/dsh-tool-bash'
import { renderResult } from '../src/render.ts'
const spillDir = mkdtempSync(join(tmpdir(), 'dsh-tool-bash-spec-'))
@@ -114,7 +114,6 @@ abstract class TestBashExecutor extends BashExecutor {
class LossyReadBashExecutor extends TestBashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-lossy'),
command: 'fake',
status: 'running',
exitCode: null,
signal: null,
@@ -289,9 +288,17 @@ describe('bash tool', () => {
it('contributes the exit-code habit as its prompt section (guidance the descriptions cannot carry)', async () => {
const ctx = await setup()
ctx.systemPrompt.section({ name: 'test:before-bash', order: 104, text: 'before' })
ctx.systemPrompt.section({ name: 'test:after-bash', order: 106, text: 'after' })
const assembly = await ctx.systemPrompt.assemble()
const section = assembly.sections.find(s => s.name === 'tool:bash')
expect(section?.order).toBe(105)
expect(assembly.sections.map(s => s.name)).toEqual([
'harness:identity',
'deployment:persona',
'test:before-bash',
'tool:bash',
'test:after-bash',
])
expect(section?.text).toContain('[exit code: N]')
})
@@ -1034,7 +1041,6 @@ describe('sandbox rendering', () => {
class FactsOnlyExecutor extends TestBashExecutor {
private readonly task: BashTask = {
id: BashTaskId('bash-facts'),
command: 'fake',
status: 'completed',
exitCode: 1,
signal: null,

View File

@@ -23,7 +23,7 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
- **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.
- **Logs stream eagerly** — console/stdout/stderr text crosses the port in emission order, so a timed-out or killed program still shows what it printed. ONE shared `maxLogBytes` ledger bounds everything: streamed text, 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.
@@ -31,6 +31,8 @@ Every field is validated (positive numbers) and defaulted; there are no other tu
Source mode loads erasable-only `src/worker.ts` through Node's native type stripping. Built mode passes the sibling `lib/worker.cjs` as a filesystem path because pkg's VFS Worker hook expects CommonJS; the same path works under ordinary Node. `tests/built-lib.e2e.ts` pins the real load path required by [docs/testing.md](../../../docs/testing.md).
The SDK surface is the default/named `WorkerCodeRuntime` class plus `Config`. The operational `./worker` subpath exists only as the packaged spawn entry; the wire protocol and bootstrap helpers are source-private implementation details.
## Model Experience
Indirectly, through Code Mode in [`dsh-tools`](../../core/tools/README.md), which renders this worker's capped printed or returned data and exact `[dsh-code-runtime-worker] log capture truncated at <maxLogBytes> bytes` and `… [truncated]` markers into a retained `run_code` result. Binding traffic and worker internals stay outside context.

View File

@@ -15,7 +15,6 @@
"types": "./lib/types/worker.d.ts",
"default": "./lib/worker.cjs"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
@@ -28,13 +27,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-code-runtime": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-code-runtime": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -7,7 +7,6 @@
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'
@@ -28,12 +27,12 @@ export interface PatchableStream {
}
/**
* 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
* Ordered text capture under one shared byte budget, delivered to a sink as
* each item lands (the real sink streams text 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.
* exhausted it emits exactly one in-band marker 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
@@ -42,28 +41,28 @@ export class LogBuffer {
// 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
private readonly sink: (text: string) => void
constructor(maxBytes: number, sink: (entry: CodeLogEntry) => void) {
constructor(maxBytes: number, sink: (text: string) => 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.
* Emit text to the sink, charging it against the budget (drops + marks once exhausted).
* @param text - the captured text to deliver.
*/
push(entry: CodeLogEntry): void {
push(text: string): void {
if (this.truncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
const cost = Buffer.byteLength(text, 'utf8')
if (cost > this.remaining) {
this.truncated = true
this.sink({ source: 'stderr', text: logTruncationMarker(this.maxBytes) })
this.sink(logTruncationMarker(this.maxBytes))
return
}
this.remaining -= cost
this.sink(entry)
this.sink(text)
}
}
@@ -84,7 +83,7 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
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) }) }
shim[level] = (...args: unknown[]) => { logs.push(render(args)) }
}
return shim
}
@@ -98,17 +97,16 @@ export function makeConsoleShim(logs: LogBuffer): Record<(typeof CONSOLE_LEVELS)
*
* @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 {
export function captureStreamWrites(logs: LogBuffer, stream: PatchableStream): () => 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) })
logs.push(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(
@@ -256,9 +254,9 @@ export async function runWorkerMain(
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 logs = new LogBuffer(data.maxLogBytes, (text) => { port.postMessage({ type: 'log', text }) })
captureStreamWrites(logs, streams.stdout)
captureStreamWrites(logs, streams.stderr)
const pending = new Map<number, PendingCall>()
wireReplies(port, pending)

View File

@@ -12,14 +12,11 @@ import { fileURLToPath } from 'node:url'
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 type { CodeBindingFunction, 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 {
/**
@@ -112,10 +109,6 @@ 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
@@ -134,20 +127,8 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | 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,
},
}
if (typeof m.text !== 'string') return undefined
return { type: 'log', text: m.text }
}
case 'done': {
if (m.error === undefined) return { type: 'done', ...m.value !== undefined ? { value: m.value } : {} }
@@ -291,33 +272,33 @@ export class WorkerCodeRuntime extends CodeRuntime {
return new Promise<CodeRunResult>((resolve) => {
let settled = false
const answered = new Set<number>()
const logs: CodeLogEntry[] = []
const strayLogs: CodeLogEntry[] = []
const logs: string[] = []
const strayLogs: string[] = []
// One host-side budget covers normal, forged, and stray-pipe log entries. The first
// overflow emits the shared in-band marker and drops everything after it.
let logBudget = this.config.maxLogBytes
let logsTruncated = false
const admit = (entry: CodeLogEntry, sink: CodeLogEntry[]): void => {
const admit = (text: string, sink: string[]): void => {
if (logsTruncated) return
const cost = Buffer.byteLength(entry.text, 'utf8')
const cost = Buffer.byteLength(text, 'utf8')
if (cost > logBudget) {
logsTruncated = true
sink.push({ source: 'stderr', text: logTruncationMarker(this.config.maxLogBytes) })
sink.push(logTruncationMarker(this.config.maxLogBytes))
return
}
logBudget -= cost
sink.push(entry)
sink.push(text)
}
// 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)
const captureStray = (chunk: Buffer): void => {
admit(chunk.toString('utf8'), strayLogs)
}
worker.stdout.on('data', captureStray('stdout'))
worker.stderr.on('data', captureStray('stderr'))
worker.stdout.on('data', captureStray)
worker.stderr.on('data', captureStray)
// Exactly one outcome wins. Every path cleans up, terminates, and awaits the worker;
// logs captured before timeout, abort, or failure remain in the result.
@@ -386,7 +367,7 @@ export class WorkerCodeRuntime extends CodeRuntime {
// 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)
if (message.type === 'log' && !settled) admit(message.text, logs)
onCall(message)
onDone(message)
})

View File

@@ -5,8 +5,6 @@
* @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. */
@@ -20,7 +18,7 @@ export interface WorkerBootData {
}
/** Worker → host: one bridged binding call. */
export interface CallMessage {
interface CallMessage {
type: 'call'
/** Worker-issued correlation id; the host answers each id at most once and ignores duplicates. */
id: number
@@ -32,10 +30,10 @@ export interface CallMessage {
args: unknown
}
/** Worker → host: one captured log entry, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
export interface LogMessage {
/** Worker → host: captured text, streamed eagerly so output survives a mid-run termination (timeout, abort, OOM). */
interface LogMessage {
type: 'log'
entry: CodeLogEntry
text: string
}
/**

View File

@@ -1,9 +1,8 @@
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'
import { LogBuffer, makeConsoleShim, makeNamespaces, captureStreamWrites, prepareValue, runWorkerMain, truncateUtf8Bytes, wireReplies } from '../src/bootstrap.ts'
import type { BootstrapPort, PatchableStream, PendingCall } from '../src/bootstrap.ts'
import type { ReplyMessage, WorkerToHost } from '../src/protocol.ts'
/**
* An in-process stand-in for the worker's parentPort: the test plays the
@@ -31,8 +30,8 @@ class FakePort implements BootstrapPort {
this.emitter.emit('message', message)
}
logs(): CodeLogEntry[] {
return this.sent.filter(message => message.type === 'log').map(message => message.entry)
logs(): string[] {
return this.sent.filter(message => message.type === 'log').map(message => message.text)
}
done(): WorkerToHost | undefined {
@@ -48,12 +47,12 @@ 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([
const seen: string[] = []
const buffer = new LogBuffer(10, text => seen.push(text))
buffer.push('12345')
buffer.push('123456')
buffer.push('dropped')
expect(seen).toEqual([
'12345',
'[dsh-code-runtime-worker] log capture truncated at 10 bytes',
])
@@ -61,40 +60,37 @@ describe('LogBuffer', () => {
})
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)))
it('captures the five methods and renders non-strings inspect-style', () => {
const seen: string[] = []
const shim = makeConsoleShim(new LogBuffer(1_000, text => seen.push(text)))
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)
expect(seen).toEqual(['plain { a: 1 }', 'i', 'w', 'e', 'd'])
})
})
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))
const seen: string[] = []
const buffer = new LogBuffer(1_000, text => seen.push(text))
let underlying = ''
const stream: PatchableStream = { write: (chunk: unknown) => { underlying += String(chunk); return true } }
const restore = captureStreamWrites(buffer, stream, 'stdout')
const restore = captureStreamWrites(buffer, stream)
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(seen).toEqual(['captured', 'bytes'])
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')
captureStreamWrites(buffer, stream)
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))
@@ -107,7 +103,7 @@ describe('captureStreamWrites', () => {
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')
captureStreamWrites(buffer, stream)
stream.write('this write overflows the budget and is dropped')
await new Promise<void>(resolve => stream.write('also dropped', resolve))
})
@@ -210,7 +206,7 @@ describe('runWorkerMain', () => {
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.logs()).toEqual(['got 42'])
expect(port.done()).toEqual({ type: 'done', value: { doubled: 42 } })
})
@@ -268,6 +264,6 @@ describe('runWorkerMain', () => {
// 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' })
expect(port.logs().at(-1)).toBe('never seen — already restored? no: patch persists in worker')
})
})

View File

@@ -38,9 +38,9 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => {
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 }
const result = JSON.parse(lastLine) as { value?: unknown; logs: string[]; error?: unknown }
expect(result.error).toBeUndefined()
expect(result.value).toBe(42)
expect(result.logs).toContainEqual({ source: 'console', level: 'log', text: 'halfway 42' })
expect(result.logs).toContain('halfway 42')
})
})

View File

@@ -28,7 +28,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
expect(runtime.isolation).toBe('worker-thread')
})
it('runs TypeScript (erasable syntax), captures console/stdout in order, returns the value', async () => {
it('runs TypeScript (erasable syntax), captures output in order, returns the value', async () => {
const { runtime } = await setup()
const result = await runtime.run({
program: `
@@ -43,12 +43,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
})
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 }')
expect(result.logs).toEqual(['point { x: 1, y: 2 }', 'raw-out\n', 'careful'])
})
it('bridges binding calls both ways and rejects the program-side call on a host rejection', async () => {
@@ -115,7 +110,7 @@ describe('WorkerCodeRuntime — programs and bindings (real workers)', () => {
bindings: [],
})
expect(result.error?.kind).toBe('exception')
expect(result.logs.map(entry => entry.text)).toContain('before')
expect(result.logs).toContain('before')
})
})
@@ -210,8 +205,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
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(result.logs.at(-1)).toContain('truncated at 300 bytes')
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(text, 'utf8'), 0)
expect(total).toBeLessThan(1_000)
})
@@ -241,7 +236,7 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
})
expect(result.error).toBeUndefined()
expect(result.value).toBe('done')
expect(result.logs).toContainEqual({ source: 'stdout', text: 'flushed' })
expect(result.logs).toContain('flushed')
})
it('caps a huge container whose bounded rendering is small (wire size, not rendering, is what counts)', async () => {
@@ -268,8 +263,8 @@ describe('WorkerCodeRuntime — budgets and containment (real workers)', () => {
bindings: [],
})
expect(result.error).toBeUndefined()
expect(result.logs).toContainEqual({ source: 'stdout', text: 'abcd' })
expect(result.logs.map(entry => entry.text)).not.toContain('ef')
expect(result.logs).toContain('abcd')
expect(result.logs).not.toContain('ef')
}, 15_000)
})
@@ -304,11 +299,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
{ 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: 'log', text: null },
{ type: 'log', text: 7 },
{ type: 'log', text: {} },
{ type: 'done', error: 5 },
{ type: 'done', error: { message: 5 } },
]) parentPort.postMessage(junk);
@@ -329,7 +322,7 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
// 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 } });
for (let i = 0; i < 50; i++) parentPort.postMessage({ type: 'log', text: 'F'.repeat(100), forged: true });
parentPort.postMessage({ type: 'done', value: 'V'.repeat(100000) });
for (;;) {}
`,
@@ -341,10 +334,9 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
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)
const total = result.logs.reduce((sum, text) => sum + Buffer.byteLength(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)
expect(result.logs.at(-1)).toBe(marker)
})
it('accepts a forged done carrying both value and error (self-sabotage, contained)', async () => {

View File

@@ -16,7 +16,7 @@ Semantics every implementation must honor (contract details in the class JSDoc):
## 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.
`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?`, ordered capped `logs: string[]`, and the `error?` (`CodeRunFailure`: `kind` + model-feedable `message`). See `src/types.ts` for the full contracts.
## Model Experience

View File

@@ -22,9 +22,9 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -10,7 +10,6 @@ import type { CodeRunRequest, CodeRunResult } from './types.ts'
export type {
CodeBindingFunction,
CodeBindingNamespace,
CodeLogEntry,
CodeRunFailure,
CodeRunRequest,
CodeRunResult,

View File

@@ -54,20 +54,6 @@ export interface CodeRunRequest {
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
@@ -98,8 +84,8 @@ export interface CodeRunResult {
* or value-less run leaves this absent.
*/
value?: unknown
/** Everything the program emitted, in order (capped by the implementation). */
logs: CodeLogEntry[]
/** Text the program emitted, in order (capped by the implementation). */
logs: string[]
/** Present iff the run failed; see {@link CodeRunFailure} for the taxonomy. */
error?: CodeRunFailure
}

View File

@@ -55,7 +55,7 @@ describe('CodeRuntime service seam', () => {
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' }],
logs: ['boom'],
error: { kind: 'exception', message: 'boom' },
}
const result = await runtime.run({ program: 'throw new Error("boom")', bindings: [] })

View File

@@ -26,7 +26,7 @@
"@deepseek-ai/dsh-compact": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -37,6 +37,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -24,11 +24,11 @@
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -18,7 +18,7 @@ When `timeZone` is omitted, the plugin resolves the Node process's system zone o
The duration starts at the latest user, assistant, tool-result, context, or steering message before the current `turn/start`. Every refresh in the turn retains that baseline, so the current prompt does not collapse the interval to approximately zero. The first turn reports that no earlier message exists. The durable clock source is session-event append time, not client send time.
The loop records the dynamic section in `request/header` / `request/header-delta`. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history.
The loop records the dynamic section in full `request/header` snapshots. Requests therefore remain reconstructable, carry one timing block, and retain no earlier readings in conversation history.
## Model Experience

View File

@@ -27,7 +27,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -36,6 +36,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -37,8 +37,8 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7",
"@cordisjs/plugin-timer": "workspace:^"
}
}

View File

@@ -240,8 +240,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
'registerSearchProvider(provider: WebSearchProvider): () => void',
'registerFetchProvider(provider: WebFetchProvider): () => void',
'async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>',
'async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>',
'async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>',
'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>',
],
},
{
@@ -387,18 +387,6 @@ export const EVENT_API: readonly EventApiEntry[] = [
signature: '\'session/flush\'(this: Scoped<Session>, session: Session): Promise<void> | void',
summary: 'Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto.',
},
{
name: 'skill/provider-added',
mode: 'emit',
signature: '\'skill/provider-added\'(provider: SkillProvider): void',
summary: 'A skill provider became resolvable in the `ctx.skills` registry.',
},
{
name: 'skill/provider-removed',
mode: 'emit',
signature: '\'skill/provider-removed\'(name: string): void',
summary: 'A skill provider left the registry because its plugin fiber was disposed.',
},
{
name: 'subagent/end',
mode: 'emit',
@@ -567,7 +555,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'AssembledSection',
declaration: 'export interface AssembledSection {\n name: string;\n order: number;\n text: string;\n}',
declaration: 'export interface AssembledSection {\n name: string;\n text: string;\n}',
},
{
name: 'BashExecRequest',
@@ -587,7 +575,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'BashTask',
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n readonly command: string;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n}',
declaration: 'export interface BashTask {\n readonly id: BashTaskId;\n status: BashTaskStatus;\n exitCode: number | null;\n signal: NodeJS.Signals | null;\n readonly done: Promise<void>;\n sandbox?: BashSandboxInfo;\n}',
},
{
name: 'BashTaskId',
@@ -621,10 +609,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'CodeBindingNamespace',
declaration: 'export interface CodeBindingNamespace {\n global: string;\n functions: Record<string, CodeBindingFunction>;\n}',
},
{
name: 'CodeLogEntry',
declaration: 'export interface CodeLogEntry {\n source: \'console\' | \'stdout\' | \'stderr\';\n level?: \'log\' | \'info\' | \'warn\' | \'error\' | \'debug\';\n text: string;\n}',
},
{
name: 'CodeRunFailure',
declaration: 'export interface CodeRunFailure {\n kind: \'exception\' | \'timeout\' | \'abort\' | \'worker-exit\';\n message: string;\n}',
@@ -635,7 +619,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'CodeRunResult',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: CodeLogEntry[];\n error?: CodeRunFailure;\n}',
declaration: 'export interface CodeRunResult {\n value?: unknown;\n logs: string[];\n error?: CodeRunFailure;\n}',
},
{
name: 'CollectedOutput',
@@ -991,7 +975,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ToolExecutionResult',
declaration: 'export interface ToolExecutionResult {\n callId: CallId;\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
declaration: 'export interface ToolExecutionResult {\n content: ContentBlock[];\n isError: boolean;\n error?: ToolErrorInfo;\n additionalContext?: HookContext;\n meta?: unknown;\n}',
},
{
name: 'ToolExecutionToken',
@@ -1045,33 +1029,25 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'UserInteractionProvider',
declaration: 'export interface UserInteractionProvider {\n ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>;\n}',
},
{
name: 'WebExecContext',
declaration: 'export interface WebExecContext {\n readonly signal?: AbortSignal;\n}',
},
{
name: 'WebFetchBody',
declaration: 'export type WebFetchBody = {\n readonly kind: \'html\';\n readonly content: string;\n} | {\n readonly kind: \'text\';\n readonly content: string;\n};',
},
{
name: 'WebFetchProvider',
declaration: 'export interface WebFetchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult>;\n}',
declaration: 'export interface WebFetchProvider {\n readonly id: string;\n available(): boolean;\n fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>;\n}',
},
{
name: 'WebFetchRequest',
declaration: 'export interface WebFetchRequest {\n readonly url: string;\n readonly timeoutMs?: number;\n}',
declaration: 'export interface WebFetchRequest {\n readonly url: string;\n}',
},
{
name: 'WebFetchResult',
declaration: 'export interface WebFetchResult {\n readonly providerId: string;\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
},
{
name: 'WebProviderStatus',
declaration: 'export type WebProviderStatus = {\n readonly available: true;\n} | {\n readonly available: false;\n readonly reason: \'missing-credential\' | \'misconfigured\';\n};',
declaration: 'export interface WebFetchResult {\n readonly url: string;\n readonly statusCode: number;\n readonly body: WebFetchBody;\n readonly truncated: boolean;\n}',
},
{
name: 'WebSearchProvider',
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n status(): WebProviderStatus;\n search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult>;\n}',
declaration: 'export interface WebSearchProvider {\n readonly id: string;\n available(): boolean;\n search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>;\n}',
},
{
name: 'WebSearchRequest',
@@ -1079,7 +1055,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'WebSearchResult',
declaration: 'export interface WebSearchResult {\n readonly providerId: string;\n readonly query: string;\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}',
declaration: 'export interface WebSearchResult {\n readonly content?: string;\n readonly sources: readonly WebSearchSource[];\n readonly truncated: boolean;\n}',
},
{
name: 'WebSearchSource',

View File

@@ -34,7 +34,7 @@
"@deepseek-ai/dsh-tool-bash": "^0.0.1",
"@deepseek-ai/dsh-tool-skill": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-timer": "workspace:^",
@@ -49,7 +49,7 @@
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"

View File

@@ -28,7 +28,7 @@
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -43,6 +43,6 @@
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1043,8 +1043,7 @@ describe('tool result call identity', () => {
// A post-execute listener transforms the result (accept-with-replacement).
// The loop must still record the tool/result under the model's authoritative
// call.id (the loop ignores result.callId — which the registry always sets to
// exec.callId anyway — and uses call.id, the model-transcript id).
// call.id, which is the immutable identity carried by the execution input.
ctx.on('tools/post-execute', (exec, _result) => {
expect(exec.callId).toBe(CallId('c1')) // the loop passed the real id in
return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] })
@@ -1054,8 +1053,7 @@ describe('tool result call identity', () => {
send(agent, 'use tool')
await waitForIdle(ctx, agent)
// The logged tool/result.callId is the originating call.id, NOT the
// listener's wrong id.
// The logged tool/result.callId is the originating call.id.
const resultEvent = [...agent.session.events].find(e => e.type === 'tool/result')
expect(resultEvent?.type).toBe('tool/result')
if (resultEvent?.type === 'tool/result') {

View File

@@ -27,7 +27,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
@@ -35,6 +35,6 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -22,9 +22,9 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -25,12 +25,12 @@
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -41,6 +41,7 @@ declare module 'cordis' {
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners
* receive only sessions entered through that agent's context.
* @param session - the session just entered and announced.
* @dshScopeScan unsupported
* @mode emit
*/
'session/created'(this: Scoped<Session>, session: Session): void
@@ -50,6 +51,7 @@ declare module 'cordis' {
* did not begin. Listener failures are logged and contained.
* Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope.
* @param session - the session that is no longer live in the store.
* @dshScopeScan unsupported
* @mode emit
*/
'session/disposed'(this: Scoped<Session>, session: Session): void
@@ -61,6 +63,7 @@ declare module 'cordis' {
* receive only events from sessions entered through that agent's context.
* @param session - the session whose log grew.
* @param event - the appended event, exactly as recorded.
* @dshScopeScan unsupported
* @mode emit
*/
'session/event'(this: Scoped<Session>, session: Session, event: SessionEvent): void
@@ -70,6 +73,7 @@ declare module 'cordis' {
* {@link SessionStore.flush}. Scope-filtered dispatch
* (`@deepseek-ai/dsh-scope`) reuses the session's owner scope.
* @param session - the session whose buffered events must reach durable storage.
* @dshScopeScan unsupported
* @mode parallel
*/
'session/flush'(this: Scoped<Session>, session: Session): Promise<void> | void

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -32,6 +32,6 @@
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -65,10 +65,6 @@ export interface PromptSection {
export interface AssembledSection {
/** The contributing section's unique name. */
name: string
// TODO(assembled-section-order): drop this output field; registry order has
// already sorted the array, and no production renderer/listener reads it.
/** The contributing section's order (sections arrive sorted ascending). */
order: number
/** The resolved (but not yet interpolated) section text. */
text: string
}
@@ -226,7 +222,7 @@ export class SystemPrompt extends Service {
private scopedVariableProviders = new Map<ScopeKey, Map<string, (context: AssembleContext) => string | undefined>>()
private readonly toolOrder: string[] | undefined
constructor(ctx: Context, public config: Config) {
constructor(ctx: Context, config: Config) {
super(ctx, 'systemPrompt')
this.toolOrder = validateToolOrder(config.toolOrder)
// Keep harness-owned openers independent of the selected loop plugin.
@@ -403,12 +399,11 @@ export class SystemPrompt extends Service {
}
const assembly: PromptAssembly = {
sections: [...sectionByName.values()]
.sort((a, b) => a.order - b.order)
.map(section => ({
name: section.name,
order: section.order,
text: typeof section.text === 'function' ? section.text(context) : section.text,
}))
.sort((a, b) => a.order - b.order),
})),
tools: orderTools(collected, this.toolOrder, knownNames),
variables,
}

View File

@@ -139,7 +139,7 @@ describe('scoped assemble dispatch', () => {
scope.ctx.on('system-prompt/assemble', async (_assembly: PromptAssembly, context, next: () => Promise<PromptAssembly>) => {
shaped.push(context.scope)
const result = await next()
result.sections.push({ name: 'listener:extra', order: 999, text: 'listener text' })
result.sections.push({ name: 'listener:extra', text: 'listener text' })
return result
})

View File

@@ -21,9 +21,9 @@ describe('SystemPrompt', () => {
await ctx.plugin(SystemPrompt, { persona: 'You are DeepSeek Harness SDK.' })
const assembly = await ctx.systemPrompt.assemble()
expect(assembly.sections.map(s => [s.name, s.order])).toEqual([
['harness:identity', -100],
['deployment:persona', 0],
expect(assembly.sections.map(s => s.name)).toEqual([
'harness:identity',
'deployment:persona',
])
expect(renderPrompt(assembly)).toBe(`${IDENTITY}\n\nYou are DeepSeek Harness SDK.`)
// The names are reserved by the plugin — one owner per section.
@@ -183,7 +183,7 @@ describe('SystemPrompt', () => {
const contexts: AssembleContext[] = []
ctx.on('system-prompt/assemble', async (assembly: PromptAssembly, context, next) => {
contexts.push(context)
assembly.sections.push({ name: 'from-a', order: 100, text: 'a' })
assembly.sections.push({ name: 'from-a', text: 'a' })
return next()
})
// Listener B (registered later, runs after A) sees A's contribution.
@@ -235,8 +235,8 @@ describe('SystemPrompt', () => {
it('filters out empty section text from renderPrompt', () => {
const result = renderPrompt({
sections: [
{ name: 'empty', order: 0, text: '' },
{ name: 'real', order: 1, text: 'content' },
{ name: 'empty', text: '' },
{ name: 'real', text: 'content' },
],
tools: [],
variables: {},
@@ -356,13 +356,13 @@ describe('SystemPrompt', () => {
})
it('names "(none)" when no variables are registered at all', () => {
expect(() => renderPrompt({ sections: [{ name: 's', order: 0, text: '{{x}}' }], tools: [], variables: {} }))
expect(() => renderPrompt({ sections: [{ name: 's', text: '{{x}}' }], tools: [], variables: {} }))
.toThrow('unknown prompt variable "{{x}}" in section "s"; registered variables: (none)')
})
it('throws when a referenced variable has no value for this assembly', () => {
expect(() => renderPrompt({
sections: [{ name: 'persona', order: 0, text: 'in {{cwd}}' }],
sections: [{ name: 'persona', text: 'in {{cwd}}' }],
tools: [],
variables: { cwd: undefined },
})).toThrow('prompt variable "{{cwd}}" has no value for this assembly (section "persona")')
@@ -370,7 +370,7 @@ describe('SystemPrompt', () => {
it('throws on a malformed complete reference, e.g. inner spaces', () => {
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text: 'on {{ model }}' }],
sections: [{ name: 's', text: 'on {{ model }}' }],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference "{{ model }}" in section "s"')
@@ -378,7 +378,7 @@ describe('SystemPrompt', () => {
it('leaves a lone {{ verbatim only when NO }} follows anywhere after it', () => {
const text = renderPrompt({
sections: [{ name: 's', order: 0, text: 'shell ${X:-{{fallback} stays' }],
sections: [{ name: 's', text: 'shell ${X:-{{fallback} stays' }],
tools: [],
variables: {},
})
@@ -390,7 +390,7 @@ describe('SystemPrompt', () => {
{ text: 'x {{a{b}} y {{model}}', label: 'nested brace inside a would-be group' },
])('throws on a mangled reference with a }} still following ($label)', ({ text }) => {
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text }],
sections: [{ name: 's', text }],
tools: [],
variables: { model: 'm' },
})).toThrow('malformed prompt variable reference at')
@@ -400,7 +400,7 @@ describe('SystemPrompt', () => {
// `in` would find Object.prototype.constructor and splice function
// source into the prompt; Object.hasOwn must reject it instead.
expect(() => renderPrompt({
sections: [{ name: 's', order: 0, text: 'on {{constructor}}' }],
sections: [{ name: 's', text: 'on {{constructor}}' }],
tools: [],
variables: { model: 'm' },
})).toThrow('unknown prompt variable "{{constructor}}"')
@@ -416,7 +416,7 @@ describe('SystemPrompt', () => {
it('never re-scans substituted values (a value containing {{sneaky}} stays literal)', () => {
const text = renderPrompt({
sections: [{ name: 's', order: 0, text: 'v = {{model}}!' }],
sections: [{ name: 's', text: 'v = {{model}}!' }],
tools: [],
variables: { model: 'literal {{sneaky}} inside' },
})

View File

@@ -36,7 +36,7 @@ The live registry pipeline has three transformable waterfalls followed by the ob
- `ToolExecutionInput` — the caller-supplied call description: `{ callId, name, arguments, agent?, parent?, signal? }`; callers may pass an enclosing execution's opaque token as `parent` but never choose the new execution's own token.
- `ToolExecutionToken` — a fresh branded `Symbol` assigned by the registry. It supports equality correlation only and never crosses a model, log, or worker boundary.
- `ToolExecution` — the pipeline-owned call: immutable `{ token, callId, name, arguments, agent?, parent? }` identity plus optional operational `signal`, which an around wrapper may add, replace, remove, and restore. A nested call's `parent` is a `ToolExecutionToken`, not an execution object.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ callId, content, isError, error?, additionalContext?, meta? }`. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `ToolExecutionResult` — losslessly JSON-serializable outcome: `{ content, isError, error?, additionalContext?, meta? }`. Call identity stays on the immutable `ToolExecution` supplied alongside the result instead of being duplicated on the outcome. The registry materializes and freezes the complete post-policy value before final observation. On failure with a `HarnessError`, `error: { name, code }` carries the structured failure class alongside the model-facing text.
- `PreToolDecision``{kind:'allow'}` | `{kind:'deny', reason}` | `{kind:'ask', reason?}`. Input rewrite is deliberately not offered; `ask` is serviced by [`ctx.approval`](../../ui/user-approval/README.md) when mounted and otherwise degrades to deny.
- `PostToolDecision``{kind:'accept', content?, additionalContext?}` (keep the call successful, optionally replacing the model-facing content) | `{kind:'block', feedback, additionalContext?}` (turn it into an `isError` whose content is the corrective feedback). Output replacement is clean because `tool/result` is logged AFTER `execute()` returns.
- `ToolGuard``(execution) => string | undefined`; the returned string is a final monotonic denial reason evaluated after the reorderable pre-execute waterfall and before dispatch.

View File

@@ -29,7 +29,7 @@
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -42,6 +42,6 @@
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -108,14 +108,13 @@ function renderValue(value: unknown): string {
/** The run_code result's `meta` payload (JSON-serializable; `presentResult` narrows it back). */
interface RunCodeMeta {
logs: CodeRunResult['logs']
dispatches: number
}
/** Soft-narrow a result `meta` back to {@link RunCodeMeta} (replay may carry older shapes; presentation must not throw). */
function asRunCodeMeta(meta: unknown): RunCodeMeta | undefined {
if (typeof meta !== 'object' || meta === null) return undefined
const m = meta as Record<string, unknown>
if (!Array.isArray(m.logs) || typeof m.dispatches !== 'number') return undefined
if (!Array.isArray(m.logs) || !m.logs.every(log => typeof log === 'string')) return undefined
return m as unknown as RunCodeMeta
}
@@ -251,12 +250,12 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
}
if (result.error) {
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.map(entry => entry.text).join('\n')}` : ''
const logsText = result.logs.length > 0 ? `\nCaptured output:\n${result.logs.join('\n')}` : ''
throw new CodeRunFailedError(`code run failed (${result.error.kind}): ${result.error.message}${logsText}`)
}
const rendered = renderValue(result.value)
const parts = [result.logs.map(entry => entry.text).join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs, dispatches }
const parts = [result.logs.join('\n'), rendered].filter(part => part.length > 0)
const meta: RunCodeMeta = { logs: result.logs }
return {
content: [{ type: 'text', text: parts.length > 0 ? parts.join('\n') : '(run_code completed with no output)' }],
meta,
@@ -278,7 +277,7 @@ export function createRunCodeTool(registry: ToolRegistry, requireRuntime: () =>
presentResult: (_args, result) => {
const meta = asRunCodeMeta(result.meta)
if (!meta) return undefined
const output = meta.logs.map(entry => entry.text).join('\n')
const output = meta.logs.join('\n')
return {
card: 'generic',
...output.length > 0 ? { content: [{ type: 'text' as const, text: output }] } : {},

View File

@@ -220,7 +220,7 @@ export interface ToolErrorInfo {
* distinguish it from a tool body's own error.
*/
export class ToolNotFoundError extends HarnessError {
constructor(public readonly toolName: string) {
constructor(toolName: string) {
super(`unknown tool "${toolName}"`, 'UNKNOWN_TOOL')
this.name = 'ToolNotFoundError'
}
@@ -228,7 +228,6 @@ export class ToolNotFoundError extends HarnessError {
/** The outcome of one tool call. */
export interface ToolExecutionResult {
callId: CallId
content: ContentBlock[]
isError: boolean
/**
@@ -704,7 +703,7 @@ export class ToolRegistry extends Service {
}
} catch (error: unknown) {
execution = { ...base, arguments: undefined }
const result = this.materializeFinalResult(toolErrorResult(callId, error))
const result = this.materializeFinalResult(toolErrorResult(error))
this.notifyResult(execution, result)
return result
}
@@ -714,7 +713,7 @@ export class ToolRegistry extends Service {
} catch (error: unknown) {
// Outer backstop: a throwing pre/post-execute listener, guard, or the
// waterfall machinery becomes an isError result, never a turn failure.
result = this.materializeFinalResult(toolErrorResult(execution.callId, error))
result = this.materializeFinalResult(toolErrorResult(error))
}
this.notifyResult(execution, result)
return result
@@ -739,7 +738,6 @@ export class ToolRegistry extends Service {
// Every non-grant, including a failed/unavailable approval request, takes
// the same deny path and still reaches post-policy plus result observers.
const denied: ToolExecutionResult = {
callId: exec.callId,
content: [{ type: 'text', text: `Error: ${denialReason}` }],
isError: true,
}
@@ -770,16 +768,12 @@ export class ToolRegistry extends Service {
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 } : {} }
return { content, isError: false, ...meta !== undefined ? { meta } : {} }
} catch (error: unknown) {
return toolErrorResult(exec.callId, error)
return toolErrorResult(error)
}
},
)
if (result.callId !== exec.callId) {
throw new TypeError(`tools/execute returned callId "${String(result.callId)}" for authoritative call "${exec.callId}"`)
}
return await this.postExecute(exec, result)
}
@@ -854,7 +848,6 @@ export class ToolRegistry extends Service {
const additionalContext = decision.additionalContext
if (decision.kind === 'block') {
return {
callId: result.callId,
content: decision.feedback,
isError: true,
...additionalContext ? { additionalContext } : {},
@@ -883,10 +876,9 @@ function createExecutionToken(): ToolExecutionToken {
return Symbol('dsh.tool.execution') as ToolExecutionToken
}
function toolErrorResult(callId: ToolExecution['callId'], error: unknown): ToolExecutionResult {
function toolErrorResult(error: unknown): ToolExecutionResult {
const info = errorInfo(error)
return {
callId,
content: [{ type: 'text', text: `Error: ${errorMessage(error)}` }],
isError: true,
...info ? { error: info } : {},

View File

@@ -328,7 +328,7 @@ describe('the run_code dispatch bridge', () => {
const tools = request.bindings[0]!.functions
const first = await tools.echo!({ value: 'one' })
const second = await tools.echo!({ value: 'two' })
return { logs: [{ source: 'console', level: 'log', text: `saw ${String(first)}` }], value: second }
return { logs: [`saw ${String(first)}`], value: second }
}
const result = await runCode(ctx, 'const …: string = …', { agent })
expect(result.isError).toBe(false)
@@ -339,7 +339,7 @@ describe('the run_code dispatch bridge', () => {
{ parentCallId: 'call-1', subCallId: 'call-1:code:1', name: 'echo', arguments: { value: 'one' }, isError: false, resultSummary: 'echo:one' },
{ parentCallId: 'call-1', subCallId: 'call-1:code:2', name: 'echo', arguments: { value: 'two' }, isError: false, resultSummary: 'echo:two' },
])
expect(result.meta).toEqual({ logs: [{ source: 'console', level: 'log', text: 'saw echo:one' }], dispatches: 2 })
expect(result.meta).toEqual({ logs: ['saw echo:one'] })
})
it('exposes only an opaque parent token to nested result observers', async () => {
@@ -503,7 +503,7 @@ describe('the run_code dispatch bridge', () => {
it('converts a failed run into a structured isError result carrying kind, message, and captured logs', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
runtime.behavior = () => Promise.resolve({
logs: [{ source: 'console', level: 'log', text: 'got this far' }],
logs: ['got this far'],
error: { kind: 'timeout', message: 'compute budget exhausted (300ms busy)' },
})
const result = await runCode(ctx, 'program')
@@ -627,7 +627,7 @@ describe('the run_code dispatch bridge', () => {
const view = tool.presentResult?.({ code: 'return 1' }, {
content: [{ type: 'text', text: 'model-facing' }],
isError: false,
meta: { logs: [{ source: 'console', level: 'log', text: 'printed' }], dispatches: 1 },
meta: { logs: ['printed'] },
})
// The result omits the title — an update replaces only provided fields,
// so the pending card's program title persists through completion.
@@ -636,9 +636,10 @@ describe('the run_code dispatch bridge', () => {
content: [{ type: 'text', text: 'printed' }],
})
// No captured output → no content either; everything pending persists.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [], dispatches: 2 } }))
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [] } }))
.toEqual({ card: 'generic' })
// Replay with an unrecognizable meta falls back to the generic rendering.
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { logs: [{ text: 'legacy' }], dispatches: 1 } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false, meta: { other: true } })).toBeUndefined()
expect(tool.presentResult?.({ code: 'x' }, { content: [], isError: false })).toBeUndefined()
})

View File

@@ -548,7 +548,6 @@ describe('scoped execution dispatch', () => {
expect(reads).toBe(1)
expect(result).toEqual({
callId: CallId('unstable-arguments'),
content: [{ type: 'text', text: 'ran:t' }],
isError: false,
})
@@ -564,10 +563,9 @@ describe('scoped execution dispatch', () => {
ctx.on('internal/dispatch', (mode, name) => {
if (name === 'tools/result') dispatchModes.push(mode)
})
ctx.on('tools/execute', async (exec, next) => {
ctx.on('tools/execute', async (_exec, next) => {
await next()
return {
callId: exec.callId,
content: [{ type: 'text', text: 'outer failure' }],
isError: true,
}

View File

@@ -80,7 +80,7 @@ describe('ToolRegistry', () => {
const ctx = await setup()
ctx.tools.register(echoTool)
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'hi' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'hi' }], isError: false })
})
it('threads a tool-attached meta (object return form) onto the result', async () => {
@@ -94,7 +94,6 @@ describe('ToolRegistry', () => {
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'meta-tool', arguments: {} })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
meta: { diffs: [{ path: 'a', oldText: null, newText: 'x' }] },
@@ -111,7 +110,7 @@ describe('ToolRegistry', () => {
},
})
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'no-meta-tool', arguments: {} })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }], isError: false })
expect('meta' in result).toBe(false)
})
@@ -178,13 +177,12 @@ describe('ToolRegistry', () => {
})
})
it('ToolNotFoundError carries the tool name and a stable code', async () => {
it('ToolNotFoundError carries a stable message and code', async () => {
const { HarnessError } = await import('@deepseek-ai/dsh-llm')
const err = new ToolNotFoundError('ghost')
expect(err).toBeInstanceOf(HarnessError)
expect(err.name).toBe('ToolNotFoundError')
expect(err.code).toBe('UNKNOWN_TOOL')
expect(err.toolName).toBe('ghost')
expect(err.message).toBe('unknown tool "ghost"')
})
@@ -425,7 +423,7 @@ describe('ToolRegistry', () => {
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 })
expect(result).toEqual({ 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'])
})
@@ -526,8 +524,8 @@ describe('ToolRegistry', () => {
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 }))
ctx.on('tools/execute', async (_exec: ToolExecution, _next: () => Promise<ToolExecutionResult>): Promise<ToolExecutionResult> =>
({ 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
@@ -537,8 +535,7 @@ describe('ToolRegistry', () => {
it('preserves additionalContext supplied by an around-dispatch result', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async exec => ({
callId: exec.callId,
ctx.on('tools/execute', async () => ({
content: [{ type: 'text', text: 'short-circuited with context' }],
isError: false,
additionalContext: {
@@ -556,20 +553,6 @@ describe('ToolRegistry', () => {
})
})
it('normalizes a tools/execute result with the wrong call id', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
ctx.on('tools/execute', async () => ({ callId: CallId('other'), content: [], isError: false }))
const result = await ctx.tools.execute({
callId: CallId('malformed-shape'), name: 'echo', arguments: {},
})
expect(result.isError).toBe(true)
expect(result.content[0]).toMatchObject({
text: 'Error: tools/execute returned callId "other" for authoritative call "malformed-shape"',
})
})
it('returns an isError result when a tools/execute listener throws', async () => {
const ctx = await setup()
ctx.tools.register(echoTool)
@@ -577,7 +560,6 @@ describe('ToolRegistry', () => {
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,
})
@@ -593,7 +575,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: permission hook broke' }],
isError: true,
})
@@ -609,7 +590,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toEqual({
callId: CallId('c1'),
content: [{ type: 'text', text: 'Error: post hook broke' }],
isError: true,
})
@@ -625,7 +605,6 @@ describe('ToolRegistry', () => {
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'echo', arguments: { text: 'hi' } })
expect(result).toMatchObject({
callId: CallId('c1'),
isError: true,
error: { name: 'HarnessError', code: 'DENIED' },
})
@@ -1254,7 +1233,7 @@ describe('defineTool validation (the runtime-validation RFC, part 1)', () => {
},
}))
const result = await ctx.tools.execute({ callId: CallId('c1'), name: 'reader', arguments: { path: '/x' } })
expect(result).toEqual({ callId: CallId('c1'), content: [{ type: 'text', text: 'read /x' }], isError: false })
expect(result).toEqual({ content: [{ type: 'text', text: 'read /x' }], isError: false })
})
it('ToolArgsError carries a stable code and the violation list', () => {

View File

@@ -19,7 +19,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
The raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.
## Model Experience

View File

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

View File

@@ -31,20 +31,6 @@ import {
} from './fsio.ts'
import type { FsIoInternals } from './fsio.ts'
export {
applyLiteralEdit,
listDirectory,
probe,
readForEdit,
readTextForDiff,
readWholeText,
resolveLocalTarget,
restoreLineEndings,
streamWholeText,
writeFileAtomic,
} from './fsio.ts'
export type { FsIoInternals, LineEndings, LocalDirEntry, LocalTarget, PathInfo } from './fsio.ts'
/** Configuration for the local filesystem backend. */
export interface Config {
/** Base directory for relative paths. Defaults to `process.cwd()`. */

View File

@@ -20,8 +20,8 @@ import {
restoreLineEndings,
streamWholeText,
writeFileAtomic,
} from '@deepseek-ai/dsh-fs-local'
import type { LocalTarget } from '@deepseek-ai/dsh-fs-local'
} from '../src/fsio.ts'
import type { LocalTarget } from '../src/fsio.ts'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
let dir: string

View File

@@ -23,11 +23,11 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -24,11 +24,11 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -46,7 +46,7 @@ The tool passes `exec` (the tool-execution context) as the opaque `actor` on eve
`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event.
The read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them.
## Model Experience

View File

@@ -31,7 +31,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -44,6 +44,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -12,15 +12,6 @@ import { applyWriteTool } from './write.ts'
import { applyEditTool } from './edit.ts'
import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts'
export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts'
export type { ReadToolCaps } from './read.ts'
export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts'
export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts'
export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts'
export type { FileReadOutcome, FileTextLine, ReadWindow, WindowResult } from './read-render.ts'
export { DIFF_CONTEXT, computeHunkDiffs, diffsFromMeta } from './diff.ts'
export type { FsDiffMeta } from './diff.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-fs'

View File

@@ -6,7 +6,7 @@
*/
import { describe, expect, it } from 'vitest'
import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '@deepseek-ai/dsh-tool-fs'
import { computeHunkDiffs, diffsFromMeta, DIFF_CONTEXT } from '../src/diff.ts'
import type { JsonValue } from '@deepseek-ai/dsh-session'
const lines = (n: number): string => Array.from({ length: n }, (_, i) => `line${i + 1}`).join('\n') + '\n'

View File

@@ -6,8 +6,8 @@
*/
import { describe, expect, it } from 'vitest'
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs'
import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs'
import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '../src/read-render.ts'
import type { ReadWindow } from '../src/read-render.ts'
const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES }
const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS }

View File

@@ -20,8 +20,9 @@ import type {
} from '@deepseek-ai/dsh-fs'
import * as FsPolicy from '@deepseek-ai/dsh-fs-policy'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import { formatReadOutput, STREAM_MIN_SIZE } from '@deepseek-ai/dsh-tool-fs'
import type { FileReadOutcome } from '@deepseek-ai/dsh-tool-fs'
import { STREAM_MIN_SIZE } from '../src/read.ts'
import { formatReadOutput } from '../src/read-render.ts'
import type { FileReadOutcome } from '../src/read-render.ts'
/** An in-memory fake provider; a test can arm a rejection on any primitive. */
class FakeFs extends FileSystem {

View File

@@ -27,7 +27,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -36,6 +36,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -24,11 +24,11 @@
"peerDependencies": {
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -31,7 +31,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -44,6 +44,6 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -30,7 +30,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -42,6 +42,6 @@
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -23,13 +23,13 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -23,7 +23,7 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.79.1",
@@ -32,6 +32,6 @@
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

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

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"node-addon-landlock-run": "0.0.0-test.0",
@@ -33,6 +33,6 @@
"devDependencies": {
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -71,7 +71,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish-
// Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional
// dependencies because the launcher selects its OS/CPU package through one.
writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' }))
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.6'], {
const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], {
cwd: consumerDir,
encoding: 'utf8',
timeout: 300_000,

View File

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

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -32,6 +32,6 @@
"devDependencies": {
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -20,6 +20,19 @@ function mutableHeader(header: SessionHeader): MutableSessionHeader {
return header
}
async function expectParallelFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
await promise
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const [cause] = (error as AggregateError).errors as unknown[]
expect(cause).toBeInstanceOf(Error)
expect((cause as Error).message).toMatch(message)
return
}
throw new Error('expected parallel flush to reject')
}
async function freshRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-'))
dirs.push(dir)
@@ -699,7 +712,7 @@ describe('SessionPersistenceJsonl: edge cases', () => {
const backend = ctx2.sessionPersistence as unknown as { materialize: (...args: unknown[]) => Promise<void> }
const origMat = backend.materialize.bind(backend)
backend.materialize = () => Promise.reject(new Error('disk full'))
await expect(ctx2.parallel('session/flush', session)).rejects.toThrow(/disk full/)
await expectParallelFlushError(ctx2.parallel('session/flush', session), /disk full/)
// The events are STILL buffered (not silently dropped): a retry persists them.
backend.materialize = origMat
await ctx2.parallel('session/flush', session)

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -32,6 +32,6 @@
"devDependencies": {
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -14,6 +14,19 @@ import { runCoordinatorContract, type CoordinatorFixture } from '../../session-p
const dirs: string[] = []
afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) })
async function expectParallelFlushError(promise: Promise<unknown>, message: RegExp): Promise<void> {
try {
await promise
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const [cause] = (error as AggregateError).errors as unknown[]
expect(cause).toBeInstanceOf(Error)
expect((cause as Error).message).toMatch(message)
return
}
throw new Error('expected parallel flush to reject')
}
async function freshDbPath(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-sqlite-'))
dirs.push(dir)
@@ -441,7 +454,7 @@ describe('SessionPersistenceSqlite: edge cases', () => {
}, { inject: ['sessions'] }))
session.append('turn/start', { turn: 9, trigger: { kind: 'message', source: { kind: 'user' } } })
await ctx.plugin(SessionPersistenceSqlite, { path })
await expect(ctx.parallel('session/flush', session)).rejects.toThrow(/id collision/)
await expectParallelFlushError(ctx.parallel('session/flush', session), /id collision/)
await ctx.fiber.dispose()
})
})

View File

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

View File

@@ -25,7 +25,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
@@ -39,6 +39,6 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-fs": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0",
@@ -33,6 +33,6 @@
"devDependencies": {
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -22,12 +22,12 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -119,23 +119,6 @@ declare module 'cordis' {
interface Context {
skills: SkillService
}
interface Events {
/**
* A skill provider became resolvable in the `ctx.skills` registry.
* Consumers can observe this instead of depending on Cordis plugin load
* order, which is concurrent for sibling plugins.
* @param provider - the provider that just registered.
* @mode emit
*/
'skill/provider-added'(provider: SkillProvider): void
/**
* A skill provider left the registry because its plugin fiber was disposed.
* @param name - the registry name that no longer resolves.
* @mode emit
*/
'skill/provider-removed'(name: string): void
}
}
interface IndexedCandidate {
@@ -191,19 +174,16 @@ export class SkillService extends Service {
throw new Error(`a skill provider named "${name}" is already registered`)
}
const providers = this.providers
const ctx = this.ctx
const order = this.nextProviderOrder
const invalidateCache = (): void => { this.invalidateCache() }
this.nextProviderOrder += 1
const dispose = ctx.effect(function* () {
const dispose = this.ctx.effect(function* () {
providers.set(name, { provider, order })
invalidateCache()
yield () => {
providers.delete(name)
invalidateCache()
ctx.emit('skill/provider-removed', name)
}
ctx.emit('skill/provider-added', provider)
}, 'skills.registerProvider()')
// eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity
return dispose

View File

@@ -26,7 +26,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -38,6 +38,6 @@
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -26,7 +26,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-subprocess": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@agentclientprotocol/sdk": "0.25.1",
@@ -37,7 +37,7 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-subprocess": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -26,7 +26,7 @@
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -42,7 +42,7 @@
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -28,7 +28,7 @@
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
@@ -39,6 +39,6 @@
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -24,7 +24,7 @@
"peerDependencies": {
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-subagent-inprocess": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -43,7 +43,7 @@
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -22,9 +22,9 @@
],
"license": "BSD-3-Clause",
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -26,13 +26,13 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -77,6 +77,7 @@ declare module 'cordis' {
* parent-scoped listener observes only its own delegations. Paired with
* `subagent/end`.
* @param info - the provider and ready child identity.
* @dshScopeScan unsupported
* @mode emit
*/
'subagent/start'(this: Scoped<SubagentService>, info: SubagentRunInfo): void
@@ -85,6 +86,7 @@ declare module 'cordis' {
* parent carrier as `subagent/start`, so the lifecycle pair reaches the
* same scoped audience.
* @param info - the run identity and terminal outcome.
* @dshScopeScan unsupported
* @mode emit
*/
'subagent/end'(this: Scoped<SubagentService>, info: SubagentRunEndInfo): void

View File

@@ -26,7 +26,7 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-subagent": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
@@ -38,7 +38,7 @@
"@deepseek-ai/dsh-subagent-mock": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@cordisjs/plugin-loader": "^1.0.0-rc.4",
"cordis": "^4.0.0-rc.6"
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -6,7 +6,8 @@ Packages that exist to serve development, testing, and the examples rather than
|---|---|---|
| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) |
| `invariants/` | Runtime event-contract assertions for development diagnostics | (listens on `session/*`, `agent/*`) |
| `loader-smoke/` | Shared real-Loader subprocess harness for keyless example smokes | (library — imported by example e2e suites) |
| `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` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `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.
`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-core` bundle mounts it unconditionally. `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, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. `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

@@ -5,8 +5,8 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie
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), `scrubSystemPrompts` (prompt text → `{{system}}` in every JSONL), and `scrubRequestHeaders` (the remaining header bulk → `{{tools}}`/`{{messagePrefix}}` outside each pin, 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/refresh fixture write-back, the per-header-class pin (`system-prompt.golden.md` plus the JSONL's full tool schemas) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time.
- **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), `scrubSystemPrompts` (prompt text → `{{system}}`), `scrubToolSchemas` (schema bulk → `{{tools}}`), and `scrubRequestHeaders` (all header bulk → `{{system}}`/`{{tools}}`/`{{messagePrefix}}` outside each pin, 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/refresh fixture write-back, rejection of structured `UNKNOWN_TOOL` results, the per-header-class pin (`system-prompt.golden.md` plus `tool-schemas.golden.json`) with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL prompt/schema-scrubbed, non-pinning fixtures fully header-scrubbed). Must be called at vitest collection time.
A consuming `*.snapshot.ts` is the scenario table plus one factory call:
@@ -35,9 +35,9 @@ defineAcpSnapshotSuite({
})
```
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode scenarios are the template. Each pinning directory's generated `system-prompt.golden.md` is the reviewable snapshot of the normalized composed prompt; `session.jsonl` stores `"system":"{{system}}"` while retaining the complete tool list. A pin whose scenario legitimately changes its header mid-run declares `expectedHeaderChanges`; the Markdown snapshot then records each later full prompt under a `request/header change` marker.
A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Each pinning directory stores the normalized full prompt sequence in generated `system-prompt.golden.md` and the corresponding full tool-schema sequence in generated `tool-schemas.golden.json`; `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`, which fixes the length of both sidecar sequences.
Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, and prompt snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
Examples use a `cordis.snapshot.yml` overlay with [`dsh-llm-replay`](../llm-replay/README.md). Recording calls the live model and updates model fixtures; keyless refresh replays those fixtures and updates derived stdout, session-log, prompt, and tool-schema snapshots. See the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md).
`suite.ts` imports Vitest, so use this package only inside a Vitest run. The ACP-specific script queues permission answers by stable option kind and maps them to current option ids; a missing answer cancels, while an unavailable kind fails the scenario after cancelling the agent request. It can also set session config options or assert that unknown ids and values are rejected in the transcript.

View File

@@ -27,9 +27,9 @@
"vitest": "^4.1.8"
},
"peerDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"cordis": "^4.0.0-rc.6"
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -20,6 +20,7 @@ export {
normalizeStdout,
scrubRequestHeaders,
scrubSystemPrompts,
scrubToolSchemas,
type NormalizeContext,
} from './normalize.ts'
export {

View File

@@ -1,8 +1,8 @@
/**
* Pure ACP transcript and session-log normalizers. They scrub session ids, temp cwd, RPC ids,
* timestamps, and hook duration while preserving deterministic event sequence numbers.
* Request-header scrubbers stay separate so one scenario per header class can pin tools and a
* readable prompt while other fixtures omit duplicated header bulk.
* Request-header scrubbers stay composable so one scenario per header class can pin prompt and
* tool-schema sidecars while retaining any model-visible prefix in the session log.
* @module @deepseek-ai/dsh-acp-snapshot/normalize
*/
@@ -123,7 +123,21 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri
* @returns The JSONL with system-prompt content tokenized.
*/
export function scrubSystemPrompts(rawLog: string): string {
return scrubHeaderContent(rawLog, false)
return scrubHeaderContent(rawLog, { system: true })
}
/**
* Replace tool schemas in full request-header snapshots with `{{tools}}`
* tokens while retaining field presence. System prompts and session-prefix
* messages stay verbatim so pinning fixtures can move only schema bulk into
* their dedicated JSON sidecar. Lines without a tool payload pass through
* byte-for-byte; the transform is idempotent.
*
* @param rawLog The raw session `.jsonl` content.
* @returns The JSONL with tool-schema content tokenized.
*/
export function scrubToolSchemas(rawLog: string): string {
return scrubHeaderContent(rawLog, { tools: true })
}
/**
@@ -138,11 +152,18 @@ export function scrubSystemPrompts(rawLog: string): string {
* @returns The JSONL with all header bulk tokenized, other lines byte-identical.
*/
export function scrubRequestHeaders(rawLog: string): string {
return scrubHeaderContent(rawLog, true)
return scrubHeaderContent(rawLog, { system: true, tools: true, prefix: true })
}
/** Transform header content, optionally including tool schemas and the session prefix. */
function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): string {
/** Which independent request-header payloads a scrubber replaces. */
interface HeaderScrubOptions {
system?: boolean
tools?: boolean
prefix?: boolean
}
/** Transform the selected request-header payloads. */
function scrubHeaderContent(rawLog: string, options: HeaderScrubOptions): string {
const lines = rawLog.split('\n')
const out = lines.map((line) => {
if (line.trim().length === 0) return line
@@ -153,9 +174,9 @@ function scrubHeaderContent(rawLog: string, scrubToolsAndPrefix: boolean): strin
const header = data.header as Record<string, unknown> | null | undefined
if (header === null || typeof header !== 'object') return line
let touched = false
if ('system' in header) { header.system = SYSTEM; touched = true }
if (scrubToolsAndPrefix && 'tools' in header) { header.tools = TOOLS; touched = true }
if (scrubToolsAndPrefix && Array.isArray(header.messagePrefix)) {
if (options.system === true && 'system' in header) { header.system = SYSTEM; touched = true }
if (options.tools === true && 'tools' in header) { header.tools = TOOLS; touched = true }
if (options.prefix === true && Array.isArray(header.messagePrefix)) {
header.messagePrefix = header.messagePrefix.map(() => MESSAGE_PREFIX)
touched = true
}

View File

@@ -5,11 +5,10 @@
* model scenarios from the live API, while refresh mode replays committed
* scripts and rewrites derived artifacts without a key.
*
* Exactly one scenario per header-composition class pins tool schemas in JSONL
* and the system prompt in Markdown. Every live header is checked against that
* pin, so session-dependent composition must declare a separate class instead
* of escaping coverage.
*
* Exactly one scenario per header-composition class pins the full prompt and
* tool-schema sequences in dedicated sidecars. Every live header is checked
* against that pin, so session-dependent composition must declare a separate
* class instead of escaping coverage.
* @module @deepseek-ai/dsh-acp-snapshot/suite
*/
@@ -24,11 +23,18 @@ import {
normalizeStdout,
scrubRequestHeaders,
scrubSystemPrompts,
scrubToolSchemas,
} from './normalize.ts'
/** The readable system-prompt snapshot beside each header-pinning fixture. */
const SYSTEM_PROMPT_SNAPSHOT = 'system-prompt.golden.md'
/** The structured tool-schema snapshot beside each header-pinning fixture. */
const TOOL_SCHEMAS_SNAPSHOT = 'tool-schemas.golden.json'
/** Stable session-log token standing in for the sidecar's initial schemas. */
const TOOLS_TOKEN = '{{tools}}'
/** A snapshot scenario and how its fixtures are produced. */
export interface Scenario {
name: string
@@ -71,8 +77,8 @@ export interface Scenario {
*/
childSessions?: number
/**
* Whether this scenario is its header class's sole request-header pin. Its Markdown file owns
* the prompt, its JSONL keeps tool schemas, and every classmate is checked for equality.
* Whether this scenario is its header class's sole request-header pin. Dedicated sidecars own
* the prompt and tool schemas, while every classmate is checked for equality.
*/
pinsHeader?: boolean
/**
@@ -184,6 +190,77 @@ export function normalizedSystemPrompts(rawLog: string, ctx: NormalizeContext):
})
}
/**
* The normalized tool-schema arrays carried by request headers in a session
* JSONL, in log order. Headers without an array-valued tools field are omitted
* so callers can assert one schema set per header explicitly.
*
* @param rawLog The session `.jsonl` content to inspect.
* @param ctx The volatile values of the run that produced it.
* @returns The normalized initial tool-schema arrays, in header order.
*/
export function normalizedToolSchemas(rawLog: string, ctx: NormalizeContext): unknown[][] {
return normalizedHeaders(rawLog, ctx).flatMap((header) => {
if (header === null || typeof header !== 'object') return []
const tools = (header as { tools?: unknown }).tools
return Array.isArray(tools) ? [tools] : []
})
}
/** The structured contents of a tool-schema sidecar. */
export interface ToolSchemasSnapshot {
/** The complete tool schemas from the pinned request header. */
initial: unknown[]
/** Complete tool schemas from subsequent changed-header snapshots. */
changes: unknown[][]
}
/**
* Render the full tool-schema sequence as canonical, readable JSON.
*
* @param initial The pinned request header's complete tool schemas.
* @param changes Complete tool schemas from later changed headers.
* @returns A pretty-printed JSON snapshot ending in one newline.
*/
export function formatToolSchemasSnapshot(initial: readonly unknown[], changes: readonly unknown[][] = []): string {
return `${JSON.stringify({ initial, changes }, null, 2)}\n`
}
/**
* Parse and validate the stable top-level shape of a tool-schema sidecar.
*
* @param snapshot The JSON sidecar text.
* @returns Its initial and changed-header schema sets.
*/
export function parseToolSchemasSnapshot(snapshot: string): ToolSchemasSnapshot {
const parsed = JSON.parse(snapshot) as unknown
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('acp-snapshot: tool-schema snapshot must be an object')
}
const { initial, changes } = parsed as { initial?: unknown; changes?: unknown }
if (!Array.isArray(initial) || !Array.isArray(changes) || !changes.every(Array.isArray)) {
throw new Error('acp-snapshot: tool-schema snapshot must carry array-valued initial and changes fields')
}
return { initial, changes }
}
/**
* Restore one sidecar schema set into a tokenized pinned header.
*
* @param header The parsed request header carrying `tools: "{{tools}}"`.
* @param schemas The complete schemas for this full header snapshot.
* @returns A copy of the header with its complete schemas restored.
*/
export function restorePinnedToolSchemas(header: unknown, schemas: readonly unknown[]): unknown {
if (header === null || typeof header !== 'object' || Array.isArray(header)) {
throw new Error('acp-snapshot: pinned request header must be an object')
}
if ((header as { tools?: unknown }).tools !== TOOLS_TOKEN) {
throw new Error(`acp-snapshot: pinned request header tools must equal ${TOOLS_TOKEN}`)
}
return { ...header, tools: schemas }
}
/**
* Render a normalized prompt as a repository-friendly Markdown snapshot.
* Prompt text is unchanged except that a missing terminal newline is added so
@@ -241,6 +318,27 @@ function parseJsonlRecords(text: string): Record<string, unknown>[] {
.map(line => JSON.parse(line) as Record<string, unknown>)
}
/**
* Find tool calls whose structured result reports `UNKNOWN_TOOL`.
*
* Snapshot refresh must not turn a missing registration into accepted behavior;
* intentional unknown-tool behavior belongs in a focused unit or e2e test.
*
* @param rawLog The session JSONL to inspect.
* @returns The failing call ids in log order, using a diagnostic placeholder when absent.
*/
export function unknownToolCallIds(rawLog: string): string[] {
return parseJsonlRecords(rawLog).flatMap((record) => {
if (record.type !== 'tool/result') return []
const data = record.data
if (data === null || typeof data !== 'object') return []
const { callId, error } = data as { callId?: unknown; error?: unknown }
if (error === null || typeof error !== 'object') return []
if ((error as { code?: unknown }).code !== 'UNKNOWN_TOOL') return []
return [typeof callId === 'string' ? callId : '<missing callId>']
})
}
/**
* Build the cross-log id/cwd replacements used by refresh write-back.
*
@@ -368,6 +466,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {},
})
for (const log of result.sessionLogs) {
expect(unknownToolCallIds(log.content), `session ${log.id}: snapshot scenarios must not accept UNKNOWN_TOOL`)
.toEqual([])
}
// 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).
@@ -380,9 +483,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
// Record writes live model fixtures; keyless refresh writes every comparable replayed
// fixture. Pins keep tools but all JSONL files scrub prompt text.
// fixture. Pinning JSONL keeps prefixes but moves prompts and schemas into sidecars.
const scrub = scenario.pinsHeader === true
? scrubSystemPrompts
? (log: string): string => scrubToolSchemas(scrubSystemPrompts(log))
: scrubRequestHeaders
const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)]
const existingFixtures = REFRESHING
@@ -411,6 +514,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
expect(prompts.length, `${mode} produced no system prompt to snapshot`).toBeGreaterThan(0)
const snapshot = formatSystemPromptSnapshot(prompts[0] as string, prompts.slice(1))
await writeFile(join(dir, SYSTEM_PROMPT_SNAPSHOT), snapshot)
const schemaSets = normalizedToolSchemas(primary.content, ctx)
expect(schemaSets.length, `${mode} produced no tool schemas to snapshot`).toBeGreaterThan(0)
expect(schemaSets.length, `${mode} produced a tool-schema sequence that differs from its prompt sequence`)
.toBe(prompts.length)
await writeFile(join(dir, TOOL_SCHEMAS_SNAPSHOT), formatToolSchemasSnapshot(
schemaSets[0] as unknown[],
schemaSets.slice(1),
))
}
}
@@ -433,10 +545,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
}
// Header-uniformity guard: every live header in a class must equal the
// class pin split across its JSONL header (system token + real tools)
// and readable Markdown prompt. A pinning scenario may carry declared
// changed-header snapshots; each full prompt lives in that Markdown.
// Every live full header must equal its class pin reconstructed from
// tokenized JSONL plus readable prompt and structured schema sidecars.
/* v8 ignore next -- construction guarantees the pin exists; a miss would fail the one-header assertion loudly. */
const pinningScenario = pinningByClass.get(classOf(scenario)) ?? scenario
const pinningDir = join(snapshotsDir, pinningScenario.name)
@@ -446,6 +556,15 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
const initialPromptSnapshot = initialSystemPromptSnapshot(promptSnapshot)
expect(pinned.length, `the pinning fixture (${pinningScenario.name}) has an unexpected request/header count`)
.toBe(1 + (pinningScenario.expectedHeaderChanges ?? 0))
const toolSchemasSnapshot = await readFile(join(pinningDir, TOOL_SCHEMAS_SNAPSHOT), 'utf8')
const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot)
const pinnedSchemaSets = [toolSchemas.initial, ...toolSchemas.changes]
expect(pinnedSchemaSets.length, `the pinning fixture (${pinningScenario.name}) has an unexpected tool-schema count`)
.toBe(pinned.length)
const pinnedHeaders = pinned.map((header, index) => restorePinnedToolSchemas(
header,
pinnedSchemaSets[index] as unknown[],
))
for (const [logIndex, log] of result.sessionLogs.entries()) {
const expectedChanges = scenario.pinsHeader === true && logIndex === 0
? scenario.expectedHeaderChanges ?? 0
@@ -454,10 +573,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
.toBe(expectedChanges)
const headers = normalizedHeaders(scrubSystemPrompts(log.content), ctx)
const prompts = normalizedSystemPrompts(log.content, ctx)
const schemaSets = normalizedToolSchemas(log.content, ctx)
expect(prompts.length, `session ${log.id}: every request/header must carry a string system prompt`)
.toBe(headers.length)
expect(schemaSets.length, `session ${log.id}: every request/header must carry an array-valued tools field`)
.toBe(headers.length)
for (const [k, header] of headers.entries()) {
const expected = expectedChanges > 0 ? pinned[k] : pinned[0]
const expected = expectedChanges > 0 ? pinnedHeaders[k] : pinnedHeaders[0]
expect(header, `session ${log.id}: request/header #${k + 1} diverged from the pinned (${pinningScenario.name}) header`)
.toEqual(expected)
if (expectedChanges === 0) {
@@ -471,6 +593,11 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
prompts.slice(1),
), `session ${log.id}: changed system prompts diverged from ${pinningScenario.name}/${SYSTEM_PROMPT_SNAPSHOT}`)
.toEqual(promptSnapshot)
expect(formatToolSchemasSnapshot(
schemaSets[0] as unknown[],
schemaSets.slice(1),
), `session ${log.id}: changed tool schemas diverged from ${pinningScenario.name}/${TOOL_SCHEMAS_SNAPSHOT}`)
.toEqual(toolSchemasSnapshot)
}
}
})
@@ -499,6 +626,8 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
.toBe(overridden === true)
expect(existsSync(join(dir, SYSTEM_PROMPT_SNAPSHOT)), `${name}/${SYSTEM_PROMPT_SNAPSHOT} presence must match \`pinsHeader\``)
.toBe(pinsHeader === true)
expect(existsSync(join(dir, TOOL_SCHEMAS_SNAPSHOT)), `${name}/${TOOL_SCHEMAS_SNAPSHOT} presence must match \`pinsHeader\``)
.toBe(pinsHeader === 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)) {
@@ -522,29 +651,37 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
}
})
it('every pinning fixture carries one anchor, one readable prompt artifact, and its declared changes', async () => {
// The live uniformity guard runs only in NON-pinning scenarios, so a
// class made of just its pinning scenario would otherwise accept a
// re-recorded pin with undeclared mid-run header changes. Assert the
// committed pins directly; a scenario whose arc legitimately rewrites
// a prompt section declares the exact count via expectedHeaderChanges.
it('every pinning fixture carries one tokenized header sequence and two sidecars', async () => {
// Assert the committed pin directly because a class containing only its
// pinning scenario has no non-pinning live run to catch undeclared changes.
for (const scenario of pinningByClass.values()) {
const fixture = await readFile(join(snapshotsDir, scenario.name, 'session.jsonl'), 'utf8')
const headers = normalizedHeaders(fixture, fixtureContext(fixture))
const promptSnapshot = await readFile(join(snapshotsDir, scenario.name, SYSTEM_PROMPT_SNAPSHOT), 'utf8')
expect(headers.length, `${scenario.name}: unexpected request/header count`)
.toBe(1 + (scenario.expectedHeaderChanges ?? 0))
const toolSchemasSnapshot = await readFile(join(snapshotsDir, scenario.name, TOOL_SCHEMAS_SNAPSHOT), 'utf8')
const toolSchemas = parseToolSchemasSnapshot(toolSchemasSnapshot)
const schemaSets = [toolSchemas.initial, ...toolSchemas.changes]
expect(schemaSets.length, `${scenario.name}: tool-schema sequence must match the header sequence`)
.toBe(headers.length)
for (const [index, header] of headers.entries()) {
expect(() => restorePinnedToolSchemas(header, schemaSets[index] as unknown[]), `${scenario.name}: tools must use the sidecar token`)
.not.toThrow()
}
expect(promptSnapshot.length, `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must not be empty`).toBeGreaterThan(0)
expect(promptSnapshot.endsWith('\n'), `${scenario.name}/${SYSTEM_PROMPT_SNAPSHOT} must end in a newline`).toBe(true)
expect(toolSchemasSnapshot, `${scenario.name}/${TOOL_SCHEMAS_SNAPSHOT} must use canonical JSON formatting`)
.toBe(formatToolSchemasSnapshot(toolSchemas.initial, toolSchemas.changes))
expect(headerChangeCount(fixture), `${scenario.name}: a pinning fixture must carry exactly its declared changed headers`)
.toBe(scenario.expectedHeaderChanges ?? 0)
}
})
it('every committed JSONL omits system prompts and only pinning fixtures keep other header bulk', async () => {
// System prompts always live in the readable Markdown artifact. Header
// pins keep tool schemas/prefixes in JSONL; every other fixture tokenizes
// all header bulk. Fixed-point checks make both storage rules fail loud.
it('every committed JSONL has valid tool results and canonical header storage', async () => {
// Prompts and schemas always leave JSONL. Header pins retain prefixes;
// every other fixture tokenizes those too. Fixed-point checks make both
// storage rules fail loud.
for (const scenario of scenarios) {
const dir = join(snapshotsDir, scenario.name)
const files = [
@@ -553,12 +690,13 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void {
]
for (const file of files) {
const fixture = await readFile(join(dir, file), 'utf8')
expect(unknownToolCallIds(fixture), `${scenario.name}/${file} contains UNKNOWN_TOOL`)
.toEqual([])
expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`)
.toEqual(fixture)
if (scenario.pinsHeader === true) {
expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} must pin the non-system header content`)
.not.toEqual(fixture)
} else {
expect(scrubToolSchemas(fixture), `${scenario.name}/${file} carries unscrubbed tool schemas`)
.toEqual(fixture)
if (scenario.pinsHeader !== true) {
expect(scrubRequestHeaders(fixture), `${scenario.name}/${file} carries unscrubbed header content`)
.toEqual(fixture)
}

View File

@@ -1,2 +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":"{{system}}","tools":[{"name":"t1","description":"D1","parameters":{"type":"object"}}]},"reason":"initial"}}
{"type":"request/header","seq":0,"time":4,"data":{"header":{"config":{"model":"fake"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}

View File

@@ -0,0 +1,12 @@
{
"initial": [
{
"name": "t1",
"description": "D1",
"parameters": {
"type": "object"
}
}
],
"changes": []
}

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