From e481288a3a5fdf05f4bcc491dc120820975710d3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:04:11 +0800 Subject: [PATCH 001/163] refactor: derive snapshot session fixtures from disk --- .../2026-07-08-shared-acp-snapshot-package.md | 2 +- .../2026-06-20-discover-package-inventory.md | 5 +- examples/acp-agent/tests/acp.snapshot.ts | 10 +-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/suite.ts | 68 +++++++++++-------- .../support/acp-snapshot/tests/suite.spec.ts | 44 +++++++++--- 6 files changed, 85 insertions(+), 46 deletions(-) diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index c622a7f1be..eca3661b33 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -16,7 +16,7 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. -**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`childFixturePaths`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. +**`src/suite.ts`** — the `Scenario` type and `defineAcpSnapshotSuite(options)`, registering the per-scenario compares, record/refresh fixture write-back, the header pin with its live uniformity guard, and the fixture guard block (no orphan scenario dirs, required files present, exactly one pin per class, every JSONL a `scrubSystemPrompts` fixed point, non-pinning fixtures also `scrubRequestHeaders` fixed points). A scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are its ordered primary/child inventory, so the scenario table declares policy without duplicating a child count. The pinned-header contract ([pinned-header RFC](2026-07-06-pin-request-header-content-in-one-scenario.md)) is per-suite: each header class flags exactly one `pinsHeader` scenario, whose `system-prompt.golden.md` and JSONL tool list split the composed header into reviewable artifacts; the uniformity guard compares both against every live header in that class. A pinning scenario declares any legitimate changed-header count, and its Markdown artifact records every full changed prompt. The pure helpers (`sessionFixtureNames`, `fixtureContext`, `normalizedHeaders`, `normalizedSystemPrompts`, `formatSystemPromptSnapshot`, `headerChangeCount`) are exported from the module for direct unit coverage. ## Alternatives considered diff --git a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md index 3587393efe..a091203da9 100644 --- a/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md +++ b/docs/rfc/proposed/process/2026-06-20-discover-package-inventory.md @@ -4,7 +4,7 @@ Status: proposed ## Problem -Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). The ACP snapshot suite's scenario table (`examples/acp-agent/tests/acp.snapshot.ts`) hand-maintains a `childSessions` count per scenario that duplicates the number of `session..jsonl` fixture siblings on disk. These lists are small today, but every new package or scenario class creates another manual synchronization point. +Package and gate inventories are repeated by hand. The [package cookbook](../../../cookbook/adding-a-package.md) tells authors to update several files. The [package README](../../../../packages/README.md) carries a hand-written dependency graph. [CI](../../../../.github/workflows/ci.yml) and [development docs](../../../development.md) can drift from the actual `doc-sync` subcommands when new gates are added. `tsconfig.build.json` and the root `tsconfig.json` each hand-list every package as explicit project `references` — two identical sets that grow in lockstep, so a single generator can emit both — and `tsconfig.base.json`'s paths map hand-lists the per-group glob fan-out. `knip.json` restates a per-package `entry` stanza for each package that gains an `*.e2e.ts` suite — byte-identical overrides that exist only because the shared `packages/*/*` stanza omits the e2e glob (an entry glob matching no files is inert, so the default stanza could carry it for every package). These lists are small today, but every new package creates another manual synchronization point. The [package hierarchy](../../implemented/architecture/2026-06-20-package-hierarchy.md) already removed several of these by hand: `scripts/publint-all.ts` now derives its list from the `packages//` layout, and the two `tsconfig` `paths` maps collapsed to one `@deepseek-ai/dsh-*` wildcard. What remains is the inventory that cannot be globbed away — chiefly `tsconfig.build.json`'s project `references`, which TypeScript requires as an explicit array (no wildcard form). @@ -16,7 +16,7 @@ Make the remaining package/gate inventories discoverable. A single canonical sou The hierarchy does not need to encode every fact about a package, but it should encode the broad maintenance policy: core/product packages, integrations, capability seams, and support/test/example packages should not all require a hand-maintained exception list before scripts can tell them apart. -Two of the cataloged items need no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright, and `childSessions` can be discovered from each scenario's fixture directory, leaving the scenario table to declare only policy (`recorded`, `hasModelTurn`, `comparesLog`) — and even those track fixture-derivable facts today (`comparesLog` ⟺ the committed log has entries beyond its header line; `recorded` ⟺ `hasModelTurn` with no `replay.override.json` sibling), so each new scenario class keeps adding knobs the fixture directory already answers. +One cataloged item needs no generator at all: folding the e2e entry glob into knip's default stanza deletes the per-package restatements outright. ## Acceptance criteria @@ -25,7 +25,6 @@ Two of the cataloged items need no generator at all: folding the e2e entry glob - Docs describe the source of truth rather than repeating generated inventories. - CI invokes the aggregate commands and lets those commands own their sub-gate lists. - `knip.json` carries a per-package override only where it encodes real information (an extra entry file, an ignored dependency), never a restatement of the default stanza. -- Snapshot scenarios declare policy, not facts discoverable from their fixture directories. ## Risks diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index eaa4e0381a..b1a43b83db 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -67,14 +67,14 @@ const SCENARIOS: Scenario[] = [ // tiers (gentle at 3, detailed at 5) as context/message in transcript and log. { name: 'repeat-tool-guard', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false, overridden: true }, - { name: 'subagent-spawn', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-multi', hasModelTurn: true, recorded: true, childSessions: 2 }, - { name: 'subagent-fork', hasModelTurn: true, recorded: true, childSessions: 1 }, - { name: 'subagent-mixed', hasModelTurn: true, recorded: true, childSessions: 2 }, + { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + { name: 'subagent-fork', hasModelTurn: true, recorded: true }, + { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // The workflow tool: the model writes a one-child orchestration script; the // child runs as a spawn subagent under the worker-thread engine (its session is the // child fixture), and the tool result carries the script's return value. - { name: 'workflow-run', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'workflow-run', hasModelTurn: true, recorded: true }, // Hook matrix — one scenario per hook point × its headline Decision outcome, // across BOTH bridges (Claude `hooks.json`, Codex `codex-hooks.json`, seeded in // workspace/). The block scenarios need no model call: a UserPromptSubmit hook diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 13dd1ab247..ac9c0ceee3 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -6,7 +6,7 @@ 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. +- **`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). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. A consuming `*.snapshot.ts` is the scenario table plus one factory call: diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 523092bf09..6311a15031 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -80,14 +80,6 @@ export interface Scenario { * false (replay derives from the fixture's `assistant/chunk` events). */ overridden?: boolean - /** - * How many SUBAGENT child sessions this scenario records beyond the top-level - * one (0 for a single-session scenario). Each child rides in a sibling fixture - * `session..jsonl` (1-based); replay forwards them to `dsh-llm-replay` so - * each child session replays from its own script, and record mode writes the - * harvested child logs back to those files. Defaults to 0. - */ - childSessions?: number /** * Whether THIS scenario pins its header class's model-facing request-header * content. Its actual composed prompt is maintained as a readable @@ -150,14 +142,40 @@ export interface SnapshotSuiteOptions { } /** - * The sibling child-fixture paths for a scenario (`session.1.jsonl` …). + * Validate and order a scenario directory's session-fixture filenames. * - * @param dir The scenario's snapshots directory (`/`). - * @param childSessions How many subagent child sessions the scenario records. - * @returns One path per child, 1-based, in fixture order. + * The primary fixture is always `session.jsonl`; child sessions are discovered + * from contiguous `session.1.jsonl` … filenames. The directory is the source of + * truth, so scenario tables do not duplicate a child count that can drift from + * the files. A session-like JSONL with any other suffix fails loud. + * + * @param names File names in one scenario directory. + * @returns The primary and child fixture names in replay/harvest order. */ -export function childFixturePaths(dir: string, childSessions: number): string[] { - return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`)) +export function sessionFixtureNames(names: readonly string[]): string[] { + if (!names.includes('session.jsonl')) throw new Error('missing session.jsonl') + const children: { name: string; index: number }[] = [] + for (const name of names) { + if (name === 'session.jsonl') continue + if (!name.startsWith('session.') || !name.endsWith('.jsonl')) continue + const match = /^session\.([1-9]\d*)\.jsonl$/.exec(name) + if (match === null) throw new Error(`invalid child session fixture name: ${name}`) + children.push({ name, index: Number(match[1]) }) + } + children.sort((a, b) => a.index - b.index) + for (const [offset, child] of children.entries()) { + const expected = offset + 1 + if (child.index !== expected) { + throw new Error(`child session fixtures must be contiguous: expected session.${expected}.jsonl, found ${child.name}`) + } + } + return ['session.jsonl', ...children.map(child => child.name)] +} + +/** Read one scenario directory's validated session-fixture inventory. */ +async function sessionFixtures(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + return sessionFixtureNames(entries.filter(entry => entry.isFile()).map(entry => entry.name)) } /** @@ -392,7 +410,9 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') - const childSessions = scenario.childSessions ?? 0 + const fixtureFiles = await sessionFixtures(dir) + const childFixtureFiles = fixtureFiles.slice(1) + const childSessions = childFixtureFiles.length const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn const result = await runScenario(input, { agent, @@ -401,7 +421,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(overrideFile) ? { overrideFile } : {}, // In REPLAY, forward the recorded child fixtures so each subagent session // replays from its own script. In RECORD they are harvested, not read. - ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, + ...!RECORDING && childSessions > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. @@ -432,7 +452,6 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const scrub = scenario.pinsHeader === true ? scrubSystemPrompts : scrubRequestHeaders - const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] const existingFixtures = REFRESHING ? await Promise.all(fixtureFiles.map(file => readFile(join(dir, file), 'utf8'))) : [] @@ -543,7 +562,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { expect(onDisk).toEqual(registered) }) - it('every registered scenario has its required fixture files', () => { + it('every registered scenario has its required fixture files', async () => { // Every scenario has an input script and an stdout golden. EVERY scenario // also needs `session.jsonl`: the suite boots `llm-replay` with that path // as the replay source for ALL scenarios (the factory passes @@ -556,7 +575,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // `overridden` flag: required when set, forbidden when not — the harness // forwards the file purely on existence, so an unregistered stray sidecar // would silently replace the derived script. - for (const { name, overridden, childSessions, pinsHeader } of scenarios) { + for (const { name, overridden, pinsHeader } of scenarios) { const dir = join(snapshotsDir, name) expect(existsSync(join(dir, 'input.json')), `${name}/input.json`).toBe(true) expect(existsSync(join(dir, 'stdout.golden.jsonl')), `${name}/stdout.golden.jsonl`).toBe(true) @@ -565,11 +584,7 @@ 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) - // A nested-agent scenario ships one child fixture per recorded subagent - // session (`session.1.jsonl` …), the replay source for that child session. - for (const childFixture of childFixturePaths(dir, childSessions ?? 0)) { - expect(existsSync(childFixture), childFixture).toBe(true) - } + await expect(sessionFixtures(dir), `${name}: session fixture inventory`).resolves.toBeDefined() } }) @@ -615,10 +630,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // all header bulk. Fixed-point checks make both storage rules fail loud. for (const scenario of scenarios) { const dir = join(snapshotsDir, scenario.name) - const files = [ - 'session.jsonl', - ...Array.from({ length: scenario.childSessions ?? 0 }, (_, i) => `session.${i + 1}.jsonl`), - ] + const files = await sessionFixtures(dir) for (const file of files) { const fixture = await readFile(join(dir, file), 'utf8') expect(scrubSystemPrompts(fixture), `${scenario.name}/${file} carries an unscrubbed system prompt`) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 4ca9c8573d..f27f36c564 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -6,13 +6,13 @@ import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' import { defineAcpSnapshotSuite, type HarvestedLog, type Scenario } from '../src/index.ts' import { - childFixturePaths, fixtureContext, formatSystemPromptSnapshot, headerChangeCount, normalizedHeaders, normalizedSystemPrompts, refreshFixtureReplacements, + sessionFixtureNames, stabilizeRefreshLog, } from '../src/suite.ts' @@ -51,7 +51,7 @@ const RECORD_SRC = fileURLToPath(new URL('./fixtures/record-suite', import.meta. // example's code-mode scenarios). const REPLAY_SCENARIOS: Scenario[] = [ { name: 'pin-turn', hasModelTurn: true, recorded: true, pinsHeader: true, expectedHeaderChanges: 1, headerClass: 'main' }, - { name: 'plain-turn', hasModelTurn: true, recorded: true, childSessions: 1, headerClass: 'main', configPath: AGENT.configPath }, + { name: 'plain-turn', hasModelTurn: true, recorded: true, headerClass: 'main', configPath: AGENT.configPath }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, { name: 'authored-error', hasModelTurn: true, recorded: false, overridden: true, headerClass: 'main' }, @@ -59,7 +59,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ const RECORD_SCENARIOS: Scenario[] = [ { name: 'rec-pin', hasModelTurn: true, recorded: true, pinsHeader: true }, - { name: 'rec-child', hasModelTurn: true, recorded: true, childSessions: 1 }, + { name: 'rec-child', hasModelTurn: true, recorded: true }, // recorded:false in record mode → registered but skipped (never re-recorded). { name: 'rec-skip', hasModelTurn: true, recorded: false, overridden: true }, ] @@ -180,13 +180,41 @@ describe('defineAcpSnapshotSuite: registration contract', () => { }) }) -describe('childFixturePaths', () => { - it('yields one sibling path per child, 1-based', () => { - expect(childFixturePaths('/snap/s', 2)).toEqual(['/snap/s/session.1.jsonl', '/snap/s/session.2.jsonl']) +describe('sessionFixtureNames', () => { + it('orders the primary and contiguous child fixtures while ignoring other files', () => { + expect(sessionFixtureNames([ + 'stdout.golden.jsonl', + 'session.2.jsonl', + 'session.jsonl', + 'session.1.jsonl', + 'input.json', + ])).toEqual(['session.jsonl', 'session.1.jsonl', 'session.2.jsonl']) }) - it('yields nothing for a single-session scenario', () => { - expect(childFixturePaths('/snap/s', 0)).toEqual([]) + it('accepts a primary-only scenario', () => { + expect(sessionFixtureNames(['session.jsonl'])).toEqual(['session.jsonl']) + }) + + it('rejects a directory without the primary fixture', () => { + expect(() => sessionFixtureNames(['session.1.jsonl'])).toThrow('missing session.jsonl') + }) + + it('rejects gapped child fixtures', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.2.jsonl'])) + .toThrow('expected session.1.jsonl, found session.2.jsonl') + }) + + it.each(['session.0.jsonl', 'session.child.jsonl', 'session.01.jsonl'])( + 'rejects invalid child fixture name %s', + (name) => { + expect(() => sessionFixtureNames(['session.jsonl', name])) + .toThrow(`invalid child session fixture name: ${name}`) + }, + ) + + it('rejects duplicate child indexes', () => { + expect(() => sessionFixtureNames(['session.jsonl', 'session.1.jsonl', 'session.1.jsonl'])) + .toThrow('expected session.2.jsonl, found session.1.jsonl') }) }) From 0e7d539bbc5b75ad224b312a11cc1e15e1ba527a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:18:00 +0800 Subject: [PATCH 002/163] refactor: share the ACP test launcher --- .../2026-07-08-shared-acp-snapshot-package.md | 10 +- examples/acp-agent/tests/acp.e2e.ts | 182 +++--------------- examples/acp-agent/tests/hooks.e2e.ts | 68 ++----- .../sandbox-acp-agent/tests/escalation.e2e.ts | 79 +++----- packages/support/README.md | 4 +- packages/support/acp-snapshot/README.md | 5 +- packages/support/acp-snapshot/package.json | 2 +- packages/support/acp-snapshot/src/harness.ts | 130 ++----------- packages/support/acp-snapshot/src/index.ts | 24 ++- packages/support/acp-snapshot/src/launcher.ts | 152 +++++++++++++++ .../acp-snapshot/tests/harness.spec.ts | 33 ++++ 11 files changed, 292 insertions(+), 397 deletions(-) create mode 100644 packages/support/acp-snapshot/src/launcher.ts diff --git a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md index eca3661b33..59734535b8 100644 --- a/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md +++ b/docs/rfc/implemented/testing/2026-07-08-shared-acp-snapshot-package.md @@ -6,13 +6,15 @@ Status: implemented The ACP snapshot tier ([snapshot RFC](2026-06-19-acp-snapshot-tests.md)) was built from three modules living inside one example's test directory: `snapshot-harness.ts` (boot the real bin subprocess, drive it over ACP JSON-RPC, harvest the persisted logs), `snapshot-normalize.ts` (the pure golden normalizers), and the ~150-line scenario body plus fixture guards in `acp.snapshot.ts` (record/replay modes, the stdout-golden and log compares, the pinned-header uniformity guard, the orphan/required-file/single-pin meta-tests). -A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was already triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness (`TODO(acp-test-harness)`). Location also decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all. +A second ACP example wanting snapshot coverage — the sandbox/approval composition is the immediate consumer — could only copy those modules, forking exactly the logic that must not drift: record write-back, header scrubbing, child-session harvest ordering. The spawn/client glue was also triplicated across `acp.e2e.ts`, `hooks.e2e.ts`, and the harness. Location decided test rigor: the per-file 100% coverage gate measures `packages/*/*/src` only, so none of this machinery was measured — the same gap that had moved `dsh-llm-replay` out of `examples/` into [packages/support](../../../../packages/support/README.md). And the harness's ACP client hardcoded `requestPermission → cancelled`, so an approval round-trip — the headline behavior of the sandbox composition — could not be expressed at the snapshot tier at all. ## Decision The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/support/acp-snapshot/README.md) (`@deepseek-ai/dsh-acp-snapshot`); an example's `*.snapshot.ts` is its scenario table, its agent paths, and one factory call, over its own `snapshots/` fixtures and `cordis.snapshot.yml` overlay ([single-source replay config](2026-07-04-single-source-acp-replay-config.md)). Reading `DSH_SNAPSHOT` stays at that edge — the library takes a resolved `mode`. -**`src/harness.ts`** — `runScenario` and the input-script/result types, parameterized by an `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`; absolute paths the consuming suite resolves from its own `import.meta.url`). The client's `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`. +**`src/launcher.ts`** — `launchAcpTestAgent` owns the common unbuilt-process boundary: absolute tsx loader resolution, `TSX_TSCONFIG_PATH`, isolated harness homes, stdio wiring, a raw-byte stdout tee, stderr and update capture, fail-closed permission fallback, update waiters, and graceful or signalled shutdown. Snapshot scenarios and ordinary e2e suites supply the same `AgentUnderTest` (`binScript`, `configPath`, `tsconfigPath`); a test that plays a user supplies only its permission handler. The ACP and hook e2e suites plus the sandbox/approval e2e suite use this launcher instead of rebuilding the SDK client boundary. + +**`src/harness.ts`** — `runScenario` and the input-script/result types layer deterministic steps, temp workspaces, snapshot environment, and persisted-log harvest over the launcher. Its `session/request_permission` handler consumes an optional `InputScript.permissionAnswers` FIFO queue, each entry selecting by option **kind** (ids are agent-issued randoms a committed script cannot know; kinds are the ACP-stable vocabulary, mapped to the offered `optionId` at answer time); an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run — the agent itself is answered `cancelled`, so the scenario bug fails the harness rather than being absorbed as an agent-side denial. This is what lets an approval suite drive allow/reject round-trips deterministically from `input.json`. **`src/normalize.ts`** — the pure normalizers, hook-free by policy: when a future event carries a new volatile field (an approval duration, say), the shared normalizer learns it in the same change, keeping one home for what "normalized" means rather than per-suite scrub extensions. @@ -29,8 +31,8 @@ The machinery lives in [`packages/support/acp-snapshot`](../../../../packages/su ## Testing -Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the REAL spawn path by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` covers every step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), env forwarding, workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. Two structurally unreachable guards carry reasoned `v8 ignore` comments. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). +Extraction parity was proven mechanically: after the move, `pnpm run test:snapshot` matched the base commit's result with zero byte changes under `examples/acp-agent/tests/snapshots/`. The package's `src/` holds per-file 100% statements/branches/functions/lines under the gating unit run, driven through the real launcher by a scripted fake ACP bin (`tests/fixtures/fake-acp-agent.ts`, behavior scripted per scenario via a `behavior.json` beside the fixture): `harness.spec.ts` directly covers launcher defaults, captures, update waiting, shutdown, and environment/config variants, then covers every scenario step op, both expect-error arms, the permission queue (selection, fallback, impossible-click), workspace seeding, and the harvest ordering/noise/fallback branches; `suite.spec.ts` runs the factory for real at collection time — a replay suite over committed synthetic fixtures and a record suite over a temp copy (write-back never touches the committed tree; `ACP_SNAPSHOT_SPEC_BOOTSTRAP=1` re-bootstraps it) — plus direct cases for the pure helpers. The fake bin substitutes the `session/new` cwd, not `process.cwd()`, into scripted logs, matching what the real bin's header carries (darwin realpaths `/var/folders/…` to `/private/var/folders/…`). ## Consequences -A new example gets the whole snapshot tier from a scenario table plus fixtures — the sandbox branch merges master down and adds its own suite (own pin scenario, own overlay, fixtures via `test:snapshot:record`, approvals via `permissionAnswers`). The costs: `suite.ts` imports vitest, so the package is importable only inside a vitest run — a shape no other package has, stated in its README; each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard); and the e2e launcher duplication remains (`TODO(acp-test-harness)`) — the harness is the extraction target when that migration lands. +A new example gets the whole snapshot tier from a scenario table plus fixtures, while an ordinary ACP e2e gets the same tested process/client boundary from one launcher call. The costs: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run — a shape no other package has, stated in its README; and each suite pins its own ~8 KB header fixture (a genuinely distinct composition deserves its own pin; an identical one would be caught by that suite's uniformity guard). diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 8ff012c2c3..4b6c675208 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,20 +1,14 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' import { mkdtemp, rm, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' /** * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over @@ -26,127 +20,18 @@ import { * WITHOUT a key, since it only needs the server to boot and answer initialize. */ -// The dsh-acp-agent bin (the demo:acp entry) and this example's cordis.yml. The -// bin resolves its config-path arg from CWD; the subprocess runs from a temp -// workdir, so pass the example config's ABSOLUTE path. -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -// Resolve tsx's loader to an ABSOLUTE path: the subprocess runs with cwd set to -// a temp workdir (this test launches there and uses it as the session cwd; the -// bridge no longer requires cwd === the launch dir, but a temp dir keeps the -// test hermetic), where a bare `--import tsx` would not resolve from -// node_modules. import.meta.resolve gives the worktree's tsx regardless of cwd. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// Absolute path to the repo-root tsconfig. Dev/test/demo run UNBUILT: the -// `@deepseek-ai/dsh-*` workspace imports resolve through the `paths` map in the -// root tsconfig (tsx reads it), NOT through built `lib/` output. But tsx finds -// that tsconfig by searching UP from the child's cwd — and the child's cwd is a -// temp workdir OUTSIDE the repo, so the search misses and the dsh-* imports fail -// (the child dies before writing a byte). Point tsx at the repo tsconfig -// explicitly via TSX_TSCONFIG_PATH so resolution is cwd-independent. (Without -// this the suite only passed by accident when a stale built `lib/` happened to -// exist — exactly the contamination that masked the inject bug this suite now -// guards.) The repo root is four levels up from this file (examples/acp-agent/tests). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } -// TODO(acp-test-harness): this subprocess/client boot glue is duplicated with -// hooks.e2e.ts and partly with dsh-acp-snapshot's harness. Migrate both e2e -// files onto that launcher before the TSX/env/permission-stub details drift. -function spawnAcpAgent(cwd: string, env: NodeJS.ProcessEnv = process.env): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, configPath], - { - cwd, - env: { - ...env, - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - // This example composes no ask-producing policy (no hooks), so the - // bridge never prompts here; answer cancelled (fail closed) if it ever - // does — an unexpected prompt must not grant anything. - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined -function hasStdoutLine(out: string[]): boolean { - return out.join('').split('\n').some(line => line.trim().length > 0) -} - -async function waitForStdoutLine(child: ChildProcessWithoutNullStreams, out: string[], stderr: string[], timeoutMs: number): Promise { - await new Promise((resolve, reject) => { - const cleanup = () => { - clearTimeout(timeout) - child.stdout.off('data', onData) - child.off('exit', onExit) - child.off('error', onError) - } - const pass = () => { - cleanup() - resolve() - } - const fail = (reason: string) => { - cleanup() - reject(new Error(`${reason}; stderr: ${stderr.join('')}`)) - } - const onData = () => { - if (hasStdoutLine(out)) pass() - } - const onExit = (code: number | null, signal: NodeJS.Signals | null) => { - fail(`ACP child exited before emitting a stdout frame (code ${code ?? 'null'}, signal ${signal ?? 'null'})`) - } - const onError = (error: Error) => { - fail(`ACP child failed before emitting a stdout frame: ${error.message}`) - } - const timeout = setTimeout(() => { - fail(`ACP child did not emit a stdout frame within ${timeoutMs}ms`) - }, timeoutMs) - - child.stdout.on('data', onData) - child.on('exit', onExit) - child.on('error', onError) - onData() - }) -} - afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } + await spawned?.close('SIGKILL') + spawned = undefined if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined }) @@ -154,39 +39,18 @@ afterEach(async () => { describe('acp-agent over real stdio (no key required)', () => { it('emits only framed JSON-RPC on stdout', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - // Collect raw stdout bytes directly (bypass the SDK framing) to inspect. + // Inspect the launcher's raw-byte tee in addition to driving its SDK client. // A dummy key lets the deepseek adapter APPLY (it only checks the key is // present at boot, not valid — the key is used only on a real model call, // which this purity test never triggers). So this runs WITHOUT real creds. - const child = spawn(process.execPath, ['--import', tsxLoader, binScript, configPath], { + spawned = launchAcpTestAgent({ + agent: AGENT, cwd: workdir, - env: { - ...process.env, - DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', - TSX_TSCONFIG_PATH: repoTsconfig, - DSH_HOME: join(workdir, '.dsh'), - DSH_AGENTS_HOME: join(workdir, '.agents'), - }, - stdio: ['pipe', 'pipe', 'pipe'], + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, }) - const out: string[] = [] - const stderr: string[] = [] - child.stdout.setEncoding('utf8') - child.stderr.setEncoding('utf8') - child.stdout.on('data', (c: string) => out.push(c)) - child.stderr.on('data', (c: string) => stderr.push(c)) + await spawned.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - // Send a single initialize request as a newline-delimited JSON-RPC frame. - const req = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} } }) - child.stdin.write(req + '\n') - - try { - await waitForStdoutLine(child, out, stderr, 15_000) - } finally { - child.kill('SIGKILL') - } - - const lines = out.join('').split('\n').filter(l => l.trim().length > 0) + const lines = spawned.rawStdout().split('\n').filter(line => line.trim().length > 0) expect(lines.length).toBeGreaterThan(0) for (const line of lines) { // Every stdout line MUST parse as JSON (a JSON-RPC frame). A non-JSON @@ -210,7 +74,11 @@ describe('acp-agent over real stdio (no key required)', () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) // A dummy key lets the deepseek adapter boot (it only checks presence, not // validity, at apply time); no model call is made, so the key is never used. - spawned = spawnAcpAgent(workdir, { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }) + spawned = launchAcpTestAgent({ + agent: AGENT, + cwd: workdir, + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + }) const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -223,7 +91,7 @@ describe('acp-agent over real stdio (no key required)', () => { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over ACP', () => { it('runs a real turn and the agent writes the requested file (verified on disk)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -264,7 +132,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: real prompt over it('with the terminal_output capability, a real bash call renders as a terminal card (content + _meta + exit)', async () => { workdir = await mkdtemp(join(tmpdir(), 'acp-e2e-')) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir }) const { client, updates } = spawned // Advertise the Zed `_meta.terminal_output` capability so the bridge emits diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index bdb800186a..a553addc96 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,20 +1,14 @@ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { - ClientSideConnection, - ndJsonStream, - PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, - type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, -} from '@agentclientprotocol/sdk' + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' /** * With-key e2e: the Claude Code hook bridge running against the REAL acp-agent @@ -34,54 +28,18 @@ import { * only a real model deciding to call bash exercises the PreToolUse seam live. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] - stderr: string[] +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), } -function spawnAcpAgent(cwd: string): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, configPath], - { cwd, env: { ...process.env, TSX_TSCONFIG_PATH: repoTsconfig }, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(_params: RequestPermissionRequest): Promise { - return Promise.resolve({ outcome: { outcome: 'cancelled' } }) - }, - }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, stderr } -} - -let spawned: Spawned | undefined +let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - if (spawned) { - spawned.child.kill('SIGKILL') - spawned = undefined - } + await spawned?.close('SIGKILL') + spawned = undefined if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined }) @@ -96,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook hooks: { PreToolUse: [{ hooks: [{ type: 'command', command: 'echo "bash blocked by policy" >&2; exit 2' }] }] }, })) - spawned = spawnAcpAgent(workdir) + spawned = launchAcpTestAgent({ agent: AGENT, cwd: workdir }) const { client, updates } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/sandbox-acp-agent/tests/escalation.e2e.ts index 93915717a1..a7e8787e36 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/sandbox-acp-agent/tests/escalation.e2e.ts @@ -1,20 +1,18 @@ -import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from 'node:child_process' -import { Readable, Writable } from 'node:stream' +import { spawnSync } from 'node:child_process' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' import { - ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, - type RequestPermissionResponse, - type SessionNotification, } from '@agentclientprotocol/sdk' +import { + launchAcpTestAgent, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from '@deepseek-ai/dsh-acp-snapshot' /** * examples/sandbox-acp-agent end to end. @@ -35,12 +33,11 @@ import { * escalation target the model picks can land the write. */ -const binScript = fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) -// The subprocess runs from a temp cwd OUTSIDE the repo; point tsx at the repo -// tsconfig so the unbuilt `paths` map resolves (see examples/AGENTS.md). -const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const AGENT: AgentUnderTest = { + binScript: fileURLToPath(new URL('../../../packages/ui/acp-agent/src/bin.ts', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + tsconfigPath: fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)), +} // A usable confining runner, probed the same way the executor suites do: // bwrap on Linux, Seatbelt's sandbox-exec on macOS. Without one the strict @@ -56,44 +53,19 @@ const hasSeatbelt = process.platform === 'darwin' && spawnSync('sandbox-exec', [ }).status === 0 const hasRunner = hasBwrap || hasSeatbelt -interface Spawned { - child: ChildProcessWithoutNullStreams - client: ClientSideConnection - updates: SessionNotification['update'][] +interface Spawned extends LaunchedAcpTestAgent { permissionRequests: RequestPermissionRequest[] - stderr: string[] } /** Boot the example as an ACP subprocess; the scripted client answers every permission prompt with `answer`. */ -function spawnSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { - const child = spawn( - process.execPath, - ['--import', tsxLoader, binScript, configPath], - { - cwd, - // A dummy key lets the deepseek adapter boot keyless (presence-checked at - // apply, used only on a real model call); the with-key tests carry the - // real key, so the fallback is inert there. - env: { ...process.env, DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot', TSX_TSCONFIG_PATH: repoTsconfig }, - stdio: ['pipe', 'pipe', 'pipe'], - }, - ) - const stderr: string[] = [] - child.stderr.setEncoding('utf8') - child.stderr.on('data', (chunk: string) => stderr.push(chunk)) - - const updates: SessionNotification['update'][] = [] +function launchSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once'): Spawned { const permissionRequests: RequestPermissionRequest[] = [] - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(child.stdout) as ReadableStream, - ) - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd, + // A dummy key lets the adapter boot keylessly; live tests carry the real key. + env: { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY ?? 'sk-dummy-for-boot' }, + requestPermission(params) { permissionRequests.push(params) const option = params.options.find(o => o.optionId === answer) // The scripted human: pick the requested option when the prompt offers @@ -102,15 +74,14 @@ function spawnSandboxAcpAgent(cwd: string, answer: 'allow-once' | 'reject-once') return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) - return { child, client, updates, permissionRequests, stderr } + return Object.assign(launched, { permissionRequests }) } let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - if (spawned !== undefined && spawned.child.exitCode === null) spawned.child.kill('SIGKILL') + await spawned?.close('SIGKILL') spawned = undefined if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) workdir = undefined @@ -119,7 +90,7 @@ afterEach(async () => { describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the tree — sandbox executor + approval service + bridge — and opens a session', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-smoke-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = launchSandboxAcpAgent(workdir, 'reject-once') const { client } = spawned // A dummy key boots the adapter; no prompt is ever sent, so no model call // and no sandbox runner probe happen. This drives the fiber tree the same @@ -132,7 +103,7 @@ describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () it('advertises both session config options and honors a switch end to end (no key, no model)', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-config-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = launchSandboxAcpAgent(workdir, 'reject-once') const { client } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) // This tree composes bash-sandbox (mode: read-only) + approval → both @@ -164,7 +135,7 @@ describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox-acp-agent e2e: the live approval loop', () => { it('denial → model escalation → editor prompt → allow-once → the retried write lands on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnSandboxAcpAgent(workdir, 'allow-once') + spawned = launchSandboxAcpAgent(workdir, 'allow-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -193,7 +164,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || !hasRunner)('sandbox-acp-agent it('a rejected escalation stays denied: no write lands, the turn still ends', async () => { workdir = await mkdtemp(join(tmpdir(), 'sandbox-acp-e2e-')) - spawned = spawnSandboxAcpAgent(workdir, 'reject-once') + spawned = launchSandboxAcpAgent(workdir, 'reject-once') const { client, permissionRequests } = spawned await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) diff --git a/packages/support/README.md b/packages/support/README.md index c8883a89ff..9a05ad3c9a 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -4,9 +4,9 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| -| `acp-snapshot/` | ACP snapshot suite kit: subprocess scenario harness + golden normalizers + the `defineAcpSnapshotSuite` factory | (library — imported by example `*.snapshot.ts` suites) | +| `acp-snapshot/` | ACP test kit: shared subprocess/client launcher + snapshot harness, normalizers, and suite factory | (library — imported by ACP e2e and `*.snapshot.ts` suites) | | `invariants/` | Dev-mode event-contract assertions | (listens on `session/*`, `agent/*`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `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` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, so e2e tests share one launcher and every snapshot suite is a scenario table over one 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. diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index ac9c0ceee3..4a43d0eef9 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -2,8 +2,9 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tier (`pnpm run test:snapshot`, [testing policy](../../../docs/testing.md)). An example gets a full snapshot suite from a scenario table plus a fixtures directory; every compare/guard mechanic lives here, under the per-file coverage gate, instead of being copied per example. -Three layers, importable separately: +Four layers, importable separately: +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`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). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. @@ -39,4 +40,4 @@ A scenario booting a differently-composed tree sets its own `configPath` (an ove The example also ships a `cordis.snapshot.yml` replay overlay next to its `cordis.yml` (the bin swaps them under `DSH_SNAPSHOT=replay` — [single-source replay config RFC](../../../docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md)); replay fixtures are served by [`dsh-llm-replay`](../llm-replay/README.md), which this package points at via the `DSH_SNAPSHOT_*` env vars it sets on the child. `pnpm run test:snapshot:record` calls the live LLM and rewrites the recorded scenarios' model fixtures; `pnpm run test:snapshot:refresh` stays keyless, runs the replay overlay, and rewrites stdout, comparable session-log goldens, and each pin's Markdown prompt snapshot from the committed model scripts. Fixture roles, record/replay/refresh semantics, and scenario-table fields are documented on `Scenario` and in the [snapshot RFC](../../../docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). -Constraints: `suite.ts` imports vitest, so the package is importable only inside a vitest run (the harness and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the harness speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). +Constraints: `suite.ts` imports vitest, so the package entry is importable only inside a vitest run (the launcher, harness, and normalizers have no such dependency but ship from the same entry). ACP-specific by design — the launcher speaks the SDK's `ClientSideConnection`. Permission round-trips are scriptable: `InputScript.permissionAnswers` is a FIFO queue of option-kind selections (`allow_once`, `reject_once`, …) the client maps to the agent-issued `optionId` at answer time; an absent or exhausted queue answers `cancelled`, and a kind the request never offered rejects the run (the agent is answered `cancelled`, so a tolerant agent cannot absorb the scenario bug). Session config options are scriptable too: the `setConfigOption` step switches a knob over `session/set_config_option`, and `setConfigOptionExpectError` asserts the bridge rejects an unknown id or out-of-vocabulary value (the error frame stays in the transcript). diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 363bc86e25..b14be09c5b 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", - "description": "ACP snapshot suite kit: real-subprocess scenario harness, golden normalizers, and the suite factory behind the keyless snapshot tier", + "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, golden normalizers, and suite factory", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 491d3ea884..93bb556ba6 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -16,54 +16,20 @@ * @module @deepseek-ai/dsh-acp-snapshot/harness */ -import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' import { cp, mkdtemp, readFile, readdir, rm } from 'node:fs/promises' import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, delimiter } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Readable, Writable } from 'node:stream' import { ClientSideConnection, - ndJsonStream, PROTOCOL_VERSION, - type Agent as AcpAgent, - type Client, type RequestPermissionRequest, type RequestPermissionResponse, type SessionNotification, } from '@agentclientprotocol/sdk' +import { launchAcpTestAgent, type AgentUnderTest, type LaunchedAcpTestAgent } from './launcher.ts' -// Resolve tsx's ESM loader to an ABSOLUTE path once: the child runs with its -// cwd in a temp dir OUTSIDE the repo, where a bare `--import tsx` would not -// resolve from node_modules. import.meta.resolve gives this package's tsx -// regardless of the child cwd. -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) - -/** - * The agent composition a scenario runs against: which bin to boot and which - * leaf config it loads. All paths are ABSOLUTE — the subprocess cwd is a temp - * dir outside the repo, so relative resolution would miss; a suite resolves - * them from its own `import.meta.url`. - */ -export interface AgentUnderTest { - /** The agent bin entry (e.g. `packages/ui/acp-agent/src/bin.ts`), run unbuilt via tsx. */ - binScript: string - /** - * The example's live `cordis.yml`. Under `DSH_SNAPSHOT=replay` the bin swaps - * it for the sibling `cordis.snapshot.yml` (the keyless replay overlay), so - * one path serves both modes. - */ - configPath: string - /** - * The repo-root tsconfig whose `paths` map resolves the unbuilt workspace - * imports. Passed to the child as `TSX_TSCONFIG_PATH`: tsx finds a tsconfig - * by searching UP from the child's cwd — a temp dir outside the repo — so - * without the explicit pin the dsh-* imports fail before the bin writes a - * byte. - */ - tsconfigPath: string -} +export type { AgentUnderTest } from './launcher.ts' /** * One step of a scenario's deterministic input script (`input.json`). The @@ -194,11 +160,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Everything past the temp-dir creation runs under a try/finally that always // removes both dirs — so a failure in workspace seeding, spawn, or any step // never leaks them (the "e2e tests own their resources" rule). - let child: ChildProcessWithoutNullStreams | undefined + let launched: LaunchedAcpTestAgent | undefined let sessionId: string | undefined let sessionLogs: HarvestedLog[] = [] - const rawBuffers: Buffer[] = [] - const stderrChunks: string[] = [] try { // Seed the workspace if the scenario ships one (a file the agent reads/edits). // Copied into the temp cwd so the agent's bash tools see it; the goldens @@ -207,51 +171,15 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise await cp(opts.workspaceDir, cwd, { recursive: true }) } const env: NodeJS.ProcessEnv = { - ...process.env, - TSX_TSCONFIG_PATH: opts.agent.tsconfigPath, DSH_SNAPSHOT: opts.mode, DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, - DSH_HOME: join(cwd, '.dsh'), - DSH_AGENTS_HOME: join(cwd, '.agents'), ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, ...opts.childFiles !== undefined && opts.childFiles.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } : {}, } - child = spawn( - process.execPath, - ['--import', tsxLoader, opts.agent.binScript, opts.configPath ?? opts.agent.configPath], - { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] }, - ) - - child.stderr.setEncoding('utf8') - child.stderr.on('data', (c: string) => stderrChunks.push(c)) - - // Tee raw stdout: accumulate the bytes for the golden + purity check, and ALSO - // feed the same bytes to the SDK client through a passthrough. Buffer the raw - // bytes (not per-chunk utf8 strings) and decode once at the end, so a - // multibyte sequence split across two 'data' events can't corrupt the golden. - const passthrough = new Readable({ read() {} }) - child.stdout.on('data', (buf: Buffer) => { - rawBuffers.push(buf) - passthrough.push(buf) - }) - child.stdout.on('end', () => passthrough.push(null)) - - const stream = ndJsonStream( - Writable.toWeb(child.stdin) as WritableStream, - Readable.toWeb(passthrough) as ReadableStream, - ) - // Watcher so a step can block until the client OBSERVES a particular - // session/update — used by promptAndCancel to pin frame order (send cancel - // only after the streamed agent_message_chunk has arrived, so those frames - // deterministically precede the cancelled prompt response). - const updateWaiters: { match: (u: SessionNotification['update']) => boolean; resolve: () => void }[] = [] - const waitForUpdate = (match: (u: SessionNotification['update']) => boolean): Promise => - new Promise(resolve => updateWaiters.push({ match, resolve })) - // Permission answers are consumed FIFO across the whole run; exhaustion // falls back to `cancelled` so approval-free scenarios keep the plain stub. const permissionQueue = [...input.permissionAnswers ?? []] @@ -263,22 +191,11 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // callback answers `cancelled` (a well-defined path for the agent), // captures the error here, and the step loop fails the run on it. let scriptError: Error | undefined - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { - for (let i = updateWaiters.length - 1; i >= 0; i--) { - const waiter = updateWaiters[i] - // The index is always in-bounds (i only decreases; splice removes at - // i, so lower entries stay valid); the guard satisfies - // noUncheckedIndexedAccess. - /* v8 ignore next 1 -- unreachable in-bounds guard, see above */ - if (waiter === undefined) continue - if (waiter.match(params.update)) { - updateWaiters.splice(i, 1) - waiter.resolve() - } - } - return Promise.resolve() - }, + launched = launchAcpTestAgent({ + agent: opts.agent, + cwd, + ...opts.configPath !== undefined ? { configPath: opts.configPath } : {}, + env, requestPermission(params: RequestPermissionRequest): Promise { const answer = permissionQueue.shift() if (answer === undefined) return Promise.resolve({ outcome: { outcome: 'cancelled' } }) @@ -296,10 +213,11 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise return Promise.resolve({ outcome: { outcome: 'selected', optionId: option.optionId } }) }, }) - const client = new ClientSideConnection(makeClient, stream) + const active = launched + const { client } = active for (const step of input.steps) { - await runStep(client, step, cwd, waitForUpdate, () => sessionId, (id) => { sessionId = id }) + await runStep(client, step, cwd, match => active.waitForUpdate(match), () => sessionId, (id) => { sessionId = id }) // A permission exchange happens while a step's request is in flight, so // by the time the step settles any script bug it exposed is captured — // fail the run HERE, as a harness error, rather than hoping the agent's @@ -308,26 +226,22 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise } // Done driving: close stdin so the server disposes gracefully (flushing // persistence) and exits. Then await exit so the harvested log is complete. - child.stdin.end() - await waitForExit(child) + await active.close() // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) } finally { // Failure-safe teardown: kill a still-running child and drop the temp dirs // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a - // process or dir. `child` is undefined only if spawn itself threw. - if (child !== undefined && child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL') - await waitForExit(child) - } + // process or dir. `launched` is undefined only if launch itself threw. + await launched?.close('SIGKILL') await rm(cwd, { recursive: true, force: true }) await rm(sessionsRoot, { recursive: true, force: true }) } return { - rawStdout: Buffer.concat(rawBuffers).toString('utf8'), - stderr: stderrChunks.join(''), + rawStdout: launched.rawStdout(), + stderr: launched.stderr(), cwd, ...sessionId !== undefined ? { sessionId } : {}, sessionLogs, @@ -339,7 +253,7 @@ async function runStep( client: ClientSideConnection, step: InputStep, cwd: string, - waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, + waitForUpdate: (match: (u: SessionNotification['update']) => boolean) => Promise, getSessionId: () => string | undefined, setSessionId: (id: string) => void, ): Promise { @@ -433,16 +347,6 @@ async function runStep( } } -/** Resolve once the child process exits (any code/signal). */ -function waitForExit(child: ChildProcessWithoutNullStreams): Promise { - // Race guard: both call sites run within one synchronous frame of - // stdin.end()/kill(), so the exit event cannot have been delivered yet; - // kept for any future caller that awaits in between. - /* v8 ignore next 1 -- unreachable race guard, see above */ - if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() - return new Promise(resolve => child.once('exit', () => { resolve() })) -} - /** * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each * header line, and return them ordered primary-first: the top-level session (no diff --git a/packages/support/acp-snapshot/src/index.ts b/packages/support/acp-snapshot/src/index.ts index 74bee95385..402bd3aa3d 100644 --- a/packages/support/acp-snapshot/src/index.ts +++ b/packages/support/acp-snapshot/src/index.ts @@ -1,13 +1,14 @@ /** * ACP snapshot suite kit — the shared machinery behind the keyless snapshot - * tier (`pnpm run test:snapshot`). Three layers, composable per example: - * the subprocess scenario harness ({@link runScenario}), the pure golden - * normalizers ({@link normalizeStdout} / {@link normalizeSessionLog} / - * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite factory - * ({@link defineAcpSnapshotSuite}) that registers a scenario table as a full - * describe/it tree. An example's `*.snapshot.ts` supplies only its - * {@link AgentUnderTest} paths, its snapshots directory, and its - * {@link Scenario} table. + * tier (`pnpm run test:snapshot`). Four layers, composable per example: the + * shared subprocess/client launcher ({@link launchAcpTestAgent}), the scripted + * scenario harness ({@link runScenario}), the pure golden normalizers + * ({@link normalizeStdout} / {@link normalizeSessionLog} / + * {@link scrubRequestHeaders} / {@link scrubSystemPrompts}), and the suite + * factory ({@link defineAcpSnapshotSuite}) that registers a scenario table as a + * full describe/it tree. Ordinary ACP e2e tests can use the launcher directly; + * an example's `*.snapshot.ts` supplies only its {@link AgentUnderTest} paths, + * snapshots directory, and {@link Scenario} table. * * NOTE: ./suite.ts imports vitest, so this package is importable only inside a * vitest run — a support-tier constraint stated in the README. @@ -17,7 +18,6 @@ export { runScenario, - type AgentUnderTest, type HarvestedLog, type InputScript, type InputStep, @@ -25,6 +25,12 @@ export { type RunOptions, type RunResult, } from './harness.ts' +export { + launchAcpTestAgent, + type AcpTestLaunchOptions, + type AgentUnderTest, + type LaunchedAcpTestAgent, +} from './launcher.ts' export { normalizeSessionLog, normalizeStdout, diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts new file mode 100644 index 0000000000..635a02ca35 --- /dev/null +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -0,0 +1,152 @@ +/** + * Shared launcher for ACP tests that drive an unbuilt agent subprocess over + * JSON-RPC stdio. It owns the tsx loader, workspace-resolution environment, + * stdout tee, SDK client, update collection, permission fallback, and process + * shutdown so e2e and snapshot suites do not each reconstruct that boundary. + * + * @module @deepseek-ai/dsh-acp-snapshot/launcher + */ + +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Readable, Writable } from 'node:stream' +import { + ClientSideConnection, + ndJsonStream, + type Agent as AcpAgent, + type Client, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, +} from '@agentclientprotocol/sdk' + +// The child runs from a temp directory outside the repo, where a bare +// `--import tsx` cannot resolve. Resolve this package's loader once instead. +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + +/** The unbuilt agent entry, leaf config, and workspace tsconfig an ACP test boots. */ +export interface AgentUnderTest { + /** The agent bin entry (for example `packages/ui/acp-agent/src/bin.ts`). */ + binScript: string + /** The leaf `cordis.yml` loaded by the bin. */ + configPath: string + /** The repo tsconfig whose paths resolve unbuilt workspace imports. */ + tsconfigPath: string +} + +/** Options for one ACP test subprocess. */ +export interface AcpTestLaunchOptions { + /** The agent composition to boot. */ + agent: AgentUnderTest + /** Process cwd and default session-home root. */ + cwd: string + /** Alternate leaf config for this launch. */ + configPath?: string + /** Extra environment values layered over the parent environment. */ + env?: NodeJS.ProcessEnv + /** Permission handler; omitted requests fail closed as `cancelled`. */ + requestPermission?: (params: RequestPermissionRequest) => Promise +} + +/** A running ACP test process and its captured client-side surfaces. */ +export interface LaunchedAcpTestAgent { + /** The child process, exposed for process-level assertions. */ + child: ChildProcessWithoutNullStreams + /** The SDK connection backed by the child's stdio. */ + client: ClientSideConnection + /** Session updates in receive order. */ + updates: SessionNotification['update'][] + /** Decode all stdout bytes captured so far. */ + rawStdout(): string + /** Decode all stderr chunks captured so far. */ + stderr(): string + /** Resolve when a future session update matches the predicate. */ + waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise + /** Gracefully close stdin, or send a signal, and wait for process exit. */ + close(signal?: NodeJS.Signals): Promise +} + +/** + * Boot an ACP agent subprocess and connect an SDK client to its stdio. + * + * @param options Agent paths, cwd, environment, and optional permission handler. + * @returns The running process, connected client, captures, and shutdown handle. + */ +export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTestAgent { + const { agent, cwd } = options + const child = spawn( + process.execPath, + ['--import', tsxLoader, agent.binScript, options.configPath ?? agent.configPath], + { + cwd, + env: { + ...process.env, + ...options.env, + TSX_TSCONFIG_PATH: agent.tsconfigPath, + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + stdio: ['pipe', 'pipe', 'pipe'], + }, + ) + + const stderrChunks: string[] = [] + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => stderrChunks.push(chunk)) + + const rawBuffers: Buffer[] = [] + const passthrough = new Readable({ read() {} }) + child.stdout.on('data', (buffer: Buffer) => { + rawBuffers.push(buffer) + passthrough.push(buffer) + }) + child.stdout.on('end', () => passthrough.push(null)) + + const updates: SessionNotification['update'][] = [] + const updateWaiters: { + match: (update: SessionNotification['update']) => boolean + resolve: (update: SessionNotification['update']) => void + }[] = [] + const stream = ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(passthrough) as ReadableStream, + ) + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + updates.push(params.update) + for (let index = updateWaiters.length - 1; index >= 0; index--) { + const waiter = updateWaiters[index] + /* v8 ignore next 1 -- index is bounded by the array length */ + if (waiter === undefined) continue + if (!waiter.match(params.update)) continue + updateWaiters.splice(index, 1) + waiter.resolve(params.update) + } + return Promise.resolve() + }, + requestPermission: options.requestPermission + ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' } })), + }) + const client = new ClientSideConnection(makeClient, stream) + + return { + child, + client, + updates, + rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), + stderr: () => stderrChunks.join(''), + waitForUpdate: match => new Promise(resolve => updateWaiters.push({ match, resolve })), + async close(signal?: NodeJS.Signals): Promise { + if (child.exitCode !== null || child.signalCode !== null) return + if (signal === undefined) child.stdin.end() + else child.kill(signal) + await waitForExit(child) + }, + } +} + +/** Resolve once a running child exits. */ +function waitForExit(child: ChildProcessWithoutNullStreams): Promise { + return new Promise(resolve => child.once('exit', () => { resolve() })) +} diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index b0817a8d04..ec1ed5cf6f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -3,7 +3,9 @@ import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, describe, expect, it } from 'vitest' +import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' +import { launchAcpTestAgent } from '../src/launcher.ts' /** * Unit tests for the subprocess harness, driven through the REAL spawn path @@ -38,6 +40,37 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) + const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) + tempDirs.push(sessionsRoot) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + configPath: AGENT.configPath, + env: { + DSH_SNAPSHOT: 'replay', + DSH_SNAPSHOT_FILE: fixtureFile, + DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk') + await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk') + expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) + expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') + expect(launched.stderr()).toContain('launcher stderr') + await launched.close() + await launched.close('SIGKILL') + + // The minimal shape needs no environment or config override. + const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await minimal.close() + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From b579a13db759c38c41541a021bbbac188f4bf8a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 00:28:20 +0800 Subject: [PATCH 003/163] test: trim PostToolUse snapshot retries --- .../2026-07-04-hook-snapshot-matrix.md | 4 +- examples/acp-agent/tests/acp.snapshot.ts | 3 - .../hook-cc-posttool-block/input.json | 2 +- .../hook-cc-posttool-block/session.jsonl | 694 +----------------- .../stdout.golden.jsonl | 434 +---------- .../hook-codex-posttool-block/input.json | 2 +- .../hook-codex-posttool-block/session.jsonl | 2 +- 7 files changed, 16 insertions(+), 1125 deletions(-) diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index 47db2cd793..1b3e70eb13 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -29,6 +29,8 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook- Date: Tue, 14 Jul 2026 00:46:46 +0800 Subject: [PATCH 004/163] fix: let snapshot recording create fixture inventory --- packages/support/acp-snapshot/src/suite.ts | 38 ++++++++++++++----- .../support/acp-snapshot/tests/suite.spec.ts | 17 ++++++++- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 6311a15031..4d87093c8a 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -31,7 +31,7 @@ * @module @deepseek-ai/dsh-acp-snapshot/suite */ -import { readFile, readdir, writeFile } from 'node:fs/promises' +import { readFile, readdir, rm, writeFile } from 'node:fs/promises' import { existsSync } from 'node:fs' import { join } from 'node:path' import { describe, expect, it } from 'vitest' @@ -410,9 +410,12 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') - const fixtureFiles = await sessionFixtures(dir) + // Replay/refresh need the committed inventory up front because those + // files drive the model scripts. Record mode creates that inventory + // from the harvested live logs, so it must also work for a brand-new + // scenario with no session.jsonl yet. + let fixtureFiles = RECORDING ? [] : await sessionFixtures(dir) const childFixtureFiles = fixtureFiles.slice(1) - const childSessions = childFixtureFiles.length const comparesLog = scenario.comparesLog ?? scenario.hasModelTurn const result = await runScenario(input, { agent, @@ -421,7 +424,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...existsSync(overrideFile) ? { overrideFile } : {}, // In REPLAY, forward the recorded child fixtures so each subagent session // replays from its own script. In RECORD they are harvested, not read. - ...!RECORDING && childSessions > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, + ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. @@ -460,18 +463,35 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { || (REFRESHING && comparesLog) if (writesSessionFixtures) { expect(result.sessionLogs.length, `${mode} produced no session log to harvest`).toBeGreaterThan(0) - expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`) - .toBe(childSessions + 1) + if (REFRESHING) { + expect(result.sessionLogs.length, `expected ${fixtureFiles.length} session logs (parent + children)`) + .toBe(fixtureFiles.length) + } + const outputFixtureFiles = [ + 'session.jsonl', + ...Array.from({ length: result.sessionLogs.length - 1 }, (_, i) => `session.${i + 1}.jsonl`), + ] const primary = (result.sessionLogs[0] as HarvestedLog).content - await writeFile(join(dir, 'session.jsonl'), scrub( + await writeFile(join(dir, outputFixtureFiles[0] as string), scrub( REFRESHING ? stabilizeRefreshLog(primary, existingFixtures[0] as string, replacements) : primary, )) for (let i = 1; i < result.sessionLogs.length; i++) { const child = (result.sessionLogs[i] as HarvestedLog).content - await writeFile(join(dir, `session.${i}.jsonl`), scrub( + await writeFile(join(dir, outputFixtureFiles[i] as string), scrub( REFRESHING ? stabilizeRefreshLog(child, existingFixtures[i] as string, replacements) : child, )) } + if (RECORDING) { + const outputNames = new Set(outputFixtureFiles) + const entries = await readdir(dir, { withFileTypes: true }) + await Promise.all(entries + .filter(entry => entry.isFile() + && entry.name.startsWith('session.') + && entry.name.endsWith('.jsonl') + && !outputNames.has(entry.name)) + .map(entry => rm(join(dir, entry.name)))) + fixtureFiles = outputFixtureFiles + } if (scenario.pinsHeader === true) { const primary = result.sessionLogs[0] as HarvestedLog const prompts = normalizedSystemPrompts(primary.content, ctx) @@ -498,7 +518,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { // prompt becomes the fixture's `{{system}}`; non-pinning scenarios // additionally tokenize tools/prefix. The dedicated header guard below // compares those omitted values against their class's pin artifacts. - expect(result.sessionLogs.length, 'this scenario must persist a session log').toBe(childSessions + 1) + expect(result.sessionLogs.length, 'this scenario must persist one log per session fixture').toBe(fixtureFiles.length) for (let i = 0; i < fixtureFiles.length; i++) { const harvested = scrub((result.sessionLogs[i] as HarvestedLog).content) const fixture = scrub(await readFile(join(dir, fixtureFiles[i] as string), 'utf8')) diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index f27f36c564..7aa218526c 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -69,7 +69,13 @@ const RECORD_SCENARIOS: Scenario[] = [ // committed record fixtures/goldens in place. const BOOTSTRAP = process.env.ACP_SNAPSHOT_SPEC_BOOTSTRAP === '1' const recordDir = BOOTSTRAP ? RECORD_SRC : mkdtempSync(join(tmpdir(), 'acp-snap-record-suite-')) -if (!BOOTSTRAP) cpSync(RECORD_SRC, recordDir, { recursive: true }) +if (!BOOTSTRAP) { + cpSync(RECORD_SRC, recordDir, { recursive: true }) + // Record mode owns its output inventory: a new scenario has no primary yet, + // while a changed child count can leave old numbered fixtures behind. + rmSync(join(recordDir, 'rec-pin', 'session.jsonl')) + writeFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'stale child\n') +} const refreshDir = mkdtempSync(join(tmpdir(), 'acp-snap-refresh-suite-')) cpSync(REPLAY_DIR, refreshDir, { recursive: true }) staleRefreshFixtures(refreshDir) @@ -141,6 +147,13 @@ describe('defineAcpSnapshotSuite: refresh write-back', () => { }) }) +describe('defineAcpSnapshotSuite: record inventory write-back', () => { + it('creates a missing primary fixture and prunes stale child fixtures', () => { + expect(readFileSync(join(recordDir, 'rec-pin', 'session.jsonl'), 'utf8')).toContain('"type":"session"') + expect(() => readFileSync(join(recordDir, 'rec-child', 'session.2.jsonl'), 'utf8')).toThrow() + }) +}) + describe('defineAcpSnapshotSuite: registration contract', () => { it("throws when a scenario's header class has no pinning scenario", () => { expect(() => { From f95a411b0ac7d5f347c602308df1123dc1aecd5f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:09:07 +0800 Subject: [PATCH 005/163] fix: record self-limiting hook snapshot --- .../2026-07-04-hook-snapshot-matrix.md | 2 +- .../hook-cc-posttool-block/input.json | 2 +- .../hook-cc-posttool-block/session.jsonl | 253 ++++++++++++------ .../stdout.golden.jsonl | 83 +++++- .../workspace/hooks.json | 2 +- .../workspace/posttool-once.sh | 7 + 6 files changed, 259 insertions(+), 90 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index 1b3e70eb13..0327662d3d 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -29,7 +29,7 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook-&2; exit 2" } + { "type": "command", "command": "sh posttool-once.sh" } ] } ] diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh new file mode 100644 index 0000000000..2acc98bb58 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/workspace/posttool-once.sh @@ -0,0 +1,7 @@ +#!/bin/sh +if test -e .posttool-blocked; then + exit 0 +fi +: > .posttool-blocked +printf '%s\n' 'tool output rejected by policy: retry once' >&2 +exit 2 From d7de8a8d138b70d79e7066195ac11a512cbf4249 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:24:20 +0800 Subject: [PATCH 006/163] refactor: narrow compaction surface --- docs/cordis-catalog/services.md | 4 +- docs/core-data-structures/compaction.md | 14 +- .../2026-06-18-compaction-capability-seam.md | 4 +- packages/compact/compact-basic/README.md | 2 +- packages/compact/compact-basic/src/index.ts | 24 ++- .../compact-basic/tests/compact-basic.spec.ts | 147 +++++++----------- packages/compact/compact/README.md | 2 +- packages/compact/compact/src/index.ts | 11 +- packages/compact/compact/src/types.ts | 8 - .../compact/compact/tests/compact.spec.ts | 23 ++- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- 11 files changed, 95 insertions(+), 148 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ecb68913df..bfb31749d5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -113,12 +113,12 @@ Implementations MUST honor: ```ts cordis-catalog abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise -abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise +abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` Types: [Message](../core-data-structures/core.md) -Source: [`packages/compact/compact/src/index.ts:65`](../../packages/compact/compact/src/index.ts) +Source: [`packages/compact/compact/src/index.ts:66`](../../packages/compact/compact/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 2f402be57d..de350df9f3 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -1,6 +1,6 @@ # Compaction -The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). +The compaction seam — a [capability seam](../rfc/implemented/architecture/2026-06-13-capability-seams.md) split like bash: interface ([dsh-compact](../../packages/compact/compact), `ctx.compact`), implementation (a backend such as [dsh-compact-basic](../../packages/compact/compact-basic)), and consumer (a `/compact` tool, deferred). Compaction is **one optional capability**, not part of the agent-loop spine — so its vocabulary lives here, not in [core.md](core.md). A tokenizer- or template-based backend is a sibling package implementing the same interface. Unlike bash, the interface necessarily depends on `dsh-session` and `dsh-llm`: its verbs act on an agent-owned `Session`, and its durable summary event uses the `ContentBlock` vocabulary (see the [compaction capability-seam RFC](../rfc/implemented/feature/2026-06-18-compaction-capability-seam.md)). Source: [`packages/compact/compact/src/types.ts`](../../packages/compact/compact/src/types.ts) @@ -20,18 +20,10 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b ## `CompactionResult` -What a successful compaction returns to its caller: the seqs of the three appended `compact/*` events, the summary blocks, and the shadowed range/seqs plus the estimated token count. +What a successful compaction returns to its caller: the shadowed range and seqs plus the estimated token count. The durable `compact/summary` event owns the raw summary and bookkeeping-event identity. ```ts type-equiv interface CompactionResult { - /** The seq of the appended `compact/start` event. */ - startSeq: number - /** The seq of the appended `compact/summary` event. */ - summarySeq: number - /** The seq of the appended `compact/end` event. */ - endSeq: number - /** The summary content blocks produced by the backend. */ - summary: ContentBlock[] /** * The surface-boundary pair that was shadowed: the seqs of the first * (`start`) and last (`end`) surface nodes of the replaced range. A @@ -50,6 +42,6 @@ interface CompactionResult { ## The service -`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(session, start, end, agent, signal?)` forcibly summarizes surface nodes `[start, end]` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, the assembled `fullSystemPrompt`, the instance's composed `sessionPrefix` (request-only messages the derived history omits, so the pressure estimate must count them), and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. +`CompactService` (`ctx.compact`, abstract — defined in [`packages/compact/compact/src/index.ts`](../../packages/compact/compact/src/index.ts)) declares two abstract methods: `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` checks token pressure and compacts an older range if the history is too large (returning `null` when nothing needs it), and `compactRegion(start, end, agent, signal?)` forcibly summarizes surface nodes `[start, end]` from `agent.session` into a single replacement node. `compactIfNeeded`'s parameters are all required — the loop's `agent/pre-step` checkpoint supplies the agent, the assembled `fullSystemPrompt`, the instance's composed `sessionPrefix` (request-only messages the derived history omits, so the pressure estimate must count them), and the turn `signal`. A backend summarizing via `ctx.llm.stream()` must forward `signal` into the call's `GenerateOptions.signal`, so an abort or dispose tears down the in-flight summarization. The entire strategy — token estimation, retention policy, event sequencing, summarization — is a HOW decision owned by the implementation. Auto-compaction runs on the serial `agent/pre-step` loop seam (fired once per step, after `turn/start` and BEFORE the step opens and its request history is derived), not the `agent/request` waterfall: compaction mutates the session surface in place — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives the request from the already-compacted surface. Retention is turn-agnostic — the only structural guard is tool-pairing balance (a compacted region's edges are balanced cuts on the surface, so it never splits a step's tool-calls from their results), so a single runaway turn that alone exceeds the window compacts its own early closed steps rather than being retained verbatim. The backend that ships this (`dsh-compact-basic`) documents the retention walk, summary shrink validation, bounded re-compaction, and the crash/recoverable failure taxonomy. diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index e0835dcaa6..b99bdfc3b3 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -22,7 +22,7 @@ Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capabil ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation -The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs are defined *over* a `Session` (`compactRegion(session, start, end)`) and its output *is* the content vocabulary (`CompactionResult.summary: ContentBlock[]`). There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). +The capability-seams RFC states the interface package "depends only on cordis" (true of `dsh-bash`, whose vocabulary is self-contained). Compaction **cannot** honor that: its verbs act on an agent-owned `Session` (`compactRegion(start, end, agent)`) and the durable `compact/summary` event carries `ContentBlock[]`. There is no way to express the contract without naming `Session`/`SessionEvent` (from `dsh-session`) and `ContentBlock` (from `dsh-llm`). This is not a coupling smell — it is the contract's domain. The "only cordis" guidance was always shorthand for "the interface depends only on what the contract genuinely names, and never on an implementation." `dsh-session` and `dsh-llm` are themselves interface/vocabulary packages, not implementations; `dsh-compact` still imports no backend. The seam's real invariant — *consumers and implementations evolve independently behind an abstract service* — holds intact. @@ -30,7 +30,7 @@ This is not a coupling smell — it is the contract's domain. The "only cordis" An earlier draft put the full algorithm (the retention walk, token-summing, text extraction) as concrete methods on the interface, with only `estimateContentTokens()` and `summarize()` abstract. That recouples the contract to one strategy: a backend that wants a different retention policy or a different event-sequencing would have to fight inherited concrete code. Making both core methods abstract puts every *how* decision in the backend, where it belongs, and keeps the interface a pure statement of *what*. The backend remains internally factored — `estimateContentTokens()` and `summarize()` are `protected` hooks a sub-backend can override without reimplementing the walk — but that factoring is the backend's private concern, not the contract's. -`compactIfNeeded(agent, turn, step, fullSystemPrompt, signal)` takes **required** parameters (not the original all-optional shape). The auto-compaction seam (below) always supplies the agent, lifecycle context, assembled system prompt (counted toward the estimate), and the turn's abort signal, so optionality would only invite a hidden default at the seam. The session being compacted comes from the agent context. `compactRegion(session, start, end, agent, turn, step, signal?)` keeps an optional signal (a manual caller may omit it). Passing lifecycle context rather than a concrete model keeps router agents honest: the backend's summarization request can run through `agent/request`, where model-routing plugins already choose the actual model. +`compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` takes required inputs from the auto-compaction seam: the agent, assembled system prompt, composed request prefix, and turn abort signal. `compactRegion(start, end, agent, signal?)` uses `agent.session` as its single session identity and keeps an optional signal for manual callers. The backend's summarization request is a direct `ctx.llm.stream()` call; the configured summarization model falls back to the agent's model, and adapters can still route through the LLM seam. ### Auto-compaction runs on `agent/pre-step`, a dedicated surface-mutation seam diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index a06e74b818..c956b1e9de 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -17,7 +17,7 @@ The abstract contract states only WHAT compaction does; this backend owns every - **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface. - **Failure handling** — the `compact/start … compact/end` bracket is a log-recorded lock: it makes a crash mid-summarization a detectable orphan (a `compact/start` with no `compact/end`), records provenance, and prevents a concurrent compaction. Two failure paths: a **crash** (the loop dies mid-summarization) leaves a dangling `compact/start` that is inert — `compact/*` events are log-only, the surface replacement never landed, so the full history derives fine and generic turn-repair closes the turn; a **recoverable** failure (summarization throws but the loop survives) appends `compact/end` with its `error` field set, leaving the surface untouched so the call proceeds with full history. Core session repair stays compaction-agnostic by design — it never learns about `compact/*`. -`estimateContentTokens()` and `summarize()` are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the retention walk and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. +The protected `estimateContentTokens()` and `summarize()` methods are overridable hooks: a tokenizer-based or template-based backend can subclass `BasicCompactService` and override just those, reusing the private retention/pressure accounting and surface plumbing. `summarize()` returns the summary blocks together with the call envelope it actually used (`{ summary, model, maxTokens? }`) — the caller logs that envelope on the `compact/summary` provenance event, so an overriding backend reports its own envelope honestly. ## Config (`BasicCompactConfig`) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 0b0300472c..27e81cd3d9 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -41,12 +41,11 @@ import type { BasicCompactConfig, ResolvedConfig } from './types.ts' import { resolveConfig } from './types.ts' export type { BasicCompactConfig, ResolvedConfig } from './types.ts' -export { resolveConfig } from './types.ts' /** Per-block structural overhead for JSON framing / type tag. */ const BLOCK_OVERHEAD = 4 -/** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */ +/** Role-field framing overhead added per message in the request estimator. */ const ROLE_OVERHEAD = 4 /** Tags wrapping the structured summary inside the landed checkpoint node. */ @@ -222,7 +221,7 @@ export class BasicCompactService extends CompactService { * their JSON-stringified length. * @returns the estimated token count. */ - estimateContentTokens(blocks: readonly ContentBlock[]): number { + protected estimateContentTokens(blocks: readonly ContentBlock[]): number { const { charsPerToken } = this.config let tokens = 0 for (const block of blocks) { @@ -257,7 +256,8 @@ export class BasicCompactService extends CompactService { * @returns the estimated token count of the event's content, or 0 for a * non-message event. */ - estimateEventTokens(event: SessionEvent): number { + private estimateEventTokens(event: SessionEvent): number { + /* v8 ignore next -- callers traverse surface nodes, whose event types are the five cases below */ switch (event.type) { case 'user/message': case 'assistant/message': @@ -278,7 +278,7 @@ export class BasicCompactService extends CompactService { * @param systemPrompt - counted at chars / `charsPerToken` when provided. * @returns the estimated token footprint of the whole request. */ - estimateTokens(messages: readonly Message[], systemPrompt?: string): number { + private estimateTokens(messages: readonly Message[], systemPrompt?: string): number { let total = 0 for (const msg of messages) { total += this.estimateContentTokens(msg.content) @@ -318,7 +318,7 @@ export class BasicCompactService extends CompactService { * @returns the text-only summary blocks plus the call envelope used * (`model`, and `maxTokens` when the summarizer has a cap). */ - async summarize( + protected async summarize( text: string, agent: Agent, signal?: AbortSignal, ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { const assembler = new BlockAssembler() @@ -417,7 +417,7 @@ export class BasicCompactService extends CompactService { break } - result = await this.compactRegion(session, range.start, range.end, agent, signal) + result = await this.compactRegion(range.start, range.end, agent, signal) } const totalTokens = this.estimatePressure(session, fullSystemPrompt, sessionPrefix) @@ -439,17 +439,17 @@ export class BasicCompactService extends CompactService { * @param sessionPrefix - the instance's composed session prefix (counts toward pressure). * @returns the estimated token total the next request will carry. */ - estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number { + private estimatePressure(session: Session, fullSystemPrompt: string, sessionPrefix: readonly Message[]): number { return this.estimateTokens([...sessionPrefix, ...session.deriveMessages()], fullSystemPrompt) } override async compactRegion( - session: Session, start: number, end: number, agent: Agent, signal?: AbortSignal, ): Promise { + const session = agent.session // Resolve the range by surface POSITION, not numeric seq interval. A prior // replace lands a fresh high-seq summary node AT the shadowed range's // position, so the surface order (head→tail) no longer tracks seq order — @@ -556,13 +556,9 @@ export class BasicCompactService extends CompactService { // compact/start and here leaves a detectable orphaned lock (a compact/start // with no matching compact/end) rather than a compact/end that falsely // claims compaction finished before the surface replacement landed. - const endEvent = session.append('compact/end', { turn: openTurn }) + session.append('compact/end', { turn: openTurn }) return { - startSeq: startEvent.seq, - summarySeq: summaryEvent.seq, - endSeq: endEvent.seq, - summary, shadowedRange: { start, end }, shadowedSeqs, shadowedTokenCount, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1a30cc9fc9..46bcb9911c 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -5,7 +5,7 @@ import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import type { ContentBlock, GenerateOptions, Message, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' -import type { SessionEvent, SurfaceEvent } from '@deepseek-ai/dsh-session' +import type { SurfaceEvent } from '@deepseek-ai/dsh-session' import * as Invariants from '@deepseek-ai/dsh-invariants' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -68,6 +68,20 @@ class TestCompactService extends BasicCompactService { } } +/** Expose the backend's protected extension hooks for their focused contract tests. */ +class InspectableCompactService extends BasicCompactService { + estimateContent(blocks: readonly ContentBlock[]): number { + return this.estimateContentTokens(blocks) + } + + summarizeForTest( + text: string, + agent: Agent, + ): Promise<{ summary: ContentBlock[]; model: string; maxTokens?: number }> { + return this.summarize(text, agent) + } +} + function isFramedCheckpoint(blocks: readonly ContentBlock[]): boolean { const first = blocks[0] const last = blocks[blocks.length - 1] @@ -333,51 +347,6 @@ describe('BasicCompactService step-alignment (never split a tool-call/result pai }) }) -describe('BasicCompactService.estimateEventTokens', () => { - it('returns 0 for non-message events (boundary, chunk, step/end, tool/call)', () => { - const svc = createTestService() - expect(svc.estimateEventTokens({ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'assistant/chunk', seq: 2, time: 3, data: { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } })).toBe(0) - expect(svc.estimateEventTokens({ type: 'tool/call', seq: 4, time: 5, data: { turn: 1, step: 1, callId: CallId('c1'), name: 'read', arguments: '{}' } })).toBe(0) - }) - - it('returns estimate for message-producing events', () => { - const svc = createTestService() - const userEvent: SessionEvent = { type: 'user/message', seq: 0, time: 1, data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } } } - expect(svc.estimateEventTokens(userEvent)).toBe(10) - - const asstEvent: SessionEvent = { type: 'assistant/message', seq: 1, time: 2, data: { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }] } } - expect(svc.estimateEventTokens(asstEvent)).toBe(20) - - const toolEvent: SessionEvent = { type: 'tool/result', seq: 2, time: 3, data: { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'output' }], isError: false } } - expect(svc.estimateEventTokens(toolEvent)).toBe(10) - }) -}) - -describe('BasicCompactService.estimateTokens', () => { - it('sums token estimates across messages', () => { - const svc = createTestService() - const messages: Message[] = [ - { role: 'user', content: [{ type: 'text', text: 'hello' }] }, - { role: 'assistant', content: [{ type: 'text', text: 'hi' }, { type: 'text', text: 'there' }] }, - ] - // 1 block * 10 + 4 (role) + 2 blocks * 10 + 4 (role) = 10 + 4 + 20 + 4 = 38 - expect(svc.estimateTokens(messages)).toBe(38) - }) - - it('includes system prompt in the estimate', () => { - const svc = createTestService() - const messages: Message[] = [ - { role: 'user', content: [{ type: 'text', text: 'hi' }] }, - ] - const systemPrompt = 'You are a helpful assistant.' - // 1 block * 10 + 4 (role) + ceil(28/4) = 10 + 4 + 7 = 21 - expect(svc.estimateTokens(messages, systemPrompt)).toBe(21) - }) -}) - describe('BasicCompactService.compactRegion', () => { it('shadows surface nodes and inserts a summary via user/message', async () => { const svc = createTestService() @@ -393,7 +362,7 @@ describe('BasicCompactService.compactRegion', () => { expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) expect(result.shadowedRange.start).toBe(firstSeq) expect(result.shadowedRange.end).toBe(secondSeq) - expect(result.summary).toEqual(svc.mockSummary) + expect(result.shadowedTokenCount).toBe(20) const events = session.events const startEvent = events.findLast(e => e.type === 'compact/start') @@ -405,6 +374,7 @@ describe('BasicCompactService.compactRegion', () => { // The provenance record carries the summarize call's envelope, so "which // model wrote this summary" is answerable from the log alone. expect(summaryEvent?.type === 'compact/summary' && summaryEvent.data.model).toBe('test-model') + expect(summaryEvent?.type === 'compact/summary' && summaryEvent.data.summary).toEqual(svc.mockSummary) // compact/* events are log-only — no surfaceOp (type system enforces this). const startRaw = startEvent as unknown as { surfaceOp?: unknown } @@ -509,10 +479,9 @@ describe('BasicCompactService.compactRegion', () => { const session = multiTurnSession(3, 1) const nodes = session.surface.nodes - const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm') + await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'm') // Provenance (compact/summary) carries the RAW, unframed summary. - expect(result.summary).toEqual([{ type: 'text', text: 'STRUCTURED SUMMARY' }]) const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! expect(summaryEvent.data).toMatchObject({ summary: [{ type: 'text', text: 'STRUCTURED SUMMARY' }] }) @@ -590,7 +559,6 @@ describe('BasicCompactService.compactIfNeeded', () => { expect(result).not.toBeNull() expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(1) - expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(70) }) it('walks tail→head and retains nodes within token budget', async () => { @@ -716,7 +684,6 @@ describe('BasicCompactService.compactIfNeeded', () => { expect(result).not.toBeNull() expect(svc.summarizeCalls).toHaveLength(2) expect(session.events.filter(e => e.type === 'compact/summary')).toHaveLength(2) - expect(svc.estimateTokens(session.deriveMessages(), '')).toBeLessThan(50) }) it('throws after the configured re-compaction attempts still leave the surface above threshold', async () => { @@ -801,53 +768,51 @@ describe('BasicCompactService blocking (compaction in progress)', () => { describe('BasicCompactService token estimation (char/4 heuristic)', () => { it('estimates text blocks with char/4 + overhead', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) + const svc = new InspectableCompactService(new Context(), cfg({ auto: false })) // 'this is a somewhat longer text block' = 36 → ceil(36/4)+4 = 13; 'short' = 5 → 2+4 = 6 const blocks: ContentBlock[] = [ { type: 'text', text: 'this is a somewhat longer text block' }, { type: 'text', text: 'short' }, ] - expect(svc.estimateContentTokens(blocks)).toBe(19) + expect(svc.estimateContent(blocks)).toBe(19) }) it('estimates reasoning blocks same as text', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) + const svc = new InspectableCompactService(new Context(), cfg({ auto: false })) // 'thinking about this...' = 22 → ceil(22/4)+4 = 10 - expect(svc.estimateContentTokens([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) + expect(svc.estimateContent([{ type: 'reasoning', text: 'thinking about this...' }])).toBe(10) }) it('estimates tool-call blocks from name + arguments', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) + const svc = new InspectableCompactService(new Context(), cfg({ auto: false })) // 'bash' = 4 → 1; '{"command":"ls"}' = 16 → 4; + 4 overhead = 9 - expect(svc.estimateContentTokens([ + expect(svc.estimateContent([ { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' }, ])).toBe(9) }) it('estimates tool-result blocks recursively', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) + const svc = new InspectableCompactService(new Context(), cfg({ auto: false })) // inner text 5 → 2+4 = 6; outer 6 + 4 overhead = 10 - expect(svc.estimateContentTokens([ + expect(svc.estimateContent([ { type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'hello' }], isError: false }, ])).toBe(10) }) it('returns 0 for empty content blocks', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - expect(svc.estimateContentTokens([])).toBe(0) + const svc = new InspectableCompactService(new Context(), cfg({ auto: false })) + expect(svc.estimateContent([])).toBe(0) }) it('honors a configured charsPerToken (fractional densities included)', () => { // 'this is a somewhat longer text block' = 36 chars. const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }] // charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate. - const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 })) - expect(dense.estimateContentTokens(blocks)).toBe(22) + const dense = new InspectableCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 })) + expect(dense.estimateContent(blocks)).toBe(22) // Fractional density is legal: ceil(36/1.5)+4 = 28. - const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 })) - expect(fractional.estimateContentTokens(blocks)).toBe(28) - // The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18. - expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18) + const fractional = new InspectableCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 })) + expect(fractional.estimateContent(blocks)).toBe(28) }) }) @@ -1013,17 +978,17 @@ function compactRegion( model: string, signal?: AbortSignal, ) { - return svc.compactRegion(session, start, end, stubAgent(session, model), signal) + return svc.compactRegion(start, end, stubAgent(session, model), signal) } -function summarize(svc: BasicCompactService, text: string, model: string) { - return svc.summarize(text, stubAgent(new Session(SessionId('summary')), model)) +function summarize(svc: InspectableCompactService, text: string, model: string) { + return svc.summarizeForTest(text, stubAgent(new Session(SessionId('summary')), model)) } describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('summarizes via the registered adapter and returns its content', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, cfg({ auto: false, maxTokens: 512 })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false, maxTokens: 512 })) const { summary, model, maxTokens } = await summarize(svc, 'User: hi\n\nAssistant: hello', 'test-model') expect(summary).toEqual([{ type: 'text', text: 'SUMMARY TEXT' }]) @@ -1041,7 +1006,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('uses maxTokens as the summarization provider cap', async () => { const { ctx, adapter } = await ctxWithModel('SUMMARY TEXT') - const svc = new BasicCompactService(ctx, cfg({ + const svc = new InspectableCompactService(ctx, cfg({ auto: false, maxTokens: 50, })) @@ -1059,7 +1024,7 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { // synthesized user/message summary as an orphaned call. { type: 'tool-call', id: CallId('c1'), name: 'bash', arguments: '{}' }, ]) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) const { summary } = await summarize(svc, 'User: hi', 'test-model') @@ -1068,26 +1033,26 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('throws when no text block remains after filtering', async () => { const { ctx } = await ctxWithBlocks([{ type: 'reasoning', text: 'private only' }]) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'User: hi', 'test-model')).rejects.toThrow(/no text summary content/) }) it('throws when no model is provided', async () => { const { ctx } = await ctxWithModel('x') - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', '')).rejects.toThrow(/no model available/) }) it('rethrows when the stream ends with a finish-error chunk', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'provider 401', code: 'UNAUTHORIZED' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'provider 401', code: 'UNAUTHORIZED' }) }) it('rethrows a finish-error chunk without a code (code stays undefined)', async () => { const ctx = await ctxWithFinish({ kind: 'error', message: 'opaque failure' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) const error = await summarize(svc, 'text', 'test-model').then(() => null, (e: unknown) => e as Error & { code?: string }) expect(error?.message).toBe('opaque failure') expect(error?.code).toBeUndefined() @@ -1095,13 +1060,13 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { it('rethrows when the stream ends with a finish-aborted chunk', async () => { const ctx = await ctxWithFinish({ kind: 'aborted' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ message: 'summarization stream aborted', code: 'ABORTED' }) }) it('fails closed on a max-tokens finish (an incomplete checkpoint must not commit)', async () => { const ctx = await ctxWithFinish({ kind: 'max-tokens' }) - const svc = new BasicCompactService(ctx, cfg({ auto: false })) + const svc = new InspectableCompactService(ctx, cfg({ auto: false })) await expect(summarize(svc, 'text', 'test-model')).rejects.toMatchObject({ code: 'MAX_TOKENS' }) }) @@ -1129,8 +1094,9 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') - expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) + await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') + const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! + expect(summaryEvent.data.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // The raw summary is wrapped in the checkpoint framing on the surface. expect(session.deriveMessages()[0]!.content).toContainEqual({ type: 'text', text: 'CONDENSED' }) }) @@ -1392,10 +1358,10 @@ describe('BasicCompactService edge cases', () => { }) it('estimates unknown block types via JSON length (default branch)', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) + const svc = new InspectableCompactService(new Context(), cfg({ auto: false })) // A block whose type is none of the known kinds — exercises the default arm. const unknown = { type: 'custom-widget', payload: 'some data' } as unknown as ContentBlock - expect(svc.estimateContentTokens([unknown])).toBeGreaterThan(0) + expect(svc.estimateContent([unknown])).toBeGreaterThan(0) }) it('auto-compaction reports bounded retry exhaustion after committing a smaller summary', async () => { @@ -1604,14 +1570,15 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a // First compaction: shadow the two oldest surface nodes. const nodes0 = session.surface.nodes - const first = await compactRegion(svc, session, nodes0[0]!, nodes0[1]!, 'm') + await compactRegion(svc, session, nodes0[0]!, nodes0[1]!, 'm') + const firstSummarySeq = session.events.findLast(e => e.type === 'compact/summary')!.seq // The summary node now sits at the head with a seq HIGHER than the // retained older nodes that follow it — the non-monotonic surface. (The // head is the user/message replace node, appended after the compact/summary - // provenance event, so its seq is at least first.summarySeq.) + // provenance event. const nodes1 = session.surface.nodes - expect(nodes1[0]!).toBeGreaterThanOrEqual(first.summarySeq) + expect(nodes1[0]!).toBeGreaterThan(firstSummarySeq) expect(nodes1[0]!).toBeGreaterThan(nodes1[1]!) // Second compaction: shadow [summary(head) … turn-2's step end]. The start @@ -1623,13 +1590,14 @@ describe('BasicCompactService positional range (surface seqs are not monotonic a const endSeq = nodes1[2]! expect(startSeq).toBeGreaterThan(endSeq) const second = await compactRegion(svc, session, startSeq, endSeq, 'm') + const secondSummarySeq = session.events.findLast(e => e.type === 'compact/summary')!.seq // Exactly the three nodes at surface positions [0..2] are shadowed, in // surface order — the positional slice, regardless of their seq values. expect(second.shadowedSeqs).toEqual([nodes1[0]!, nodes1[1]!, nodes1[2]!]) // The surface still derives cleanly: a new head replace node + the rest. const finalNodes = session.surface.nodes - expect(finalNodes[0]!).toBeGreaterThanOrEqual(second.summarySeq) + expect(finalNodes[0]!).toBeGreaterThan(secondSummarySeq) expect(session.deriveMessages().length).toBe(finalNodes.length) }) @@ -1679,8 +1647,9 @@ describe('BasicCompactService llm inject (real plugin-load path)', () => { const svc = ctx.compact as BasicCompactService const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') - expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) + await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') + const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! + expect(summaryEvent.data.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // Tear the fiber down so this test owns no leaked registration; the // dedicated cleanup assertion lives in the "HMR safety" suite. diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index e98f00899f..fafcad76b8 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -19,7 +19,7 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | Member | Semantics | |---|---| | `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | -| `compactRegion(session, start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +| `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 7d1138314c..1eb73c60db 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -14,7 +14,8 @@ * implementation (deferred) / consumer (a `/compact` tool, deferred) — modeled * on the bash trio. Unlike `dsh-bash`, this interface necessarily * depends on `dsh-session` and `dsh-llm`: the contract's verbs are defined over - * a `Session` and its output is the `ContentBlock` vocabulary. That deviation + * an agent-owned `Session` and the durable summary event uses the + * `ContentBlock` vocabulary. That deviation * from the "interface depends only on cordis" guidance is intentional and * recorded in the [compaction capability-seam RFC](../../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). * @@ -132,10 +133,10 @@ export abstract class CompactService extends Service { * open (unclosed) tail step is invalid — its tool-calls have no results yet. * `dsh-session` exports `isToolPairingBalanced` for this check. * - * @param session - the session whose surface is mutated. * @param start - inclusive seq of the first surface node to compact. * @param end - inclusive seq of the last surface node to compact. - * @param agent - agent context used by router-aware summarizers. + * @param agent - agent context whose session is mutated and whose routing + * options are used by summarizers. * @param signal - optional cancellation signal. A backend that summarizes via * `ctx.llm.stream()` MUST forward this into the call's `GenerateOptions.signal` * so an abort/dispose tears down the in-flight summarization rather than @@ -146,10 +147,10 @@ export abstract class CompactService extends Service { * prior replace can leave the surface non-monotonic in seq order), or if * either boundary is not a balanced tool-pairing cut (would split a step's * tool-call/result pair). - * @returns what the compaction did (the replaced range and its summary node). + * @returns the replaced range and its token accounting. The durable + * `compact/summary` event owns the summary and bookkeeping-event identity. */ abstract compactRegion( - session: Session, start: number, end: number, agent: CompactAgentContext, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index ba9834910f..78e94173a3 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -49,14 +49,6 @@ declare module '@deepseek-ai/dsh-session' { /** Result of a successful compaction operation. */ export interface CompactionResult { - /** The seq of the appended `compact/start` event. */ - startSeq: number - /** The seq of the appended `compact/summary` event. */ - summarySeq: number - /** The seq of the appended `compact/end` event. */ - endSeq: number - /** The summary content blocks produced by the backend. */ - summary: ContentBlock[] /** * The surface-boundary pair that was shadowed: the seqs of the first * (`start`) and last (`end`) surface nodes of the replaced range. A diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index c4daa8cc5a..e34c8420d9 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -27,28 +27,24 @@ class StubCompactService extends CompactService { } override async compactRegion( - session: Session, start: number, end: number, - _agent: CompactAgentContext, + agent: CompactAgentContext, signal?: AbortSignal, ): Promise { this.lastSignal = signal + const session = agent.session // Minimal stub honoring the lock + log-only event contract. - const startEvent = session.append('compact/start', { turn: 0 }) - const summaryEvent = session.append('compact/summary', { + session.append('compact/start', { turn: 0 }) + session.append('compact/summary', { summary: [{ type: 'text', text: 'stub' }], shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, model: 'stub', }) - const endEvent = session.append('compact/end', { turn: 0 }) + session.append('compact/end', { turn: 0 }) return { - startSeq: startEvent.seq, - summarySeq: summaryEvent.seq, - endSeq: endEvent.seq, - summary: [{ type: 'text', text: 'stub' }], shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, @@ -88,7 +84,7 @@ describe('CompactService seam', () => { const svc = new StubCompactService(ctx) const session = new Session(SessionId('s')) - const result = await svc.compactRegion(session, 0, 0, stubAgent(session, 'm')) + const result = await svc.compactRegion(0, 0, stubAgent(session, 'm')) const startEvent = session.events.find(e => e.type === 'compact/start') expect(startEvent).toBeDefined() @@ -96,8 +92,9 @@ describe('CompactService seam', () => { // verify the runtime value is absent. const raw = startEvent as unknown as { surfaceOp?: unknown } expect(raw.surfaceOp).toBeUndefined() - expect(result.summarySeq).toBeGreaterThan(result.startSeq) - expect(result.endSeq).toBeGreaterThan(result.summarySeq) + expect(result.shadowedRange).toEqual({ start: 0, end: 0 }) + expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) + .toEqual(['compact/start', 'compact/summary', 'compact/end']) }) it('threads the cancellation signal through to the backend', async () => { @@ -106,7 +103,7 @@ describe('CompactService seam', () => { const session = new Session(SessionId('s')) const controller = new AbortController() - await svc.compactRegion(session, 0, 0, stubAgent(session, 'm'), controller.signal) + await svc.compactRegion(0, 0, stubAgent(session, 'm'), controller.signal) expect(svc.lastSignal).toBe(controller.signal) await svc.compactIfNeeded(stubAgent(session), '', [], controller.signal) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index b4420e80c7..3d6ade301f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -109,7 +109,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ summary: 'Abstract compaction service.', methods: [ 'abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise', - 'abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', + 'abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise', ], }, { @@ -624,7 +624,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CompactionResult', - declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', + declaration: 'export interface CompactionResult {\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, { name: 'ConfinedArgv', From 7a33ee94be9f4338369f388c5efe3deb4965b50d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:29:16 +0800 Subject: [PATCH 007/163] refactor: remove agent entry mirror --- packages/core/agent/src/index.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 7864f5938c..edb7f59a37 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -202,9 +202,6 @@ interface FactorySlot { */ export class AgentRegistry extends Service { private store = new Map() - // TODO(agent-entry-mirror): derive exact-object checks from store.get(agent.id) - // plus entry.agent identity; this WeakMap mirrors the authoritative id map. - private entries = new WeakMap() private factory: FactorySlot | undefined constructor(ctx: Context) { @@ -334,7 +331,7 @@ export class AgentRegistry extends Service { const carrier = scopeTarget(agent, agent) // This is the authoritative collision boundary. Concurrent create/resume // operations may both prepare, but only one exact entry can publish. - if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) + if (this.store.has(id)) throw new Error(`agent "${id}" is already registered`) const entry: AgentEntry = { id, agent, @@ -344,7 +341,6 @@ export class AgentRegistry extends Service { detachRequested: false, } this.store.set(id, entry) - this.entries.set(agent, entry) let entered = true const detach = (): void => { if (!entered) return @@ -371,7 +367,6 @@ export class AgentRegistry extends Service { /* v8 ignore next -- enter() rejects replacement while this single-shot detach capability is live. */ if (this.store.get(entry.id) !== entry) return this.store.delete(entry.id) - this.entries.delete(entry.agent) // An insertion rolled back before announce was never externally created, // so emitting disposed would invent an impossible lifecycle edge. Marking // happens before the created emit: if a later created listener throws, @@ -403,8 +398,8 @@ export class AgentRegistry extends Service { * creation listener). */ announce(agent: Agent): void { - const entry = this.entries.get(agent) - if (entry === undefined || this.store.get(entry.id) !== entry) { + const entry = this.store.get(agent.id) + if (entry === undefined || entry.agent !== agent) { throw new Error(`agent "${agent.id}" is not live in this registry`) } if (entry.announced || entry.announcing) { From 709cc7200eb2b3ac64090c67833a25a367a1b3df Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:59:21 +0800 Subject: [PATCH 008/163] refactor: unify agent and session identity --- docs/config-catalog.md | 18 +- docs/cookbook/extension-cookbook.md | 4 +- docs/cordis-catalog/events.md | 34 +-- docs/cordis-catalog/services.md | 12 +- docs/core-data-structures/approval.md | 2 +- docs/core-data-structures/bash.md | 2 +- docs/core-data-structures/core.md | 8 +- docs/core-data-structures/subagent.md | 2 +- docs/event-producer-consumer.md | 34 +-- .../2026-06-11-content-block-vocabulary.md | 2 +- .../2026-06-14-session-persistence.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 10 +- .../2026-06-30-event-domain-semantics.md | 2 +- .../2026-07-08-agent-scope-contexts.md | 3 +- .../feature/2026-07-08-repeat-tool-guard.md | 5 +- ...-20-remove-agent-boundary-mirror-events.md | 8 +- ...claude-code-and-codex-subagent-backends.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 6 +- examples/coding-agent/tests/code-mode.e2e.ts | 7 +- .../coding-agent/tests/coding-task.e2e.ts | 4 +- examples/coding-agent/tests/compaction.e2e.ts | 4 +- examples/coding-agent/tests/full-loop.e2e.ts | 4 +- examples/coding-agent/tests/resume.e2e.ts | 3 - examples/coding-agent/tests/todo-write.e2e.ts | 4 +- .../cordis-agent/tests/cordis-tools.e2e.ts | 8 +- .../bash/tool-bash/tests/integration.spec.ts | 11 +- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- .../tests/compact-loop-repro.spec.ts | 7 +- .../cordis/tool-cordis/src/api-catalog.ts | 16 +- .../tool-cordis/tests/integration.spec.ts | 7 +- packages/core/agent-core/README.md | 2 +- .../core/agent-core/tests/agent-core.spec.ts | 12 +- packages/core/agent-loop/README.md | 10 +- packages/core/agent-loop/src/agent.ts | 8 +- packages/core/agent-loop/src/index.ts | 29 ++- packages/core/agent-loop/tests/agent.spec.ts | 53 +++-- packages/core/agent-loop/tests/cancel.spec.ts | 35 ++-- .../tests/config-session-id.spec.ts | 29 +-- .../agent-loop/tests/coverage-edges.spec.ts | 21 +- .../agent-loop/tests/interception.spec.ts | 62 +++--- packages/core/agent-loop/tests/loop.spec.ts | 85 ++++---- .../core/agent-loop/tests/properties.spec.ts | 11 +- .../agent-loop/tests/request-cache.e2e.ts | 7 +- .../tests/request-reconstruction.spec.ts | 22 +- packages/core/agent-loop/tests/resume.spec.ts | 85 ++++---- .../agent-loop/tests/review-fixes.spec.ts | 83 ++++---- .../agent-loop/tests/scope-lifecycle.spec.ts | 193 ++++++++---------- .../core/agent-loop/tests/tool-order.spec.ts | 9 +- .../core/agent-loop/tests/turn-stop.spec.ts | 19 +- packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 28 ++- packages/core/agent/src/types.ts | 20 +- packages/core/agent/tests/agent.spec.ts | 29 +-- packages/core/session/src/index.ts | 7 +- packages/core/tools/tests/code-mode.spec.ts | 3 +- packages/core/tools/tests/scoped.spec.ts | 14 +- packages/fs/tool-fs/tests/fs-tools.e2e.ts | 4 +- packages/guard/repeat-tool-guard/README.md | 4 +- packages/guard/repeat-tool-guard/src/index.ts | 19 +- .../tests/repeat-tool-guard.spec.ts | 39 ++-- .../hooks/hooks-claude/tests/bridge.spec.ts | 31 +-- .../hooks/hooks-claude/tests/coverage.spec.ts | 73 +++---- .../hooks/hooks-codex/tests/bridge.spec.ts | 17 +- .../hooks/hooks-codex/tests/coverage.spec.ts | 73 +++---- packages/subagent/subagent-acp/src/run.ts | 4 +- .../tests/multi-subagent.spec.ts | 7 +- .../subagent-fork/tests/subagent-fork.spec.ts | 7 +- .../subagent/subagent-inprocess/src/index.ts | 7 +- .../tests/structured.spec.ts | 9 +- .../tests/subagent-inprocess.spec.ts | 7 +- .../subagent-spawn/tests/spawn.e2e.ts | 4 +- .../tests/subagent-spawn.spec.ts | 13 +- packages/subagent/subagent/src/index.ts | 7 +- packages/subagent/subagent/src/types.ts | 5 +- .../subagent/subagent/tests/service.spec.ts | 12 +- .../tool-subagent/tests/tool-subagent.spec.ts | 24 ++- packages/support/subagent-mock/src/index.ts | 4 +- .../subagent-mock/tests/subagent-mock.spec.ts | 6 +- .../todo/tool-todo/tests/integration.spec.ts | 9 +- .../todo/tool-todo/tests/tool-todo.spec.ts | 5 +- packages/ui/acp/src/index.ts | 3 - packages/ui/acp/tests/approval.spec.ts | 6 +- packages/ui/acp/tests/bridge.spec.ts | 24 +-- packages/ui/acp/tests/dispose.spec.ts | 43 ++-- packages/ui/acp/tests/edges.spec.ts | 3 +- packages/ui/acp/tests/load.spec.ts | 9 +- packages/ui/acp/tests/multi-session.spec.ts | 6 +- packages/ui/acp/tests/turns.spec.ts | 6 +- packages/ui/jsonrpc/src/server.ts | 2 - packages/ui/jsonrpc/tests/server.spec.ts | 27 +-- packages/ui/stdio-agent/README.md | 8 +- packages/ui/stdio-agent/src/index.ts | 23 +-- packages/ui/stdio-agent/src/stdio-chat.ts | 40 ++-- .../ui/stdio-agent/tests/readline.spec.ts | 2 +- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 16 +- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 60 +++--- packages/util/brand/README.md | 4 +- packages/util/brand/src/index.ts | 8 +- .../tool-workflow/tests/tool-workflow.spec.ts | 6 +- .../workflow-workerthread/src/runtime.ts | 4 +- .../tests/integration.spec.ts | 9 +- .../tests/source-worker.compat.spec.ts | 4 +- .../tests/workflow-workerthread.e2e.ts | 8 +- .../tests/workflow-workerthread.spec.ts | 20 +- packages/workflow/workflow/src/types.ts | 5 +- 105 files changed, 899 insertions(+), 948 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..94d613bc4e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:250`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:249`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` @@ -117,8 +117,8 @@ Requires: `agents` · `sessions` · `llm` · `tools` · `systemPrompt` export interface Config { /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -127,9 +127,9 @@ export interface Config { } ``` -Depends on: [`AgentId`](../packages/core/agent/src/index.ts) · [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) +Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:325`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` @@ -611,7 +611,7 @@ Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-l ```ts config-catalog /** * App config: the swappable per-demo values, each routed to where the app wires - * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through + * it. `model`/`resumeSessionId` configure the pre-created agent (through * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` * is the explicit model-facing tool order (forwarded to the system-prompt plugin); @@ -620,7 +620,7 @@ Source: [`packages/skill/skill-local/src/index.ts:39`](../packages/skill/skill-l * `welcome` is the UI banner. */ export interface Config { - /** Model name for the `main` agent (must have a registered adapter). */ + /** Model name for the pre-created agent (must have a registered adapter). */ model: string /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -635,7 +635,7 @@ export interface Config { /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -645,7 +645,7 @@ export interface Config { Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/ui/stdio-agent/src/index.ts:64`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index e2677e463a..1db5e85828 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -36,7 +36,7 @@ A UI plugin renders from the `session/event` feed (the assistant token stream as ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -50,7 +50,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 5a3398f9a8..a5e018079a 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:316`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:331`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:605`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:593`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:438`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:426`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:456`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:360`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:348`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:485`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:473`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:537`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:525`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:381`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:552`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:540`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:570`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:558`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:588`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts) ## `approval/*` @@ -317,7 +317,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:90`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:91`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -327,7 +327,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:66`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:67`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -337,7 +337,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:72`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:73`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -347,7 +347,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:82`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index bfb31749d5..4d162a55f5 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -14,12 +14,12 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary Concrete ReactLoopAgent factory and driver service. ```ts cordis-catalog -create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:338`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:337`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` @@ -32,13 +32,13 @@ async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void enter(agent: Agent): () => void announce(agent: Agent): void -get(id: AgentId): Agent | undefined +get(id: SessionId): Agent | undefined list(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:203`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:199`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` @@ -218,7 +218,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:592`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:597`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` @@ -244,7 +244,7 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:124`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index c5fa1fe13b..e8820ccfeb 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -6,7 +6,7 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv ## Identity and outcome -Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call, session, or agent ids. +Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids. ```ts type-equiv type ApprovalRequestId = Branded<'ApprovalRequestId'> diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index 51f7cb0696..df6c5ab16b 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -110,7 +110,7 @@ The `owner` token is the isolation key: the executor stores it but never interpr `stdin` and `env` are set by in-process plugins (the hooks bridges, native plugins) to feed a hook command its JSON payload on stdin and its `CLAUDE_PROJECT_DIR`/`CLAUDE_PLUGIN_ROOT` env. The model-facing `dsh-tool-bash` tool does not expose them as parameters — its request is built from `command`/`workdir`/`timeoutMs`/`signal`/`owner` only — because a model already has equivalent power through shell syntax (`FOO=bar cmd`, a heredoc), so duplicating them as tool params would be redundant. This is NOT a security boundary: the credential scrub in `dsh-bash-local` is what stops the harness's ambient secrets reaching a spawned command, and it works regardless of these fields (a model cannot read a value the scrub removed, and tool-call args are static JSON, never shell-evaluated). A guard test asserts the tool doesn't forward model `env`/`stdin` — to catch a future `...args` spread, not to defend a trust wall. `env` is merged AFTER the scrub so an explicit caller entry (a value it already holds) wins even on a credential-shaped name. See [the bash-stdin-env RFC](../rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). -Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`/`AgentId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. +Both ids the seam handles are [branded](core.md) (zero-cost `string` brands, the same machinery as `SessionId`): `BashTaskId` (a tracked background task, generated `bash-N` by the local executor) and `OwnerToken` (the opaque isolation key). `OwnerToken` is deliberately a DISTINCT brand from `SessionId`, not an alias: the bash seam is a capability seam that must not know what an owner token *means*, so it never imports `dsh-session`'s vocabulary — the `dsh-tool-bash` consumer is the single boundary that casts the owning agent's `SessionId` into an `OwnerToken`. Branding both stops a raw `string` (or a `BashTaskId` where an `OwnerToken` is expected, or vice versa) from slipping through the type checker on the model-facing `task_id` path. ## Foreground runs: `BashRunResult` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 168d727c8d..c6b74ed3c0 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -73,7 +73,7 @@ Two large discriminated unions are the ones consumers `switch` over most: **`Str ## Branded IDs -IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (an `AgentId` can't be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. +IDs that cross package boundaries are **branded** — structurally strings, but non-interchangeable at the type level (a `SessionId` cannot be passed where a `CallId` is expected). Construction goes through a per-type factory; comparison, logging, and JSON behave as ordinary strings. The `Branded` primitive lives in its own type-only package, [dsh-brand](../../packages/util/brand) (no runtime code, no harness-package dependency), so any package can brand the ids it owns without depending on an unrelated capability package (e.g. dsh-bash brands `BashTaskId`/`OwnerToken` via dsh-brand alone, never pulling in dsh-llm). @@ -83,7 +83,7 @@ Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index type Branded = string & { readonly [BRAND]: B } ``` -The three core IDs: `CallId` (correlates a tool call with its result; dsh-llm), `SessionId` (dsh-session), `AgentId` (dsh-agent). Each is `Branded<'CallId'>` etc. plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md). +The two core IDs are `CallId` (correlates a tool call with its result; dsh-llm) and `SessionId` (the shared live agent and durable session identity; dsh-session). Each is a `Branded<'…'>` plus a same-named factory function. Capability seams brand their own ids too — see `BashTaskId`/`OwnerToken` in [bash.md](bash.md). ## Content blocks and messages @@ -254,7 +254,7 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types ```ts type-equiv interface Agent { - readonly id: AgentId + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus @@ -353,7 +353,7 @@ interface Agent { } ``` -`AgentStatus` is `'idle' | 'running' | 'disposed'`. `AgentId` is a branded string. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. +`AgentStatus` is `'idle' | 'running' | 'disposed'`. `Agent.id` and `Agent.session.id` are the same branded `SessionId`. `AgentOptions` (`model?`) is merge-extensible — plugins add creation options by declaration merging. Persona is not an agent option: the `dsh-system-prompt` config supplies the global default, and an agent-scoped `deployment:persona` section may shadow it. The `agent/*` event taxonomy (lifecycle emits incl. `agent/session-start`, serial `agent/pre-step`/`agent/turn-stop` checkpoints, and the `agent/prompt-submit`/`agent/request`/`agent/session-prefix`/`agent/step-result`/`agent/turn-continuation` waterfalls) is in [architecture.md § Event taxonomy](../architecture.md#event-taxonomy); turn/step boundaries are durable `session/event` records, not `agent/*` emits. ## Interception decisions diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 5454f326b3..883c33020a 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -68,7 +68,7 @@ The handle the consumer holds after a provider has established a ready child. Th ```ts type-equiv interface SubagentRun { - readonly id: AgentId + readonly id: SessionId readonly result: Promise dispose(): Promise sendMessage?(content: ContentBlock[]): void diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index a7e2c8bb37..80389608cc 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:316`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:331`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:605`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:438`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:456`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:360`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:485`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:537`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:381`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:345`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:552`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:570`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:588`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:348`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:473`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:525`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:576`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | @@ -31,10 +31,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:100`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `skill/provider-added` | `emit` | [`packages/skill/skill/src/index.ts:131`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | | `skill/provider-removed` | `emit` | [`packages/skill/skill/src/index.ts:137`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`emit`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:90`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:66`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:72`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:67`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:49`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:59`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 42efb34774..fb272c5df9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -21,4 +21,4 @@ In-session context injection (`context/message`, `steering/message`) renders as - Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. -- IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. +- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md index 03d8a30b37..824e59a172 100644 --- a/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md +++ b/docs/rfc/implemented/architecture/2026-06-14-session-persistence.md @@ -23,7 +23,7 @@ Key choices recorded here because they are durable, contested, and surprising: - **Append-only; a crashed turn is closed, never truncated.** Committed events — those at or below a flushed `turn/end` — are never rewritten. The loop only flushes at `turn/end`, so a crash can leave a durable log whose final turn never closed: real, fully-written events sit after the last `turn/end`. **A single turn can be huge in a long-horizon task** (many steps, large tool output spanning a long autonomous run), so discarding the interrupted turn would silently destroy a large amount of real work — truncating a turn is wrong. Instead, on reload `load` PRESERVES those events and CLOSES the orphaned turn by durably appending the minimal synthetic boundary events: an error `tool/result` for every `tool-call` the crash left unanswered, then a `step/end` if a step was still open, then a `turn/end` carrying the merge-extensible `{ kind: 'interrupted' }` reason (a marker that records the turn was cut short by a crash, not completed by the model — no loop ever emits it). The synthetic tool results matter for resume correctness: the loop logs the `assistant/message` (carrying the `tool-call` blocks) BEFORE running the tools, so a crash mid-tool leaves calls without results; `deriveMessages()` would then replay a dangling assistant tool-call, which every provider rejects as an invalid transcript on the next request. Answering each orphaned call with an error result keeps the rehydrated history valid. `load` returns the balanced log, so a resumed session is immediately usable. The ONLY thing discarded is a never-fully-written **torn tail fragment** — a final record whose bytes (JSONL) or row were never completely flushed; that fragment is not a valid event and is dropped before the synthetic closers are written. A parse error or `seq` gap in the COMMITTED region (at or before the last real `turn/end`) is genuine corruption and makes the session unloadable. - **File backend canonical, DB backend a proven drop-in.** `SessionEvent` maps 1:1 onto a row `(session_id, seq, type, time, data)` — `append` is INSERT (in a transaction asserting the contiguous-seq contract), `load` is SELECT … ORDER BY seq. `dsh-session-persistence-sqlite` is exactly this: a `SessionPersistence` subclass with no interface change (opencode runs this exact shape on SQLite/WAL), and it passes the same `runPersistenceContract` suite as the JSONL backend — so the contract holds both backends to identical semantics (lazy materialization, interrupted-turn close on load, contiguous-seq), expressed once over file bytes and once over rows. - **Metadata is out-of-log.** Format version, cwd, and lineage are storage concerns, not replayable conversation state, so they live in a `SessionHeader` owned by `dsh-session` and attached to a `Session` via a new readonly `session.header` — never in `SessionEventMap`, never reaching `deriveMessages()`. The alternative (a merge-extensible `session/meta` event as log line 0) was rejected: an in-log event would ride along with a seeded/forked session for free, but metadata is not replayable state, so the explicit out-of-log header seam is the cleaner cost. (The header was originally split into an immutable `SessionHeader` plus a mutable `SessionSummary` whose union was `SessionMeta`; the mutable summary was later removed as dead state — see [Drop the mutable session summary](../simplification/2026-06-19-drop-mutable-session-summary.md).) -- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and starts a fresh agent on the resumed id (NOT `${agentId}-session`). The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. +- **`ctx.agents.create()` and `ctx.agents.resume()` are async factories; resume additionally crosses the persistence boundary.** `ctx.agents.resume({ resumeSessionId })` awaits `ctx.sessionPersistence.load`, recreates the live session with the loaded events (so `lastTurnNumber`/`deriveMessages` continue), and registers the fresh agent under the exact resumed id. The agent-loop does NOT hard-inject `sessionPersistence` (that would pend non-persistent demos forever); `resume` rejects with a clear error when it is absent. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 8171a05d30..5fe8224644 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -4,23 +4,23 @@ Status: implemented ## Problem -The harness already brands three identifiers — `CallId` (`packages/llm/llm/src/brand.ts`), `SessionId` (`packages/core/session/src/types.ts`), and `AgentId` (`packages/core/agent/src/types.ts`) — using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". -**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId`/`SessionId`/`AgentId` decay back to bare `string` at exactly the places confusion is most likely: the registry/store `Map` key types and most public method params. Representative sites: `SessionStore.store = new Map()` and `create`/`prepare(id?: string)`/`get(id: string)` (`packages/core/session/src/index.ts`); `AgentRegistry.store = new Map()` and `register`/`get(id: string)` (`packages/core/agent/src/index.ts`); `ToolPresenter.pending = new Map()` keyed by call id and `call(callId: string)`/`result(callId: string)` (`packages/ui/acp/src/index.ts`); the ACP session-id surface beyond the store map — `SessionRecord.sessionId: string`, `bySession = new WeakMap()`, `loadingIds = new Set()`, `requireSession(sessionId: string)`, and the exported `streamSessionEventUpdate(sessionId: string, …)` (`packages/ui/acp/src/index.ts`); and the persistence coordinator's `Map` keyed by session id (`packages/session-persistence/session-persistence/src/coordinator.ts`). A brand that is dropped at the `Map` key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Decision A type-only change. Brands are zero-cost casts; nothing about runtime behavior, serialization, comparison, or the wire format changes. The work is in three parts, all honoring the existing "not every string" policy. -- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId`/`AgentId` already do. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). +- **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `get(id: SessionId)`, `Map`, `Map`, the ACP `SessionRecord.sessionId: SessionId` surface, the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on the struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -58,7 +58,7 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o ## Verification -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`AgentId`/`BashTaskId`) is keyed by bare `string` — `Map` keys, `WeakMap` value slots, `Set` membership (the ACP `bySession`/`loadingIds`), public method params, and exported signatures (`streamSessionEventUpdate`) all take the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` and threaded end-to-end (executor seam, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing surface) with no `dsh-bash` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `task_id`), never as scattered `as` casts. ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index f0ab95ca80..f785851237 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -24,7 +24,7 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. -**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) was migrated to render boundaries from `session/event`, recovering the short agent label from an `agent/created`→id map. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). +**Applying the rule to the boundary twins:** all four boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are **REMOVED**. No production consumer needs the live `Agent` at a boundary: the ACP bridge settles from `session/event` `turn/end` plus `agent/status`, and the only turn-mirror consumer (`dsh-ui-stdio`, a disposable test REPL) renders boundaries from `session/event` while retaining its live target object for the fixed `main` label. The step mirrors were removed first (they had no consumer at all); the turn mirrors followed once ui-stdio was migrated — see [the remove-boundary-mirror-events RFC](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md), which owns that decision. Removing the emits also simplifies the loop's `closeStep`/`closeTurn` (one append each, no paired emit). ## Consequences diff --git a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md index dcff4d3220..15477e023a 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/docs/rfc/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -60,8 +60,7 @@ The ordinary contributor pattern is to register the complete local world during ```js const handle = await ctx.agents.create({ - agentId: AgentId('reviewer'), - sessionId: SessionId('reviewer-session'), + sessionId: SessionId('reviewer'), agentOptions: { model: 'model-name' }, setup(agentCtx) { agentCtx.systemPrompt.section({ diff --git a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md index 9d0446dcad..6961883318 100644 --- a/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md +++ b/docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md @@ -12,11 +12,10 @@ The harness already has every seam the pi extension uses, and better ones: [the The guard is a loop-hygiene plugin, not a model-facing tool: it never appears in the tool list, never vetoes or rewrites a call, and adds exactly one behavior — it watches each agent's stream of tool calls, counts runs of consecutive calls to the same tool with identical canonicalized arguments, and at configured run lengths injects an escalating advisory reminder telling the model to stop repeating itself, re-read the last result, and either change approach or conclude. The purpose is to break unproductive loops within a few wasted steps instead of letting them run to the turn's natural end — while leaving the decision (retry differently, gather more evidence, or finish) entirely with the model, so a legitimately repeated call is delayed by nothing and blocked by nothing. -The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers three listeners and holds all state in plugin-local maps keyed by `AgentId` — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish. +The plugin is `@deepseek-ai/dsh-repeat-tool-guard` at `packages/guard/repeat-tool-guard/`, opening the `guard/` group for loop-hygiene plugins (single-package groups have precedent: [the todo-write RFC](2026-06-29-todo-write-tool.md) shipped `todo/tool-todo`). It registers two listeners and holds state in a `WeakMap` keyed by the live `Agent` object — the tool registry is a context-level singleton whose waterfalls interleave every agent's calls (subagents run on the same context), so per-agent keying is correctness, not polish; weak object keys also make a disposal-only cleanup listener unnecessary. - **`tools/post-execute` (waterfall)** — the one detection point. The listener receives `(exec, result)` together, so counting and reminder delivery need no cross-event pending map (the pi extension needs one only because its `tool_call`/`tool_result` hooks are separate events). It always delegates via `next()` and, when a threshold is hit, folds a reminder onto the downstream decision's `additionalContext` — the observe-and-enrich posture [the hooks bridges](2026-06-30-hook-bridges.md) already use, honoring the waterfall contract. Counting happens here rather than in `tools/pre-execute` because post-execute also runs for denied calls (`ToolRegistry.execute` routes a deny through the same pipeline), and a model hammering a denied call is exactly the loop worth breaking. - **`agent/prompt-submit` (waterfall)** — pure reset hook: delegate via `next()`, clear the submitting agent's chain. A user interjection changes the context; repetition across it is not a loop. -- **`agent/status` (emit)** — on `disposed`, drop the agent's state, bounding the maps over harness lifetime. ### Detection semantics @@ -25,7 +24,7 @@ The chain key is `(tool name, canonical arguments)`; a call identical to the pre Two deliberate rules, both documented in [the package README](../../../../packages/guard/repeat-tool-guard/README.md) because they are behavior a reader would otherwise guess at: - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful — bookkeeping tools interleaved into a loop must not launder it — and it is the pi extension's (undocumented) semantics, kept on purpose and written down. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no `AgentId` to key on. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller (tests, non-loop consumers) has no model to remind and no live agent object to key on. ### Reminder delivery diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index dbef22f3a3..f4b49281ea 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -21,9 +21,9 @@ This duplication is not free. Every lifecycle change had to update the session e Make `session/event` the single live boundary/transcript stream. Consumers that render turns, tool calls, tool results, assistant messages, and durable boundaries subscribe to `session/event` and derive their UI from the same event vocabulary persistence uses. -The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle (or its short id) at a boundary keeps a small map from session id to agent id built from `agent/created`/`agent/disposed`; `dsh-ui-stdio` does exactly this to label its `[ turn N]` header, since the `turn/start` session event carries only the turn number. The canonical record remains the event-sourced session log. +The four durable-boundary mirrors — `agent/turn-start`, `agent/turn-end`, `agent/step-start`, `agent/step-end` — are removed from the agent event taxonomy. A UI that wants the agent handle at a boundary retains the live target object from `agent/created`/`agent/disposed` and compares its session directly; `dsh-ui-stdio` uses this to label the app-owned agent's `[main turn N]` header while other sessions render their durable id. The canonical record remains the event-sourced session log. -The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it was migrated to `session/event` + the id map, and the turn mirrors were removed too. +The step mirrors (which had no consumer at all) were removed first, in [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md); that RFC KEPT the turn mirrors on the stated justification that the stdio UI needed the `Agent` handle at the turn boundary. This RFC finishes the job: `dsh-ui-stdio` is a disposable test REPL whose rendering can change freely, so "ui-stdio needs it" is not a reason to keep a mirror — it reads `session/event` and retains only its live target object. ## Scope: what is and isn't removed @@ -38,8 +38,8 @@ RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: ## Alternatives considered - **Bundling `agent/steering` into the removal** — the original proposal's shape; narrowed out as scope creep: it mirrors the durable `steering/message` control record, not a boundary, and was removed by [its own later decision](2026-07-04-remove-agent-steering-mirror.md) (as was `agent/stream-chunk`, by [the stream-chunk-mirror RFC](2026-07-02-remove-stream-chunk-mirror.md)). -- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` + the id map instead. +- **Keeping the turn mirrors for the stdio UI** — [the event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s original stance; rejected here because `dsh-ui-stdio` is a disposable test REPL, not a load-bearing consumer, and it renders boundaries from `session/event` plus its live target object instead. ## Consequences -A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It must either subscribe to `session/event` or maintain a session-to-agent association. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. +A plugin can no longer observe turn/step boundaries from a convenient `Agent`-first event. It subscribes to `session/event` and, if it needs the live object, resolves the shared id through `ctx.agents` or retains the object it already owns. That is an acceptable trade: boundary consumers should not depend on a second event feed that can drift from the durable log. diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 3de3c7d9be..911d28cf1f 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -14,7 +14,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. - `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. -Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = AgentId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. +Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. ## Verified interface facts (pinned versions) diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md index 1a69f9ebe6..6e3eea1515 100644 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md @@ -16,13 +16,13 @@ Session itself repeats the same fact as `Session.id` and `Session.header.id`. Co Make an agent's registry id equal its session id. `CreateAgentOptions` accepts one id used for both final registry entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; Session keeps one identity home by deriving `id` from `header.id` or removing the alias. Keep the existing creation transaction, final-entry collision checks, and exact-entry detach semantics; remove only maps and fields whose sole job is translating between the ids. -The config-driven path must first settle its currently hidden resume-or-create policy. Today it uses a stable agent label and fresh UUID-suffixed session id to avoid colliding with a durable log on the next run. Under unification it must deliberately resume the fixed id, mint a fresh combined id, or expose an explicit policy; implementation must not pick silently. +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. `agent/created` and `agent/disposed` remain outside this proposal. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. ## Alternatives considered -**Keep separate routing and log identities.** The config-driven loop uses a stable configured agent id with a fresh UUID session on each fresh process start. That is a real use of the distinction: a stable routing/display label plus a new durable conversation. Unification can proceed only after choosing whether this path resumes a fixed identity, mints a combined per-run identity, or exposes the policy explicitly. If the stable label is a required product contract, reject this proposal rather than hiding it in another map. +**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. ## Acceptance criteria @@ -35,4 +35,4 @@ The config-driven path must first settle its currently hidden resume-or-create p ## Risks -This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. The config restart decision is blocking, not mechanical. If separate routing identity is a real requirement, reject this RFC and retain the current caller-supplied pair plus final-entry arbitration. +This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 512688d88d..22446f0dc3 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -4,11 +4,12 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -72,7 +73,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('Code Mode: real model writes a p it('collapses the wire tool list to [run_code], bridges sub-calls, and returns curated output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-code-mode-e2e-')) ctx = await codeModeHarness(workdir) - const agent = ctx.agentLoop.create(AgentId('e2e-code-mode'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-code-mode'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/coding-agent/tests/coding-task.e2e.ts index ce716bdb2c..e1ae9804ac 100644 --- a/examples/coding-agent/tests/coding-task.e2e.ts +++ b/examples/coding-agent/tests/coding-task.e2e.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The swebench-style smoke test: a real model fixes a real bug in a temp @@ -54,7 +54,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('coding task: fix a failing test expect(before.status).not.toBe(0) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-task'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-task'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/coding-agent/tests/compaction.e2e.ts index 854cf49d2a..a0e71aacc4 100644 --- a/examples/coding-agent/tests/compaction.e2e.ts +++ b/examples/coding-agent/tests/compaction.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The compaction smoke test: a real model runs a multi-step bash task with a @@ -64,7 +64,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('compaction: a long session compa }, persistenceRoot: join(workdir, '.sessions'), }) - const agent = ctx.agentLoop.create(AgentId('e2e-compaction'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-compaction'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/coding-agent/tests/full-loop.e2e.ts index 8718139ced..9a01713222 100644 --- a/examples/coding-agent/tests/full-loop.e2e.ts +++ b/examples/coding-agent/tests/full-loop.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The first place a REAL model meets the REAL bash tool: the cheap canary @@ -28,7 +28,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('full loop: real model + real bas it('runs a bash command on request and reports its output', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-full-loop-e2e-')) ctx = await codingHarness(workdir, { persona: SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-loop'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-loop'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Run `echo e2e-ok` with the bash tool and tell me its exact output.' }]) await waitForIdle(ctx, agent) diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 70382b4beb..48e135ad92 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -4,7 +4,6 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' @@ -40,7 +39,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // log on disk survives. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const first = (await ctx.agents.create({ - agentId: AgentId('resume-1'), sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, })).agent as ReactLoopAgent @@ -54,7 +52,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses // run 1's exchange as conversation history. ctx = await codingHarness(process.cwd(), { persona: SYSTEM_PROMPT, persistenceRoot: root }) const resumed = (await ctx.agents.resume({ - agentId: AgentId('resume-2'), resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, })).agent as ReactLoopAgent diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/coding-agent/tests/todo-write.e2e.ts index 698fbd9e9e..7f3c3c1e4a 100644 --- a/examples/coding-agent/tests/todo-write.e2e.ts +++ b/examples/coding-agent/tests/todo-write.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * A REAL model drives the REAL todo_write tool: verify the WORLD (the session @@ -26,7 +26,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('todo_write: real model records a it('appends a todo/write event with the model-produced task list', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-todo-write-e2e-')) ctx = await codingHarness(workdir, { persona: TODO_SYSTEM_PROMPT }) - const agent = ctx.agentLoop.create(AgentId('e2e-todo'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('e2e-todo'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Use the todo_write tool to record a plan of exactly two steps: first ' diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts index 388fcb0058..ffe9a290eb 100644 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ b/examples/cordis-agent/tests/cordis-tools.e2e.ts @@ -1,8 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId } from '@deepseek-ai/dsh-agent' import { cordisHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the self-referential cordis tools: a REAL model drives @@ -38,7 +38,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('mounts a status listener whose tagged output actually fires, then unmounts it', async () => { ctx = await cordisHarness() const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-listener'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -66,7 +66,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('builds itself a reverse_text tool and actually calls it', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-selftool'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', @@ -114,7 +114,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modif it('composes two mounts through provide/inject, and unmounting the provider parks the consumer', async () => { ctx = await cordisHarness() - const agent = ctx.agentLoop.create(AgentId('cordis-e2e-compose'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index a22e86bd99..3ab3ae6628 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { BashTaskId } from '@deepseek-ai/dsh-bash' @@ -74,7 +75,7 @@ describe('bash tool through the agent loop', () => { textResponse('The command printed integration-ok.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-fg'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-fg'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run echo integration-ok' }]) await waitForIdle(ctx, agent) @@ -106,7 +107,7 @@ describe('bash tool through the agent loop', () => { textResponse('It failed with code 9.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-exit'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-exit'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run exit 9' }]) await waitForIdle(ctx, agent) @@ -128,7 +129,7 @@ describe('bash tool through the agent loop', () => { let taskId = '' const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-bg'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-bg'), { model: 'mock' }) // Capture the generated id so the deterministic fixture is checked against // the real executor instead of silently assuming it. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 12b4a53958..c68c445f0a 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -57,7 +57,7 @@ async function setup() { const fakeAgentDisposers = new Map Promise | void)[]>() function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { // The registry KEY (agent.id) is deliberately DIFFERENT from the session - // token (session.header.id) — a config agent has `agentId !== sessionId`. The + // token (session.header.id), which is also the agent's durable id. The // owner token IS the session id, so the notice path must find the agent by // `session.header.id`, NOT the registry key. Using distinct values here makes // the test fail if a regression matched on the wrong field (a same-value fake diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index bb77e44a9c..52a2ecd134 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -3,11 +3,12 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' @@ -119,7 +120,7 @@ describe('CBR-001: a real-loop checkpoint is a valid boundary on both sides', () it('the head checkpoint the loop lands is a balanced cut on both sides', async () => { const { ctx } = await harness(8) try { - const agent = ctx.agentLoop.create(AgentId('repro'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('repro'), { model: 'mock' }) agent.send([{ type: 'text', text: 'do a long multi-step task' }]) await waitForIdle(ctx, agent) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 3d6ade301f..5721bd97d9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -56,7 +56,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ key: 'agentLoop', summary: 'Concrete ReactLoopAgent factory and driver service.', methods: [ - 'create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', + 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', ], @@ -71,7 +71,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(agent: Agent): () => void', 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', - 'get(id: AgentId): Agent | undefined', + 'get(id: SessionId): Agent | undefined', 'list(): Agent[]', ], }, @@ -488,7 +488,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ export const TYPE_API: readonly TypeApiEntry[] = [ { name: 'Agent', - declaration: 'export interface Agent {\n readonly id: AgentId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', + declaration: 'export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly ctx: Context;\n send(content: ContentBlock[], options?: SendOptions): void;\n steer(content: ContentBlock[], options?: SendOptions): void;\n inject(content: ContentBlock[], options?: SendOptions): void;\n cancel(reason?: string): void;\n whenIdle(): Promise;\n}', }, { name: 'AgentFactory', @@ -498,10 +498,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AgentHandle', declaration: 'export interface AgentHandle {\n agent: Agent;\n dispose(): Promise;\n}', }, - { - name: 'AgentId', - declaration: 'export type AgentId = Branded<\'AgentId\'>;', - }, { name: 'AgentOptions', declaration: 'export interface AgentOptions {\n model?: string;\n}', @@ -644,7 +640,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CreateAgentOptions', - declaration: 'export interface CreateAgentOptions {\n readonly agentId: AgentId;\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface CreateAgentOptions {\n readonly sessionId: SessionId;\n readonly meta?: {\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n };\n readonly seed?: readonly SessionEvent[];\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'CreateSessionOptions', @@ -756,7 +752,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ResumeAgentOptions', - declaration: 'export interface ResumeAgentOptions {\n readonly agentId: AgentId;\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', + declaration: 'export interface ResumeAgentOptions {\n readonly resumeSessionId: SessionId;\n readonly agentOptions?: AgentOptions;\n readonly signal?: AbortSignal;\n readonly setup?: (agentCtx: Context) => Promise | void;\n}', }, { name: 'SandboxEnforcement', @@ -868,7 +864,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: AgentId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 94331df7c0..0d4c6c5c18 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -51,7 +52,7 @@ describe('cordis tools through the agent loop', () => { textResponse('Done.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-cordis'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-cordis'), { model: 'mock' }) agent.send([{ type: 'text', text: 'give yourself reverse_text, use it, clean up' }]) await waitForIdle(ctx, agent) diff --git a/packages/core/agent-core/README.md b/packages/core/agent-core/README.md index 33cd0faddd..accbf66a7c 100644 --- a/packages/core/agent-core/README.md +++ b/packages/core/agent-core/README.md @@ -43,7 +43,7 @@ import type { Config } from '@deepseek-ai/dsh-agent-core' // so validation and defaulting can never drift from the owners. ``` -The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates a `main`; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. +The bundle FORWARDS each field to the child that owns it: `agents` to `agent-loop` (default `[]`), so each app supplies its own pre-created agents — a stdio app pre-creates one under the `main` config label; the ACP app pre-creates none (it creates agents on demand at `session/new`) — `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; and `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer. Forwarding is exactly why the owners can live in the shared spine even though the apps disagree on what to configure. ## Why a code bundle, not a shared YAML include diff --git a/packages/core/agent-core/tests/agent-core.spec.ts b/packages/core/agent-core/tests/agent-core.spec.ts index 855bb28d49..058191c88f 100644 --- a/packages/core/agent-core/tests/agent-core.spec.ts +++ b/packages/core/agent-core/tests/agent-core.spec.ts @@ -6,8 +6,10 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' + import type { Message } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' async function composePrefix(ctx: Context, cwd: string): Promise { const agent = { session: { header: { cwd } } } as unknown as Agent @@ -102,16 +104,18 @@ describe('dsh-agent-core bundle', () => { it('defaults the agents list to empty (no pre-created agents)', async () => { const ctx = await mount() - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.get(SessionId('main'))).toBeUndefined() await ctx.fiber.dispose() }) it('forwards a pre-created agent to the loop and the persona to system-prompt', async () => { const ctx = await mount({ - agents: [{ id: AgentId('main'), model: 'mock' }], + agents: [{ id: 'main', model: 'mock' }], persona: 'You are main.', }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) const assembly = await ctx.get('systemPrompt')!.assemble() expect(assembly.sections.find(s => s.name === 'deployment:persona')?.text).toBe('You are main.') await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 6832800d2e..f40577bb95 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -12,14 +12,14 @@ Creation and resume are one rollback-covered transaction: construct a private se The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createAgent(ownerCtx, options)` and `resume(ownerCtx, options)` receive caller ownership explicitly, while the factory keeps its own dependency context for `sessions`/`llm`/`tools`/`systemPrompt`; this lets a caller inject only `agents` without shrinking the new agent's service surface. Caller unload, handle disposal, or provider unload converge on one memoized quiescence boundary. Provider shutdown waits both resource teardown and the public create/resume wrapper that observed deactivation, so no continuation can publish after dependencies disappear. -IDs are caller-chosen and assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same agent or session id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; IDs become reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. +Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: string, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create, used directly by programs and by `cordis.yml`-configured agents. It creates a fresh per-run session id `${id}-session-` with optional metadata; the uuid avoids colliding with a prior durable log. Each call is a new session (a deliberate demo simplification — a real resume-or-create policy is a TODO). Disposed with the calling fiber. +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): -- `ctx.agents.create({ agentId, sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create on a caller-supplied `sessionId`, NOT `${id}-session`. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. -- `ctx.agents.resume({ agentId, resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. The live session id is the resumed id; turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. +- `ctx.agents.create({ sessionId, meta?, seed?, agentOptions?, setup?, signal? }): Promise` — programmatic create under the caller-supplied shared id. It awaits the unpublished setup transaction before returning; `meta` carries cwd/lineage/seed-boundary metadata and `seed` reconstructs a forked child prefix after the session boundary validates and snapshots the durable values. `signal` applies only until this promise settles. The resolved [`AgentHandle`](../agent/README.md) owns exact teardown. +- `ctx.agents.resume({ resumeSessionId, agentOptions?, setup?, signal? }): Promise` — load a persisted session via `ctx.sessionPersistence` ([session persistence](../../../docs/rfc/implemented/architecture/2026-06-14-session-persistence.md)), register the agent under that same id, reconstruct its history, then await setup against a fresh unpublished agent scope before rollback-covered publication. Turn numbering and derived history continue from the loaded log. Requires a session-persistence backend (NOT hard-injected — non-persistent demos still work; `resume` rejects with a clear error when persistence is absent). `signal` is creation-only. Returns an `AgentHandle`. The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loop fiber (it discards the handle). For a programmatic agent, the handle holder is the only consumer-facing teardown capability; AgentLoop provider unload is the independent structural teardown edge, not another handle exposed to application code. @@ -32,7 +32,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo ```ts interface Config { agents: Array<{ - id: string // required + id: string // required stable label; prefixes fresh combined ids model?: string resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index ff66f942be..d54baab597 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -8,11 +8,11 @@ import type { Context } from 'cordis' import { agentEvents } from '@deepseek-ai/dsh-agent' -import type { AgentId, AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' +import type { AgentOptions, AgentStatus, SendOptions } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { deepFreeze } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' -import { snapshotJsonValue, type Session } from '@deepseek-ai/dsh-session' +import { snapshotJsonValue, type Session, type SessionId } from '@deepseek-ai/dsh-session' import { Inbox, type InboxMessage } from './inbox.ts' import { isTurnOpen, lastTurnNumber, runLoop } from './loop.ts' @@ -59,7 +59,7 @@ export interface PreparedReactLoopAgent { * @returns the agent and closures bound only to that exact instance. */ export function prepareReactLoopAgent( - ctx: Context, id: AgentId, options: AgentOptions, session: Session, + ctx: Context, id: SessionId, options: AgentOptions, session: Session, ): PreparedReactLoopAgent { if (claimedDriverSessions.has(session)) { throw new Error(`session "${session.id}" already has a concrete agent driver`) @@ -163,7 +163,7 @@ export class ReactLoopAgent implements Agent { constructor( private loopCtx: Context, - public readonly id: AgentId, + public readonly id: SessionId, public readonly options: AgentOptions, public readonly session: Session, ) { diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 4ad4779176..33276c01f8 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -14,7 +14,6 @@ import { agentEvents } from '@deepseek-ai/dsh-agent' import type { AgentFactory, AgentHandle, - AgentId, AgentOptions, CreateAgentOptions, ResumeAgentOptions, @@ -68,7 +67,7 @@ class FactoryOwnership { } /** Build the public cancellation error while preserving a caller-supplied cause. */ -function signalAbortError(id: AgentId, signal: AbortSignal): Error { +function signalAbortError(id: SessionId, signal: AbortSignal): Error { if (signal.reason instanceof Error) return signal.reason return new Error(`agent "${id}" creation aborted`, { cause: signal.reason }) } @@ -108,7 +107,7 @@ class AgentCreationTransaction { private readonly loopCtx: Context, private readonly ownerCtx: Context, private readonly ownership: FactoryOwnership, - readonly id: AgentId, + readonly id: SessionId, signal?: AbortSignal, ) { ownerCtx.fiber.assertActive() @@ -325,8 +324,8 @@ declare module 'cordis' { export interface Config { /** Agents created or resumed at plugin startup. */ agents: (AgentOptions & { - /** Registry identity for the live agent. */ - id: AgentId + /** Stable config label used in logs and as the fresh combined-id prefix. */ + id: string /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -363,13 +362,13 @@ export class AgentLoop extends Service implements AgentFactory { for (const { id, cwd, resumeSessionId, ...options } of config.agents) { if (resumeSessionId === undefined || resumeSessionId === '') { - this.create(id, options, cwd === undefined ? {} : { cwd }) + const sessionId = SessionId(`${id}-session-${randomUUID()}`) + this.create(sessionId, options, cwd === undefined ? {} : { cwd }) continue } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { - agentId: id, resumeSessionId, agentOptions: options, }).catch((error: unknown) => { @@ -382,19 +381,19 @@ export class AgentLoop extends Service implements AgentFactory { } /** - * Create an agent on a fresh per-run session, owned by the accessing fiber. - * Constructor-driven config calls use the loop fiber itself. - * @param id - agent registry id. + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. * @param options - concrete loop options. * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: AgentId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - const session = loopCtx.sessions.prepare(sessionId, { meta }) + const session = loopCtx.sessions.prepare(id, { meta }) const agent = transaction.prepare(options, session) transaction.publish('startup') return agent @@ -417,7 +416,7 @@ export class AgentLoop extends Service implements AgentFactory { this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.sessionId, options.signal, ) try { @@ -461,7 +460,7 @@ export class AgentLoop extends Service implements AgentFactory { this.runtime.ctx, ownerCtx, this.ownership, - options.agentId, + options.resumeSessionId, options.signal, ) try { diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7f65bbc5f3..56d6489fbe 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,6 +1,5 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -53,10 +52,10 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('exclusive-driver')) - const prepared = prepareReactLoopAgent(ctx, AgentId('first-driver'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('first-driver'), { model: 'mock' }, session) expect(() => prepared.agent.ctx).toThrow('context is not bound') - expect(() => prepareReactLoopAgent(ctx, AgentId('second-driver'), { model: 'mock' }, session)) + expect(() => prepareReactLoopAgent(ctx, SessionId('second-driver'), { model: 'mock' }, session)) .toThrow('already has a concrete agent driver') await prepared.dispose() @@ -66,11 +65,11 @@ describe('ReactLoopAgent', () => { it('borrows caller options and binds its scoped context exactly once', async () => { const ctx = await harness(new MockAdapter([textResponse('unused')])) const options = { model: 'mock' } - const agent = ctx.agentLoop.create(AgentId('owned-bindings'), options) + const agent = ctx.agentLoop.create(SessionId('owned-bindings'), options) expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') - expect(agent.session.id).toMatch(/^owned-bindings-session-/) + expect(agent.session.id).toBe(agent.id) expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() @@ -81,7 +80,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -96,7 +95,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -111,7 +110,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -124,7 +123,7 @@ describe('ReactLoopAgent', () => { it('inject() decides enclosure from the LOG (open turn), not agent status', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Simulate an OPEN turn in the log while the agent is idle (status is not a // reliable open-turn signal). inject must append into that open turn, NOT @@ -150,7 +149,7 @@ describe('ReactLoopAgent', () => { // A persistence-like listener whose flush rejects. ctx.on('session/flush', () => { throw new Error('disk gone') }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // inject() is synchronous and fires a fire-and-forget flush; a rejecting // flush must be contained (logged), never thrown into the caller. @@ -163,7 +162,7 @@ describe('ReactLoopAgent', () => { it('idle inject() closes its one-shot turn AND still checkpoints even if the append throws', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) @@ -183,7 +182,7 @@ describe('ReactLoopAgent', () => { it('idle inject() still checkpoints when a listener throws on the synthetic turn/end', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let flushes = 0 ctx.on('session/flush', () => { flushes += 1 }) // Session contains a throwing post-commit turn/end observer. The accepted @@ -206,7 +205,7 @@ describe('ReactLoopAgent', () => { // A non-Error rejection exercises the String() normalization branch. ctx.on('session/flush', () => { throw 'disk gone' }) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const errors: { turn: number; step: number; message: string }[] = [] ctx.on('agent/error', (_a, turn, step, error) => void errors.push({ turn, step, message: error.message })) @@ -225,7 +224,7 @@ describe('ReactLoopAgent', () => { it('idle inject() with a non-serializable source opens no turn (nothing to close)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A non-serializable source makes the turn/start append throw BEFORE the // event is pushed (Session.append validates before push), so NO turn opens. @@ -240,7 +239,7 @@ describe('ReactLoopAgent', () => { it('steer() when idle falls through to send() and starts a turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // steer while idle delegates to send agent.steer([{ type: 'text', text: 'steer idle' }], { source: { kind: 'plugin', plugin: 'test' } }) @@ -258,7 +257,7 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('test')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { model: 'mock' }, session) const { agent } = prepared // Start the loop to get the disposer; the agent waits for messages @@ -280,7 +279,7 @@ describe('ReactLoopAgent', () => { const ctx = new Context() await ctx.plugin(SessionStore) const session = ctx.sessions.create(SessionId('pre-start-dispose')) - const prepared = prepareReactLoopAgent(ctx, AgentId('pre-start-dispose'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('pre-start-dispose'), { model: 'mock' }, session) await prepared.dispose() expect(prepared.agent.status).toBe('disposed') @@ -294,7 +293,7 @@ describe('ReactLoopAgent', () => { it('setting the same status does not emit agent/status again', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const statuses: string[] = [] ctx.on('agent/status', (subject, status) => { @@ -313,7 +312,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() resolves immediately when the agent is not running', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Fresh agent is idle — whenIdle() takes the not-running fast path and // resolves without subscribing. await must not hang. @@ -324,7 +323,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() waits for queued work that has not flipped status yet', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'queued') let settled = false @@ -342,8 +341,8 @@ describe('ReactLoopAgent', () => { it('whenIdle() awaits the running→idle transition, ignoring other subjects/running events', async () => { const adapter = new MockAdapter([textResponse('ok'), textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' }) // Drive `agent` into `running`, then await whenIdle() — it subscribes to // agent/status and resolves on the first transition out of running. @@ -379,7 +378,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter(['hang']) ctx.llm.registerAdapter(['mock'], adapter) const session = ctx.sessions.create(SessionId('bare')) - const prepared = prepareReactLoopAgent(ctx, AgentId('bare'), { model: 'mock' }, session) + const prepared = prepareReactLoopAgent(ctx, SessionId('bare'), { model: 'mock' }, session) const { agent } = prepared prepared.markPublished() const dispose = prepared.startDriver() @@ -404,7 +403,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -425,7 +424,7 @@ describe('ReactLoopAgent', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -446,7 +445,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'running') throw new Error('bad running listener') }) @@ -464,7 +463,7 @@ describe('ReactLoopAgent', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/status', (_subject, status) => { if (status === 'idle') throw new Error('bad idle listener') }) diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 56376b69a7..82b1f6c58c 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -16,7 +16,8 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -57,7 +58,7 @@ describe('Agent.cancel()', () => { it('cancel() on an idle agent with nothing queued is a no-op; the next prompt runs (F2 leak guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // The loop is parked at the idle wait with nothing queued. A cancel here must // NOT arm the marker — otherwise the next legitimate prompt would be dropped. @@ -74,7 +75,7 @@ describe('Agent.cancel()', () => { it('pre-step cancel drops the about-to-start turn (no turn is opened)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // send() queues synchronously (status still idle, loop microtask not yet // resumed). Cancel in that pre-step window: the queued turn must not run. @@ -93,7 +94,7 @@ describe('Agent.cancel()', () => { it('a whenIdle() waiter registered BEFORE a pre-step cancel resolves (F1 hang guard)', async () => { const adapter = new MockAdapter([textResponse('x')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Queue work, then register a whenIdle() waiter while in the pre-step window // (status idle, hasQueued true) — it does NOT take the fast path. Then cancel. @@ -114,7 +115,7 @@ describe('Agent.cancel()', () => { it('cancel() mid-step aborts the in-flight model call; the turn ends aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -131,7 +132,7 @@ describe('Agent.cancel()', () => { it('cancel() with no reason defaults to "cancelled" when aborting an in-flight step', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -147,7 +148,7 @@ describe('Agent.cancel()', () => { it('a prompt sent AFTER a cancelled turn settles runs normally (marker reset)', async () => { const adapter = new MockAdapter(['hang', textResponse('second reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // First turn hangs; cancel it mid-step. send(agent, 'first') @@ -169,7 +170,7 @@ describe('Agent.cancel()', () => { it('cancel from inside the agent/session-prefix waterfall drops the step (prefix-composition window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Prefix composition runs before the pre-step seam on the instance's first // step; a cancel landing inside it must drop the about-to-start step @@ -203,7 +204,6 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-prefix'), sessionId: SessionId('dispose-prefix-session'), agentOptions: { model: 'mock' }, }) @@ -232,7 +232,7 @@ describe('Agent.cancel()', () => { it('a cancel-interrupted prefix composition is discarded: the next send recomposes and ships the fresh prefix (stale-cache guard)', async () => { const adapter = new MockAdapter([textResponse('reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // The first composition is interrupted mid-waterfall and — like an // abort-aware listener bailing on a firing signal — contributes nothing. @@ -266,7 +266,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous turn/start session-event listener drops the step (step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A turn/start listener fires right after turn/start is appended, BEFORE any // AbortController is installed for the step. Cancelling there must still drop @@ -295,7 +295,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous step/start session-event listener drops the step (post-step-start window)', async () => { const adapter = new MockAdapter([textResponse('should not stream')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A step/start session-event listener fires AFTER step/start is appended // (and after the pre-step seam), so cancelling there lands in the SECOND @@ -334,7 +334,6 @@ describe('Agent.cancel()', () => { ctx.llm.registerAdapter(['mock'], adapter) const handle = await ctx.agents.create({ - agentId: AgentId('a-dispose-step-start'), sessionId: SessionId('dispose-step-start-session'), agentOptions: { model: 'mock' }, }) @@ -366,7 +365,7 @@ describe('Agent.cancel()', () => { // `aborted` and run NO second step. const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steps = 0 const reasons: TurnEndReason[] = [] @@ -398,7 +397,7 @@ describe('Agent.cancel()', () => { it('cancel from a synchronous agent/status(running) listener drops the turn (window 2)', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // setStatus('running') emits agent/status SYNCHRONOUSLY, so a running // listener can cancel in the gap between the loop's pre-step check and @@ -428,7 +427,7 @@ describe('Agent.cancel()', () => { // so whenIdle() resolves on the replacement turn's running→idle, not before. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let replaced = false const dispose = ctx.on('agent/status', (subject, status) => { @@ -458,7 +457,7 @@ describe('Agent.cancel()', () => { // settle (the quiescence contract), not resolve before B's first event. const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'A') // queues A (status still idle, loop microtask pending) const idle = agent.whenIdle() // registers a waiter (idle + hasQueued → no fast path) @@ -478,7 +477,7 @@ describe('Agent.cancel()', () => { it("cancel clears the turn's steering — it is not re-enqueued as a fresh turn", async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 07d6cd9e9b..134ea68c32 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -7,7 +7,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -32,7 +33,7 @@ describe('config-driven session id', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) const loopFiber = await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }], + agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('deferred') }], }) const resumeEffect = loopFiber.getEffects().find(effect => effect.label === 'agentLoop.resume(main)') @@ -53,11 +54,13 @@ describe('config-driven session id', () => { await ctx1.plugin(SystemPrompt) await ctx1.plugin(ToolRegistry) await ctx1.plugin(AgentRegistry) - await ctx1.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) + await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.get(AgentId('cfg')) as ReactLoopAgent + const a1 = ctx1.agents.list()[0] as ReactLoopAgent + expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) + expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -70,10 +73,11 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('cfg'), model: 'mock' }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.get(AgentId('cfg')) as ReactLoopAgent + const a2 = ctx2.agents.list()[0] as ReactLoopAgent + expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) a2.send([{ type: 'text', text: 'q2' }], { source: { kind: 'user' } }) @@ -96,7 +100,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -110,7 +114,7 @@ describe('config-driven session id', () => { await ctx2.plugin(SystemPrompt) await ctx2.plugin(ToolRegistry) await ctx2.plugin(AgentRegistry) - await ctx2.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) + await ctx2.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('sticky-1') }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) @@ -118,11 +122,12 @@ describe('config-driven session id', () => { let resumed: ReactLoopAgent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(AgentId('main')) as ReactLoopAgent | undefined + resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. + expect(resumed!.id).toBe(SessionId('sticky-1')) expect(resumed!.session.id).toBe('sticky-1') const derived = resumed!.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') @@ -138,16 +143,16 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) + await ctx.plugin(AgentLoop, { agents: [{ id: 'main', model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('x')])) // The deferred resume fails (no such session on disk). It must be contained: - // a warning is logged, no 'main' agent is registered, and the app stays up. + // a warning is logged, no agent is registered, and the app stays up. await new Promise(r => setTimeout(r, 200)) - expect(ctx.agents.get(AgentId('main'))).toBeUndefined() + expect(ctx.agents.list()).toEqual([]) expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 23e5670f85..4e82e398f1 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' -import SessionStore, { TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -40,7 +41,7 @@ describe('inbox acceptance', () => { it('rejects non-serializable content or source synchronously before notification or enqueue', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let queued = 0 ctx.on('agent/queued', () => { queued += 1 }) @@ -80,7 +81,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: typeof args === 'string' ? `raw: ${args}` : JSON.stringify(args) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -113,7 +114,7 @@ describe('tool JSON parse', () => { return [{ type: 'text', text: 'ran with empty args' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -126,7 +127,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from pre-commit dispatch validation via the runLoop backstop', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('internal/dispatch', (_mode, name, args) => { @@ -152,7 +153,7 @@ describe('toError normalization', () => { it('normalizes non-Error throws from agent/request waterfall via inline toError in runStep catch', async () => { const adapter = new MockAdapter([textResponse('irrelevant')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, _next) => { @@ -180,7 +181,7 @@ describe('coded error data emission', () => { it('errorData includes code when a coded error (LlmError) is thrown from a plugin', async () => { const adapter = new MockAdapter([textResponse('turn 1')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/request', async (_agent, _turn, _step, _options, next) => { @@ -214,7 +215,7 @@ describe('disposed vs aborted branching', () => { const ctx = await harness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -242,7 +243,7 @@ describe('structured tool error propagation (the runtime-validation RFC, part 2) textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'boom', description: 'always fails', diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 1ec48e553f..8c95be0d82 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,15 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { - AgentId, - type ContinuationDecision, - type PromptDecision, - type SessionStartSource, -} from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -57,7 +53,7 @@ describe('agent/prompt-submit', () => { it('allow (default via next) records the user/message unchanged', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const seen: string[] = [] ctx.on('agent/prompt-submit', async (_agent, content, _source, next) => { @@ -76,7 +72,7 @@ describe('agent/prompt-submit', () => { it('allow with content REWRITES the prompt before it is recorded', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'allow', content: [{ type: 'text', text: 'REWRITTEN' }] })) @@ -94,7 +90,7 @@ describe('agent/prompt-submit', () => { it('allow with additionalContext injects a separate context/message into the turn', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ @@ -127,7 +123,7 @@ describe('agent/prompt-submit', () => { // elsewhere; this asserts they see each other's effects on the same turn). const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ @@ -157,7 +153,7 @@ describe('agent/prompt-submit', () => { it('block drops the (only) prompt → zero-step turn ends rejected, model never called', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (): Promise => ({ kind: 'block', reason: 'blocked by policy' })) @@ -194,7 +190,7 @@ describe('agent/prompt-submit', () => { // vetoed prompt and its reason would vanish from the log entirely. const adapter = new MockAdapter([textResponse('ran once')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/prompt-submit', async (_agent, content, _source, next): Promise => { const text = content.map(b => (b.type === 'text' ? b.text : '')).join('') @@ -230,7 +226,7 @@ describe('agent/prompt-submit', () => { it('a throwing prompt-submit listener ends the turn balanced (error), loop survives', async () => { const adapter = new MockAdapter([textResponse('after')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threw = false ctx.on('agent/prompt-submit', async () => { @@ -263,7 +259,7 @@ describe('agent/session-start', () => { const sources: SessionStartSource[] = [] ctx.on('agent/session-start', (_agent, source) => void sources.push(source)) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // fires synchronously at create, before any turn expect(sources).toEqual(['startup']) expect(events(agent).some(e => e.type === 'turn/start')).toBe(false) @@ -282,7 +278,7 @@ describe('agent/session-start', () => { agent.inject([{ type: 'text', text: 'session preamble' }], { source: { kind: 'plugin', plugin: 'test' } }) }) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -300,8 +296,8 @@ describe('agent/session-start', () => { ctx.on('agent/session-start', () => { throw new Error('session-start hook broke') }) // create must not throw — the listener error is contained/logged - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - expect(agent.id).toBe(AgentId('a1')) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) + expect(agent.id).toBe(SessionId('a1')) // and the agent still runs send(agent, 'go') @@ -314,8 +310,8 @@ describe('agent/session-prefix', () => { it('dispatches to global and matching agent-scope listeners only', async () => { const adapter = new MockAdapter([textResponse('a done'), textResponse('b done')]) const ctx = await harness(adapter) - const agentA = ctx.agentLoop.create(AgentId('prefix-a'), { model: 'mock' }) - const agentB = ctx.agentLoop.create(AgentId('prefix-b'), { model: 'mock' }) + const agentA = ctx.agentLoop.create(SessionId('prefix-a'), { model: 'mock' }) + const agentB = ctx.agentLoop.create(SessionId('prefix-b'), { model: 'mock' }) const seen: string[] = [] ctx.on('agent/session-prefix', async (agent, _prefix, _signal, next) => { seen.push(`global:${agent.id}`) @@ -352,7 +348,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'catalog' }] } let composed = 0 @@ -385,7 +381,7 @@ describe('agent/session-prefix', () => { it('composes before the first pre-step and hands the prefix to the seam (pressure gates see the real value)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reminder: Message = { role: 'user', content: [{ type: 'text', text: 'opener' }] } const order: string[] = [] @@ -412,7 +408,7 @@ describe('agent/session-prefix', () => { it('the canonical prepend pattern composes contributions in registration order', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Both listeners use the canonical `[mine, ...await next()]` prepend: the // waterfall unwinds innermost-first (the second listener's array is built @@ -434,7 +430,7 @@ describe('agent/session-prefix', () => { it('with no contributions the header omits messagePrefix and the request is the bare derivation', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A listener that delegates without contributing — the canonical no-op. ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next) => next()) @@ -450,7 +446,7 @@ describe('agent/session-prefix', () => { it('the frozen seed rejects in-place mutation — a contribution is a returned extension', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let mutationError: unknown ctx.on('agent/session-prefix', async (_agent, prefix, _signal, next): Promise => { @@ -479,7 +475,7 @@ describe('agent/session-prefix', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const held: Message = { role: 'user', content: [{ type: 'text', text: 'v1' }] } ctx.on('agent/session-prefix', async (_agent, _prefix, _signal, next): Promise => [...await next(), held]) @@ -500,7 +496,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { it('a continue decision with a reason records next-step steering in the same turn', async () => { const adapter = new MockAdapter([textResponse('step 1 no tools'), textResponse('step 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let forced = false ctx.on('agent/turn-continuation', async (_agent, _turn, _default, next): Promise => { @@ -533,7 +529,7 @@ describe('agent/turn-continuation (ContinuationDecision)', () => { name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/turn-continuation', async (): Promise => ({ action: 'stop' })) @@ -563,7 +559,7 @@ describe('tools/post-execute additionalContext buffering across a multi-call ste name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Each call attaches additionalContext naming itself. ctx.on('tools/post-execute', async (exec, _result): Promise => @@ -599,7 +595,7 @@ describe('tools/pre-execute gate (native-plugin permission pattern, end-to-end t name: 'danger', description: 'danger', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('tools/pre-execute', async (exec, next): Promise => { if (exec.name === 'danger') return { kind: 'deny', reason: 'blocked dangerous tool' } @@ -663,7 +659,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se name: 'echo', description: 'echo', parameters: { text: { type: 'string' } }, async execute(args) { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'please echo hi') await waitForIdle(ctx, agent) @@ -686,7 +682,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) await ctx.plugin(NativeGuard) - const agent = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event: SessionEvent) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -705,7 +701,7 @@ describe('worked example: a native hook plugin is just a cordis plugin on the se await fiber.dispose() // After disposal, a destructive prompt is NOT blocked (the listener is gone). - const agent = ctx.agentLoop.create(AgentId('a3'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a3'), { model: 'mock' }) send(agent, 'run rm -rf /') await waitForIdle(ctx, agent) // the prompt ran (not rejected) — proving the prompt-submit listener was disposed diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index 71be5b339e..c4e49e0fa5 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -4,7 +4,8 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -44,7 +45,7 @@ describe('agent loop', () => { it('runs a simple turn: queued message → model → idle, with ordered events', async () => { const adapter = new MockAdapter([textResponse('hello there')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // All boundaries — turn and step — are durable session events on the // session/event feed (no agent/* mirror). Record them in fire order to @@ -92,7 +93,7 @@ describe('agent loop', () => { return [{ type: 'text', text: `echo: ${args.text}` }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -131,7 +132,7 @@ describe('agent loop', () => { return { content: [{ type: 'text', text: 'ok' }], meta: { diffs: [{ path: 'a.txt', oldText: null, newText: 'x' }] } } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -155,7 +156,7 @@ describe('agent loop', () => { return [] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -169,7 +170,6 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter, 'Working in {{cwd}}.') const handle = await ctx.agents.create({ - agentId: AgentId('a-cwd'), sessionId: SessionId('s-cwd'), meta: { cwd: '/work/space' }, agentOptions: { model: 'mock' }, @@ -192,7 +192,7 @@ describe('agent loop', () => { const ctx = await harness(adapter, 'In {{cwd}}.') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -233,7 +233,7 @@ describe('agent loop', () => { ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, model: 'mock' } }) - const agent = ctx.agentLoop.create(AgentId('a-late-model'), {}) + const agent = ctx.agentLoop.create(SessionId('a-late-model'), {}) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -259,7 +259,7 @@ describe('agent loop', () => { parameters: {}, execute: () => Promise.resolve({ content: [{ type: 'text' as const, text: 'apparent success' }], meta }), })) - const agent = ctx.agentLoop.create(AgentId('bad-meta-agent'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-meta-agent'), { model: 'mock' }) send(agent, 'use the tool') await waitForIdle(ctx, agent) @@ -288,7 +288,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.on('system-prompt/assemble', async () => ({ sections: [], tools: [], variables: {} })) - const agent = ctx.agentLoop.create(AgentId('a-no-system'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-no-system'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -300,7 +300,7 @@ describe('agent loop', () => { it('records raw chunks for replay as assistant/chunk session events', async () => { const adapter = new MockAdapter([textResponse('abc')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'hi') await waitForIdle(ctx, agent) @@ -324,7 +324,7 @@ describe('agent loop', () => { ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'slow', description: '', @@ -356,7 +356,7 @@ describe('agent loop', () => { it('steering while idle behaves like send (starts a turn)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.steer([{ type: 'text', text: 'hello' }]) await waitForIdle(ctx, agent) @@ -366,7 +366,7 @@ describe('agent loop', () => { it('inject() while idle wraps context in a one-shot turn, visible to the next request', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.inject([{ type: 'text', text: 'file changed: a.ts' }], { source: { kind: 'plugin', plugin: 'watcher' } }) // The idle inject records a self-contained turn (turn/start → context/message @@ -393,7 +393,7 @@ describe('agent loop', () => { textResponse('done'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // A tool that injects mid-execution: at this point the agent is running, so // inject must append the context/message into the ALREADY-open turn rather // than wrap it in its own one-shot turn. @@ -427,7 +427,7 @@ describe('agent loop', () => { textResponse('step 3'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -453,7 +453,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/turn-continuation', async () => ({ action: 'stop' }) as const) @@ -469,7 +469,7 @@ describe('agent loop', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) ctx.llm.registerAdapter(['other-model'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { // The seed is frozen — config is not a mutable per-call knob; a switch @@ -502,7 +502,7 @@ describe('agent loop', () => { name: 'echo', description: 'echo', parameters: {}, async execute() { return [{ type: 'text', text: 'echoed' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const fires: { turn: number; step: number; fullSystemPrompt: string }[] = [] ctx.on('agent/pre-step', (subject, turn, step, fullSystemPrompt) => { @@ -527,7 +527,7 @@ describe('agent loop', () => { // the derived request for that step (derive happens after step/start). const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let injected = false ctx.on('agent/pre-step', (subject) => { @@ -563,7 +563,7 @@ describe('agent loop', () => { // The loop survives and a follow-up prompt still runs. const adapter = new MockAdapter([textResponse('second turn ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let throwOnce = true ctx.on('agent/pre-step', () => { @@ -597,7 +597,7 @@ describe('agent loop', () => { it('cancel() mid-stream ends the turn with reason aborted', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -617,7 +617,7 @@ describe('agent loop', () => { // turn stops by default and ends max-tokens, not completed. const adapter = new MockAdapter([maxTokensResponse('truncat')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -642,7 +642,7 @@ describe('agent loop', () => { textResponse('second half'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steps = 0 ctx.on('session/event', (_session, event) => { if (event.type === 'step/end') steps++ }) @@ -673,7 +673,7 @@ describe('agent loop', () => { // stop. The per-turn reason must be independent — turn 2 ends completed. const adapter = new MockAdapter([maxTokensResponse('cut'), textResponse('clean')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -706,7 +706,7 @@ describe('agent loop', () => { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -748,7 +748,7 @@ describe('agent loop', () => { parameters: { text: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'should not run' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -767,7 +767,7 @@ describe('agent loop', () => { // on the normal step path suppresses a pure trace-only empty assistant/message. const adapter = new MockAdapter([[{ type: 'finish', reason: { kind: 'stop' } }]]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -797,7 +797,7 @@ describe('agent loop', () => { expect(message.content).toEqual([{ type: 'text', text: 'partial text' }]) return next() }) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -824,7 +824,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threw = false // Post-commit session observers cannot control the loop. The tool call still // drives the second model request, and the turn completes normally. @@ -843,7 +843,7 @@ describe('agent loop', () => { it('chains queued messages into consecutive turns', async () => { const adapter = new MockAdapter([textResponse('first'), textResponse('second')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const turns: number[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/start') turns.push(event.data.turn) }) @@ -868,7 +868,7 @@ describe('agent loop', () => { it('awaits session/flush at turn end (persistence checkpoint)', async () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let flushed = 0 let flushedBeforeIdle = false @@ -888,7 +888,7 @@ describe('agent loop', () => { it('errors from the model surface as agent/error and end the turn', async () => { const adapter = new MockAdapter([]) // script exhausted → throws const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const errors: Error[] = [] const reasons: TurnEndReason[] = [] @@ -913,10 +913,10 @@ describe('agent loop', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) - expect(ctx.agents.get(AgentId('scoped'))).toBe(agent) + expect(ctx.agents.get(SessionId('scoped'))).toBe(agent) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -925,7 +925,7 @@ describe('agent loop', () => { await agent.done expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() expect(() => { send(agent, 'too late') }).toThrow('disposed') }) @@ -938,13 +938,14 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), model: 'mock' }], + agents: [{ id: 'config-agent', model: 'mock' }], }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! as ReactLoopAgent expect(agent).toBeDefined() - expect(agent.id).toBe('config-agent') + expect(agent.id).toBe(agent.session.id) + expect(agent.id).toMatch(/^config-agent-session-/) expect(agent.options.model).toBe('mock') // the agent is alive: send triggers a turn @@ -961,10 +962,10 @@ describe('agent loop', () => { await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { - agents: [{ id: AgentId('config-agent'), model: 'mock', cwd: '/work/project' }], + agents: [{ id: 'config-agent', model: 'mock', cwd: '/work/project' }], }) - const agent = ctx.agents.get(AgentId('config-agent'))! as ReactLoopAgent + const agent = ctx.agents.list()[0]! as ReactLoopAgent expect(agent.session.header.cwd).toBe('/work/project') }) @@ -982,7 +983,7 @@ describe('agent loop', () => { return [{ type: 'text', text: String(args.text) }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'run') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 1e603a1cc4..b3653d6540 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -14,10 +14,11 @@ import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' @@ -95,7 +96,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) const { seen: trace } = recordStatus(ctx, agent) const idle = nextIdle(ctx, agent) // Send all in one synchronous tick: they queue before the loop wakes. @@ -120,7 +121,7 @@ describe('agent loop scheduling properties', () => { async (texts) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) for (const text of texts) { const idle = nextIdle(ctx, agent) agent.send([{ type: 'text', text }]) @@ -145,7 +146,7 @@ describe('agent loop scheduling properties', () => { async (steps) => { const ctx = await harness() try { - const agent = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) // Capture an idle waiter before EACH send; the last one is guaranteed // to resolve because the final send always triggers (or joins) a turn // that ends idle. Awaiting an already-resolved waiter is a no-op, so a diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 5ccaa7e797..7eddb6be39 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -71,7 +72,7 @@ function waitForIdle(context: Context, agent: Agent): Promise { describe.skipIf(!process.env.DEEPSEEK_API_KEY)('log-derived request cache hits (real API)', () => { it('every request after the first hits the provider prefix cache', async () => { ctx = await loopHarness() - const agent = ctx.agentLoop.create(AgentId('cache-e2e'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('cache-e2e'), { model: 'deepseek-v4-flash' }) // Turn 1: forces a tool call → at least two steps (two model requests). agent.send([{ type: 'text', text: 'Look up the key "deploy-color" with the lookup tool and tell me the value.' }]) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 7054b683cb..202a8f6795 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -15,7 +15,8 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -74,7 +75,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) @@ -95,7 +96,7 @@ describe('request stability across the loop', () => { it('a later turn append-extends the previous turn (one conversation, one log)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -109,7 +110,7 @@ describe('request stability across the loop', () => { it('a compaction replace rewrites the resend, and the log explains it', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -142,7 +143,7 @@ describe('request stability across the loop', () => { it('a real system-prompt change is a full changed-header snapshot; a stable prompt logs nothing', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two'), textResponse('three')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -166,7 +167,7 @@ describe('request stability across the loop', () => { it('an inject() during the agent/request waterfall joins the NEXT request (the step/start boundary)', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let injected = false ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { @@ -194,7 +195,7 @@ describe('request stability across the loop', () => { it('a mutation attempt on the frozen request content throws into the step (loud, not silent)', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -215,7 +216,7 @@ describe('request stability across the loop', () => { it('a fresh loop instance over a seeded log anchors with a resume snapshot and stays cache-aligned', async () => { const adapter = new MockAdapter([textResponse('one')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('gen1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('gen1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -224,7 +225,6 @@ describe('request stability across the loop', () => { const adapter2 = new MockAdapter([textResponse('two')]) const ctx2 = await harness(adapter2) const handle = await ctx2.agents.create({ - agentId: AgentId('gen2'), sessionId: SessionId('gen2-session'), seed: [...agent.session.events], agentOptions: { model: 'mock' }, @@ -244,7 +244,7 @@ describe('request stability across the loop', () => { it('a delegating listener cannot mutate the seed through next() — the fold stays log-true', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/request', async (_agent, _turn, _step, _config, next) => { const config = await next() @@ -278,7 +278,7 @@ describe('request stability across the loop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await waitForIdle(ctx, agent) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 93605008ec..f5809575ab 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,7 +8,8 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -83,11 +84,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => throwUnknown(failure)) await expect(ctx.agents.resume({ - agentId: AgentId('unknown-resume-failure'), resumeSessionId: sessionId, })).rejects.toBe(failure) - expect(ctx.agents.get(AgentId('unknown-resume-failure'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-resume-failure'))).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() await ctx.fiber.dispose() }) @@ -95,27 +95,26 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('createAgent uses the caller-supplied sessionId (not ${id}-session)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('custom-session'), meta: { cwd: '/w' } }) expect(agent.session.id).toBe('custom-session') expect(agent.session.header.cwd).toBe('/w') await ctx.fiber.dispose() }) - it('createAgent rejects a duplicate agent id BEFORE creating the session (no orphan)', async () => { + it('createAgent rejects a duplicate identity without orphaning a session', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - await ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-a') }) - // A second create with the SAME agent id but a fresh session id must reject - // up front — and must NOT leave an orphaned 'sess-b' session behind. - await expect(ctx.agents.create({ agentId: AgentId('dup'), sessionId: SessionId('sess-b') })).rejects.toThrow(/already registered/) - expect(ctx.sessions.get(SessionId('sess-b'))).toBeUndefined() + const sessionId = SessionId('sess-a') + await ctx.agents.create({ sessionId }) + await expect(ctx.agents.create({ sessionId })).rejects.toThrow(/already exists/) + expect(ctx.sessions.list()).toHaveLength(1) await ctx.fiber.dispose() }) it('createAgent works without meta (no cwd)', async () => { const adapter = new MockAdapter([textResponse('hi')]) const { ctx } = await persistentHarness(adapter) - const { agent } = await ctx.agents.create({ agentId: AgentId('a-nometa'), sessionId: SessionId('nometa-session') }) + const { agent } = await ctx.agents.create({ sessionId: SessionId('nometa-session') }) expect(agent.session.id).toBe('nometa-session') expect(agent.session.header.cwd).toBeUndefined() await ctx.fiber.dispose() @@ -125,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -141,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -152,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ agentId: AgentId('s'), sessionId: SessionId('start-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -171,7 +170,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx2.llm.registerAdapter(['mock'], adapter2) const sources2: string[] = [] ctx2.on('agent/session-start', (_agent, source) => void sources2.push(source)) - await ctx2.agents.resume({ agentId: AgentId('s'), resumeSessionId: SessionId('start-sess') }) + await ctx2.agents.resume({ resumeSessionId: SessionId('start-sess') }) expect(sources2).toEqual(['resume']) await ctx2.fiber.dispose() }) @@ -186,7 +185,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('resumed-atomic'))?.session).toBe(session) + expect(ctx.agents.get(sessionId)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', (agent) => { @@ -199,11 +198,10 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) const resuming = ctx.agents.resume({ - agentId: AgentId('resumed-atomic'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('resumed-atomic')) + expect(agentCtx.agent?.id).toBe(sessionId) expect(agentCtx.agent?.session.events).toHaveLength(2) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) @@ -215,7 +213,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { }) await setupStarted.promise - expect(ctx.agents.get(AgentId('resumed-atomic'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() expect(order).toEqual(['setup:start']) @@ -236,17 +234,15 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('successful resume disposal retires its caller-owned transaction effects', async () => { const sessionId = SessionId('resume-retired-effects-s') - const agentId = AgentId('resume-retired-effects') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const handle = await ctx.agents.resume({ - agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' }, }) const transactionLabels = [ - `agentLoop.owner(${agentId})`, - `agentLoop.lifecycle(${agentId})`, + `agentLoop.owner(${sessionId})`, + `agentLoop.lifecycle(${sessionId})`, ] expect(ctx.fiber.getEffects().map(effect => effect.label)).toEqual(expect.arrayContaining(transactionLabels)) @@ -255,7 +251,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('resume setup rejection publishes nothing, unwinds, and releases both identities', async () => { + it('resume setup rejection publishes nothing, unwinds, and releases the identity', async () => { const sessionId = SessionId('resume-setup-reject') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) @@ -265,7 +261,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, setup: async () => { @@ -275,10 +270,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { })).rejects.toThrow('resume setup failed') expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-reject'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() const retry = await ctx.agents.resume({ - agentId: AgentId('resume-reject'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, }) @@ -299,7 +293,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { resuming = inner.agents.resume({ - agentId: AgentId('resume-owner-race'), resumeSessionId: sessionId, agentOptions: { model: 'mock' }, setup: async () => { @@ -313,7 +306,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await owner.dispose() await expect(resuming).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('resume-owner-race'))).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() gate.resolve(undefined) @@ -322,9 +315,8 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.fiber.dispose() }) - it('owner unload aborts a never-settling persistence load, releases identities, and blocks late publication', async () => { + it('owner unload aborts a never-settling persistence load, releases the identity, and blocks late publication', async () => { const sessionId = SessionId('resume-load-owner-unload') - const agentId = AgentId('resume-load-race') const root = await persistSession(sessionId) const ctx = await mountPersistentHarness(root, new MockAdapter([textResponse('next')])) const snapshot = await ctx.sessionPersistence.load(sessionId) @@ -348,19 +340,19 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { let resuming!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { - resuming = inner.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) + resuming = inner.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/owner disposed during setup/) await promptly(owner.dispose()) expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - // owner.dispose() awaited transaction settlement, so the same identities - // can be reused before awaiting the public rejection. - const retry = await promptly(ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } })) + // owner.dispose() awaited transaction settlement, so the identity can be + // reused before awaiting the public rejection. + const retry = await promptly(ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } })) await rejection expect(loads).toBe(2) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -370,7 +362,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() await Promise.resolve() - expect(ctx.agents.get(agentId)).toBe(retry.agent) + expect(ctx.agents.get(sessionId)).toBe(retry.agent) expect(ctx.sessions.get(sessionId)).toBe(retry.agent.session) expect(published).toEqual(['session/created', 'agent/created', 'agent/session-start']) @@ -380,7 +372,6 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { it('AgentLoop unload aborts persistence load and awaits wrapper settlement', async () => { const sessionId = SessionId('resume-load-factory-unload') - const agentId = AgentId('resume-load-factory-race') const root = await persistSession(sessionId) const ctx = new Context() await ctx.plugin(LlmService) @@ -404,14 +395,14 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ctx.on('session/created', () => void published.push('session/created')) ctx.on('agent/created', () => void published.push('agent/created')) - const resuming = ctx.agents.resume({ agentId, resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) + const resuming = ctx.agents.resume({ resumeSessionId: sessionId, agentOptions: { model: 'mock' } }) await loadStarted.promise const rejection = expect(promptly(resuming)).rejects.toThrow(/agent loop is not active/) await promptly(loopFiber.dispose()) await rejection expect(published).toEqual([]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() lateLoad.resolve(structuredClone(snapshot)) await Promise.resolve() @@ -452,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) @@ -466,7 +457,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -491,7 +482,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('m'), sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -509,7 +500,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -519,7 +510,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ agentId: AgentId('main'), sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -539,7 +530,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ agentId: AgentId('main'), resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) @@ -567,7 +558,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) ctx.llm.registerAdapter(['mock'], adapter) - await expect(ctx.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('nope') })) + await expect(ctx.agents.resume({ resumeSessionId: SessionId('nope') })) .rejects.toThrow(/session persistence is not configured/) await ctx.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 5898a82ee2..942199e9eb 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -4,7 +4,8 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -53,7 +54,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( return [{ type: 'text', text: 'ran' }] }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Plugin rewrites the message: replaces the text AND adds a tool call. let rewritten = false @@ -104,7 +105,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { ]) const ctx = await harness(adapter) const executed: string[] = [] - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'aborter', description: '', @@ -148,7 +149,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { textResponse('continued because of steering'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('agent/turn-continuation', async (_agent, _turn, _decision, next) => { @@ -188,7 +189,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { textResponse('after goal reminder'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let steeredOnce = false ctx.on('session/event', (subject, event) => { @@ -218,7 +219,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { it('steer() from a turn/end session-event listener becomes a queued message for the next turn', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) const turns: number[] = [] let steeredOnce = false @@ -244,7 +245,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { it('steering queued during an aborted step is re-delivered, not silently consumed', async () => { const adapter = new MockAdapter(['hang', textResponse('recovered')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) @@ -267,7 +268,7 @@ describe('HIGH: plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let threwOnce = false ctx.on('agent/turn-continuation', async (): Promise => { @@ -295,7 +296,7 @@ describe('HIGH: plugin exceptions are contained', () => { it('a rejecting session/flush listener is reported but does not kill the agent', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) let rejectedOnce = false ctx.on('session/flush', async () => { @@ -325,7 +326,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const statuses: string[] = [] @@ -348,7 +349,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('scoped'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) ctx.on('agent/status', (_agent, status) => { @@ -361,7 +362,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { await agent.done // must not hang expect(agent.status).toBe('disposed') - expect(ctx.agents.get(AgentId('scoped'))).toBeUndefined() // unregistered despite the throw + expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw }) }) @@ -380,7 +381,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('an agent without a model fails the step with a clear error (not NO_ADAPTER for "default")', async () => { const adapter = new MockAdapter([textResponse('never')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) @@ -395,7 +396,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('the agent/request waterfall can supply the model for a model-less agent', async () => { const adapter = new MockAdapter([textResponse('routed')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), {}) // no model — router plugin decides + const agent = ctx.agentLoop.create(SessionId('a1'), {}) // no model — router plugin decides ctx.on('agent/request', async (_agent, _turn, _step, config, _next) => { return { ...config, model: 'mock' } @@ -410,7 +411,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.tools.register(defineTool({ name: 'noop', description: '', @@ -438,7 +439,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('send() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-send'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-send'), { model: 'mock' }) const content = [{ type: 'text' as const, text: 'accepted-send' }] const source = { kind: 'plugin' as const, plugin: 'accepted-source' } let notifiedContent: ContentBlock[] | undefined @@ -474,7 +475,7 @@ describe('MEDIUM: misc registry and config fixes', () => { it('running steer() owns content and source before notification and delivery', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'gate', {}), textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('owned-steer'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-steer'), { model: 'mock' }) const entered = Promise.withResolvers() const release = Promise.withResolvers() ctx.tools.register(defineTool({ @@ -528,7 +529,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) send(agent, 'first') await waitForIdle(ctx, agent) @@ -544,7 +545,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () ctx2.llm.registerAdapter(['mock'], second) const seeded = ctx2.sessions.create(SessionId('forked'), { seed: [...agent.session.events] }) - const prepared = prepareReactLoopAgent(ctx2, AgentId('forked-agent'), { model: 'mock' }, seeded) + const prepared = prepareReactLoopAgent(ctx2, SessionId('forked-agent'), { model: 'mock' }, seeded) const forked = prepared.agent prepared.markPublished() ctx2.effect(() => prepared.startDriver()) @@ -591,7 +592,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-error'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -616,7 +617,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([abortedStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-aborted'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-aborted'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -634,7 +635,7 @@ describe('HIGH: a finish-error stream chunk ends the turn as error, not complete ] const adapter = new MockAdapter([errorStream]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-finish-error-nocode'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-finish-error-nocode'), { model: 'mock' }) const reasons: TurnEndReason[] = [] ctx.on('session/event', (_s, event) => { if (event.type === 'turn/end') reasons.push(event.data.reason) }) @@ -650,7 +651,7 @@ describe('step boundary publication order', () => { it('the step/start event is in session.events when its session/event listener fires', async () => { const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-step-order'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-step-order'), { model: 'mock' }) // Session.append pushes the event BEFORE notifying session/event listeners, // so a step/start listener always finds the matching event already in the @@ -711,7 +712,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/start observer cannot change a successful turn', async () => { const adapter = new MockAdapter([textResponse('request completed')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart'), { model: 'mock' }) // Session owns post-commit containment. The loop sees a successful append, // runs the request, and balances the ordinary step and turn boundaries. @@ -740,7 +741,7 @@ describe('turn and step boundary recovery', () => { it('a pre-commit step/start validation failure does not invent a step boundary', async () => { const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepstart-veto'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepstart-veto'), { model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -771,7 +772,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider failed' } }] const adapter = new MockAdapter([errorStream]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnend-veto'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnend-veto'), { model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -805,7 +806,7 @@ describe('turn and step boundary recovery', () => { it('a one-shot step/end validation failure keeps the step open until retry succeeds', async () => { const adapter = new MockAdapter([textResponse('completed before close validation')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-veto'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-veto'), { model: 'mock' }) let rejected = false ctx.on('internal/dispatch', (_mode, name, args) => { if (name !== 'session/event') return @@ -839,7 +840,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-errorlistener'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-errorlistener'), { model: 'mock' }) let threw = false ctx.on('agent/error', () => { if (!threw) { threw = true; throw new Error('boom error-listener') } }) @@ -872,7 +873,7 @@ describe('turn and step boundary recovery', () => { const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -901,7 +902,7 @@ describe('turn and step boundary recovery', () => { const ctx = await balancedHarness(adapter) let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-prestep-dispose-throw'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) let threw = false @@ -935,7 +936,7 @@ describe('turn and step boundary recovery', () => { it('a throwing turn/start observer cannot starve the loop or later turns', async () => { const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-preturn'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-preturn'), { model: 'mock' }) let threw = false ctx.on('session/event', (_session, event) => { @@ -966,7 +967,7 @@ describe('turn and step boundary recovery', () => { it('a throwing step/end observer cannot rewrite the turn outcome', async () => { const adapter = new MockAdapter([textResponse('all good'), textResponse('turn 2 ok')]) const ctx = await balancedHarness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stepend-throw'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stepend-throw'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1008,7 +1009,7 @@ describe('turn and step boundary recovery', () => { const errorStream: StreamChunk[] = [{ type: 'finish', reason: { kind: 'error', message: 'provider 500' } }] const adapter = new MockAdapter([errorStream, textResponse('turn 2 ok')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-stependthrow'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-stependthrow'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1038,7 +1039,7 @@ describe('turn and step boundary recovery', () => { // boundary stays authoritative and the loop continues normally. const adapter = new MockAdapter([textResponse('turn 1'), textResponse('turn 2')]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('a-turnendappend'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-turnendappend'), { model: 'mock' }) let threw = false ctx.on('session/event', (_s, event) => { @@ -1085,7 +1086,7 @@ describe('tool result call identity', () => { return Promise.resolve({ kind: 'accept', content: [{ type: 'text', text: 'ok' }] }) }, { prepend: true }) - const agent = ctx.agentLoop.create(AgentId('a-callid'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a-callid'), { model: 'mock' }) send(agent, 'use tool') await waitForIdle(ctx, agent) @@ -1120,7 +1121,7 @@ describe('surface: assistant/message omits sourceEventSeqs when no chunks stream const adapter = new MockAdapter([[]]) const ctx = await harness(adapter) await ctx.plugin(Invariants) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) ctx.on('agent/step-result', async (_agent, _turn, _step, _message, _next) => ({ role: 'assistant' as const, @@ -1171,7 +1172,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-assemble'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1227,7 +1228,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-assemble'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1282,7 +1283,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-prestep'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1334,7 +1335,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-cancel-prestep'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) const reasons: TurnEndReason[] = [] @@ -1384,7 +1385,7 @@ describe('disposal and cancellation during pre-step assembly', () => { let agent!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - agent = inner.agentLoop.create(AgentId('a-dispose-no-leak'), { model: 'mock' }) + agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 2c478ad1f0..c68d96e525 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -4,7 +4,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh-agent' + import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' @@ -57,33 +58,31 @@ function disposeCurrentLifecycle(ownerCtx: Context): void { } describe('agent scope lifecycle', () => { - it('rejects an already-aborted creation signal before publishing either identity', async () => { + it('rejects an already-aborted creation signal before publishing either object', async () => { const ctx = await harness() const reason = new Error('cancelled before creation') const controller = new AbortController() controller.abort(reason) await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted'), sessionId: SessionId('pre-aborted-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('pre-aborted'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-s'))).toBeUndefined() const valueController = new AbortController() valueController.abort('plain cancellation reason') await expect(ctx.agents.create({ - agentId: AgentId('pre-aborted-value'), sessionId: SessionId('pre-aborted-value-s'), signal: valueController.signal, })).rejects.toMatchObject({ - message: 'agent "pre-aborted-value" creation aborted', + message: 'agent "pre-aborted-value-s" creation aborted', cause: 'plain cancellation reason', }) - expect(ctx.agents.get(AgentId('pre-aborted-value'))).toBeUndefined() + expect(ctx.agents.get(SessionId('pre-aborted-value-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('pre-aborted-value-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -100,12 +99,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('prepare-abort'), sessionId: SessionId('prepare-abort-s'), signal: controller.signal, })).rejects.toBe(reason) - expect(ctx.agents.get(AgentId('prepare-abort'))).toBeUndefined() + expect(ctx.agents.get(SessionId('prepare-abort-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('prepare-abort-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -124,7 +122,7 @@ describe('agent scope lifecycle', () => { thrown = createFailure let createCaught: unknown try { - ctx.agentLoop.create(AgentId('unknown-create')) + ctx.agentLoop.create(SessionId('unknown-create')) } catch (error: unknown) { createCaught = error } @@ -133,28 +131,27 @@ describe('agent scope lifecycle', () => { const ownedFailure = { source: 'createAgent' } thrown = ownedFailure await expect(ctx.agents.create({ - agentId: AgentId('unknown-owned-create'), sessionId: SessionId('unknown-owned-create-s'), })).rejects.toBe(ownedFailure) - expect(ctx.agents.get(AgentId('unknown-create'))).toBeUndefined() - expect(ctx.agents.get(AgentId('unknown-owned-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-create'))).toBeUndefined() + expect(ctx.agents.get(SessionId('unknown-owned-create-s'))).toBeUndefined() await ctx.fiber.dispose() }) it('wires agent.ctx: tagged with the agent, DX field set, ctx.agent safe elsewhere', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) expect(scopeOf(agent.ctx)).toBe(agent) expect(agent.ctx.agent).toBe(agent) // The root accessor default: a plain context answers undefined, not a throw. expect(ctx.agent).toBeUndefined() - await ctx.agents.get(AgentId('a1'))?.whenIdle() + await ctx.agents.get(SessionId('a1'))?.whenIdle() }) it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) const { agent } = handle agent.ctx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: 'You run tests.' }) agent.ctx.tools.register({ @@ -179,8 +176,8 @@ describe('agent scope lifecycle', () => { it('agent.ctx listeners hear only their own agent (scoped dispatch end to end)', async () => { const ctx = await harness(new MockAdapter([textResponse('one'), textResponse('two')])) - const a = ctx.agentLoop.create(AgentId('a'), { model: 'mock' }) - const b = ctx.agentLoop.create(AgentId('b'), { model: 'mock' }) + const a = ctx.agentLoop.create(SessionId('a'), { model: 'mock' }) + const b = ctx.agentLoop.create(SessionId('b'), { model: 'mock' }) const heard: string[] = [] a.ctx.on('agent/status', (subject, status) => void heard.push(`a-sees:${subject.id}:${status}`)) @@ -210,7 +207,6 @@ describe('agent scope lifecycle', () => { }) const handle = await ctx.agents.create({ - agentId: AgentId('child'), sessionId: SessionId('child-s'), agentOptions: { model: 'mock' }, setup: async (agentCtx) => { @@ -224,14 +220,14 @@ describe('agent scope lifecycle', () => { await handle.dispose() }) - it('keeps both identities unpublished until async setup completes, then announces in order', async () => { + it('keeps both objects unpublished until async setup completes, then announces in order', async () => { const ctx = await harness() const gate = Promise.withResolvers() const setupStarted = Promise.withResolvers() const order: string[] = [] ctx.on('session/created', (session) => { expect(ctx.sessions.get(session.id)).toBe(session) - expect(ctx.agents.get(AgentId('atomic'))?.session).toBe(session) + expect(ctx.agents.get(session.id)?.session).toBe(session) order.push('session/created') }) ctx.on('agent/created', () => void order.push('agent/created')) @@ -239,11 +235,10 @@ describe('agent scope lifecycle', () => { const acceptedOptions = { model: 'mock' } const creating = ctx.agents.create({ - agentId: AgentId('atomic'), - sessionId: SessionId('atomic-s'), + sessionId: SessionId('atomic'), agentOptions: acceptedOptions, setup: async (agentCtx) => { - expect(agentCtx.agent?.id).toBe(AgentId('atomic')) + expect(agentCtx.agent?.id).toBe(SessionId('atomic')) agentCtx.on('session/created', () => void order.push('setup-listener:session/created')) agentCtx.on('agent/created', () => void order.push('setup-listener:agent/created')) order.push('setup:start') @@ -253,7 +248,7 @@ describe('agent scope lifecycle', () => { }, }) await setupStarted.promise - expect(ctx.agents.get(AgentId('atomic'))).toBeUndefined() + expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() expect(order).toEqual(['setup:start']) gate.resolve(undefined) @@ -281,16 +276,14 @@ describe('agent scope lifecycle', () => { if (started === 2) bothStarted.resolve(undefined) await gate.promise } - const agentId = AgentId('concurrent-final-enter') + const sessionId = SessionId('concurrent-final-enter') const first = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-a'), + sessionId, agentOptions: { model: 'mock' }, setup, }) const second = ctx.agents.create({ - agentId, - sessionId: SessionId('concurrent-final-enter-b'), + sessionId, agentOptions: { model: 'mock' }, setup, }) @@ -304,7 +297,7 @@ describe('agent scope lifecycle', () => { const rejected = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') expect(fulfilled).toHaveLength(1) expect(rejected).toHaveLength(1) - expect(String(rejected[0]!.reason)).toMatch(/already registered/) + expect(String(rejected[0]!.reason)).toMatch(/already exists/) expect(ctx.agents.list()).toEqual([fulfilled[0]!.value.agent]) expect(ctx.sessions.list()).toEqual([fulfilled[0]!.value.agent.session]) @@ -318,7 +311,6 @@ describe('agent scope lifecycle', () => { const pendingController = new AbortController() const setupStarted = Promise.withResolvers() const pending = ctx.agents.create({ - agentId: AgentId('signal-pending'), sessionId: SessionId('signal-pending-s'), agentOptions: { model: 'mock' }, signal: pendingController.signal, @@ -330,12 +322,11 @@ describe('agent scope lifecycle', () => { await setupStarted.promise pendingController.abort(new Error('cancel pending creation')) await expect(pending).rejects.toThrow('cancel pending creation') - expect(ctx.agents.get(AgentId('signal-pending'))).toBeUndefined() + expect(ctx.agents.get(SessionId('signal-pending-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('signal-pending-s'))).toBeUndefined() const liveController = new AbortController() const live = await ctx.agents.create({ - agentId: AgentId('signal-live'), sessionId: SessionId('signal-live-s'), agentOptions: { model: 'mock' }, signal: liveController.signal, @@ -358,7 +349,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('owner-race'), sessionId: SessionId('owner-race-s'), agentOptions: { model: 'mock' }, setup: async () => { @@ -372,7 +362,7 @@ describe('agent scope lifecycle', () => { await owner.dispose() await expect(creating).rejects.toThrow(/owner disposed during setup/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('owner-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s'))).toBeUndefined() // Let the losing callback settle; Promise.race already observes it. gate.resolve(undefined) @@ -386,7 +376,6 @@ describe('agent scope lifecycle', () => { let creating2!: ReturnType const owner2 = await ctx.plugin(Object.assign((inner: Context) => { creating2 = inner.agents.create({ - agentId: AgentId('owner-race-2'), sessionId: SessionId('owner-race-s-2'), agentOptions: { model: 'mock' }, setup: async () => { @@ -400,7 +389,7 @@ describe('agent scope lifecycle', () => { const unload2 = owner2.dispose() await expect(creating2).rejects.toThrow(/owner disposed during setup/) await unload2 - expect(ctx.agents.get(AgentId('owner-race-2'))).toBeUndefined() + expect(ctx.agents.get(SessionId('owner-race-s-2'))).toBeUndefined() expect(ctx.sessions.get(SessionId('owner-race-s-2'))).toBeUndefined() }) @@ -413,7 +402,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) const creating = ctx.agents.create({ - agentId: AgentId('factory-setup-race'), sessionId: SessionId('factory-setup-race-s'), agentOptions: { model: 'mock' }, setup: async () => { @@ -426,7 +414,7 @@ describe('agent scope lifecycle', () => { await loopFiber.dispose() await expect(creating).rejects.toThrow(/agent loop is not active/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('factory-setup-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-setup-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-setup-race-s'))).toBeUndefined() gate.resolve(undefined) @@ -444,7 +432,6 @@ describe('agent scope lifecycle', () => { }) const creating = ctx.agents.create({ - agentId: AgentId('factory-scope-race'), sessionId: SessionId('factory-scope-race-s'), agentOptions: { model: 'mock' }, setup: () => { setupCalls += 1 }, @@ -452,7 +439,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/agent loop is not active/) await loopFiber.dispose() expect(setupCalls).toBe(0) - expect(ctx.agents.get(AgentId('factory-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-race-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -479,7 +466,6 @@ describe('agent scope lifecycle', () => { const owner = ctx.plugin(Object.assign((inner: Context) => { ownerFiber = inner.fiber creating = inner.agents.create({ - agentId: AgentId('caller-scope-race'), sessionId: SessionId('caller-scope-race-s'), agentOptions: { model: 'mock' }, }) @@ -495,7 +481,7 @@ describe('agent scope lifecycle', () => { await ownerDisposal await owner expect(scopeFiber?.uid).toBeNull() - expect(ctx.agents.get(AgentId('caller-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('caller-scope-race-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('caller-scope-race-s'))).toBeUndefined() await owner.dispose() await ctx.fiber.dispose() @@ -511,17 +497,17 @@ describe('agent scope lifecycle', () => { void loopFiber.dispose() }) - expect(() => ctx.agentLoop.create(AgentId('config-scope-race'), { model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-scope-race'), { model: 'mock' })) .toThrow(/agent loop is not active/) await loopFiber.dispose() - expect(ctx.agents.get(AgentId('config-scope-race'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-scope-race'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) await ctx.fiber.dispose() }) it('synchronous create leaves no lifecycle state when session preparation fails', async () => { const ctx = await harness() - const id = AgentId('config-prepare-failure') + const id = SessionId('config-prepare-failure') expect(() => ctx.agentLoop.create(id, { model: 'mock' }, { cwd: 'relative' })) .toThrow(/absolute path/) @@ -542,12 +528,11 @@ describe('agent scope lifecycle', () => { }) await expect(ctx.agents.create({ - agentId: AgentId('factory-scope-throw'), sessionId: SessionId('factory-scope-throw-s'), agentOptions: { model: 'mock' }, })).rejects.toThrow('scope preparation failed') await loopFiber.dispose() - expect(ctx.agents.get(AgentId('factory-scope-throw'))).toBeUndefined() + expect(ctx.agents.get(SessionId('factory-scope-throw-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('factory-scope-throw-s'))).toBeUndefined() await ctx.fiber.dispose() @@ -556,23 +541,21 @@ describe('agent scope lifecycle', () => { it('AgentLoop unload is a structural co-owner of every live programmatic agent', async () => { const { ctx, loopFiber } = await harnessWithLoop() const loop = ctx.agentLoop - const agentId = AgentId('factory-live') + const sessionId = SessionId('factory-live') const handle = await ctx.agents.create({ - agentId, - sessionId: SessionId('factory-live-s'), + sessionId, agentOptions: { model: 'mock' }, }) await loopFiber.dispose() expect(handle.agent.status).toBe('disposed') - expect(ctx.agents.get(agentId)).toBeUndefined() - expect(ctx.sessions.get(SessionId('factory-live-s'))).toBeUndefined() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(ctx.sessions.get(sessionId)).toBeUndefined() + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) // The consumer handle shares the provider's completed quiescence boundary. await handle.dispose() await expect(loop.createAgent(ctx, { - agentId: AgentId('factory-inactive'), sessionId: SessionId('factory-inactive-s'), })).rejects.toThrow('agent loop is not active') await ctx.fiber.dispose() @@ -583,7 +566,6 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType const owner = await ctx.plugin(Object.assign((inner: Context) => { creating = inner.agents.create({ - agentId: AgentId('dependency-origin'), sessionId: SessionId('dependency-origin-s'), agentOptions: { model: 'mock' }, setup: (agentCtx) => { @@ -623,7 +605,7 @@ describe('agent scope lifecycle', () => { }) ctx.on('session/created', (session) => { if (session.id !== SessionId('session-created-barrier-s')) return - const agent = ctx.agents.get(AgentId('session-created-barrier'))! + const agent = ctx.agents.get(SessionId('session-created-barrier-s'))! expect(ctx.sessions.get(session.id)).toBe(session) expect(agent.session).toBe(session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) @@ -638,7 +620,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-created-barrier'), sessionId: SessionId('session-created-barrier-s'), agentOptions: { model: 'mock' }, }) @@ -652,7 +633,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('session-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -666,19 +647,19 @@ describe('agent scope lifecycle', () => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-created') }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return lifecycle.push('agent-created:dispose') disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/created', (agent) => { - if (agent.id !== AgentId('agent-created-barrier')) return + if (agent.id !== SessionId('agent-created-barrier-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { lifecycle.push('scope-disposed') }) lifecycle.push('agent-created:observer') }) ctx.on('agent/disposed', (agent) => { - if (agent.id === AgentId('agent-created-barrier')) lifecycle.push('agent-disposed') + if (agent.id === SessionId('agent-created-barrier-s')) lifecycle.push('agent-disposed') }) ctx.on('session/disposed', (session) => { if (session.id === SessionId('agent-created-barrier-s')) lifecycle.push('session-disposed') @@ -687,7 +668,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('agent-created-barrier'), sessionId: SessionId('agent-created-barrier-s'), agentOptions: { model: 'mock' }, }) @@ -703,7 +683,7 @@ describe('agent scope lifecycle', () => { 'session-disposed', 'scope-disposed', ]) - expect(ctx.agents.get(AgentId('agent-created-barrier'))).toBeUndefined() + expect(ctx.agents.get(SessionId('agent-created-barrier-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('agent-created-barrier-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -715,13 +695,12 @@ describe('agent scope lifecycle', () => { let creating!: ReturnType ctx.on('agent/session-start', agent => void starts.push(agent.id)) ctx.on('agent/created', (agent) => { - if (agent.id === AgentId('listener-dispose')) void ownerCtx.fiber.dispose() + if (agent.id === SessionId('listener-dispose-s')) void ownerCtx.fiber.dispose() }) const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('listener-dispose'), sessionId: SessionId('listener-dispose-s'), agentOptions: { model: 'mock' }, }) @@ -730,7 +709,7 @@ describe('agent scope lifecycle', () => { await expect(creating).rejects.toThrow(/owner disposed during setup/) await owner.dispose() expect(starts).toEqual([]) - expect(ctx.agents.get(AgentId('listener-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('listener-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('listener-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -744,15 +723,15 @@ describe('agent scope lifecycle', () => { let scopeDisposed = false let observerSawLive = false ctx.on('agent/status', (agent, status) => { - if (agent.id === AgentId('session-start-dispose')) statuses.push(status) + if (agent.id === SessionId('session-start-dispose-s')) statuses.push(status) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return + if (agent.id !== SessionId('session-start-dispose-s')) return announced = agent as ReactLoopAgent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { - if (agent.id !== AgentId('session-start-dispose')) return + if (agent.id !== SessionId('session-start-dispose-s')) return expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.sessions.get(agent.session.id)).toBe(agent.session) agent.ctx.effect(() => () => { scopeDisposed = true }) @@ -762,7 +741,6 @@ describe('agent scope lifecycle', () => { const owner = await ctx.plugin(Object.assign((inner: Context) => { ownerCtx = inner creating = inner.agents.create({ - agentId: AgentId('session-start-dispose'), sessionId: SessionId('session-start-dispose-s'), agentOptions: { model: 'mock' }, }) @@ -775,7 +753,7 @@ describe('agent scope lifecycle', () => { expect(observerSawLive).toBe(true) expect(scopeDisposed).toBe(true) expect(announced.session.events).toEqual([]) - expect(ctx.agents.get(AgentId('session-start-dispose'))).toBeUndefined() + expect(ctx.agents.get(SessionId('session-start-dispose-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('session-start-dispose-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -787,7 +765,6 @@ describe('agent scope lifecycle', () => { ctx.on('agent/created', () => void published.push('agent/created')) ctx.on('agent/session-start', () => void published.push('agent/session-start')) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, setup: async () => { @@ -798,13 +775,13 @@ describe('agent scope lifecycle', () => { // Nothing leaked: no agent, no session, and the ids are reusable. expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) await retry.dispose() }) - it('rejects an exotic durable seed before publishing either identity', async () => { + it('rejects an exotic durable seed before publishing either object', async () => { const ctx = await harness() const published: string[] = [] ctx.on('session/created', () => { published.push('session') }) @@ -817,17 +794,15 @@ describe('agent scope lifecycle', () => { }] as unknown as SessionEvent[] await expect(ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { model: 'mock' }, seed, })).rejects.toThrow(/seed event at index 0 is not losslessly JSON-serializable/) expect(published).toEqual([]) - expect(ctx.agents.get(AgentId('exotic-seed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('exotic-seed-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('exotic-seed-session'))).toBeUndefined() const retry = await ctx.agents.create({ - agentId: AgentId('exotic-seed'), sessionId: SessionId('exotic-seed-session'), agentOptions: { model: 'mock' }, }) @@ -843,13 +818,13 @@ describe('agent scope lifecycle', () => { if (boom) { boom = false; throw new Error('boom created') } }) await expect(ctx.agents.create({ - agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, + sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' }, })).rejects.toThrow('boom created') - expect(ctx.agents.get(AgentId('bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('bad-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('bad-s'))).toBeUndefined() expect(disposed).toEqual([]) // inserted but never announced: no impossible disposed edge // The rollback also disposed the scope fiber: re-creating works cleanly. - const retry = await ctx.agents.create({ agentId: AgentId('bad'), sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) + const retry = await ctx.agents.create({ sessionId: SessionId('bad-s'), agentOptions: { model: 'mock' } }) expect(scopeOf(retry.agent.ctx)).toBe(retry.agent) await retry.dispose() }) @@ -866,18 +841,17 @@ describe('agent scope lifecycle', () => { ctx.on('agent/disposed', (agent) => { lifecycle.push(`agent-disposed:${agent.id}`) }) await expect(ctx.agents.create({ - agentId: AgentId('partial-agent'), sessionId: SessionId('partial-session'), agentOptions: { model: 'mock' }, })).rejects.toThrow('agent observer failed') expect(lifecycle).toEqual([ 'session-created:partial-session', - 'agent-created:partial-agent', - 'agent-disposed:partial-agent', + 'agent-created:partial-session', + 'agent-disposed:partial-session', 'session-disposed:partial-session', ]) - expect(ctx.agents.get(AgentId('partial-agent'))).toBeUndefined() + expect(ctx.agents.get(SessionId('partial-session'))).toBeUndefined() expect(ctx.sessions.get(SessionId('partial-session'))).toBeUndefined() }) @@ -892,23 +866,23 @@ describe('agent scope lifecycle', () => { } }) - expect(() => ctx.agentLoop.create(AgentId('config-bad'), { model: 'mock' })) + expect(() => ctx.agentLoop.create(SessionId('config-bad'), { model: 'mock' })) .toThrow('config publish failed') - expect(ctx.agents.get(AgentId('config-bad'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-bad'))).toBeUndefined() expect(ctx.sessions.list()).toHaveLength(sessionsBefore) }) it('registrations through a disposed agent ctx throw INACTIVE_EFFECT', async () => { const ctx = await harness() - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) await handle.dispose() expect(() => handle.agent.ctx.on('agent/status', () => {})).toThrow(/inactive context/) }) it('agentEvents fuses carrier and subject for custom drivers', async () => { const ctx = await harness() - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) - const other = ctx.agentLoop.create(AgentId('a2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) + const other = ctx.agentLoop.create(SessionId('a2'), { model: 'mock' }) const heard: string[] = [] agent.ctx.on('agent/error', (subject: Agent, turn: number) => void heard.push(`${subject.id}:${turn}`)) @@ -921,7 +895,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('o1'), sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('o1-s'), agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) const { agent } = handle @@ -930,7 +904,7 @@ describe('agent scope lifecycle', () => { if (event.type === 'turn/end') order.push('turn-end') }) ctx.on('agent/disposed', () => { - order.push(`disposed(listed=${ctx.agents.get(AgentId('o1')) !== undefined})`) + order.push(`disposed(listed=${ctx.agents.get(SessionId('o1-s')) !== undefined})`) order.push(`session-still-stored=${ctx.sessions.get(SessionId('o1-s')) !== undefined}`) }) @@ -955,7 +929,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { - handle = await inner.agents.create({ agentId: AgentId('h1'), sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) + handle = await inner.agents.create({ sessionId: SessionId('h1-s'), agentOptions: { model: 'mock' } }) }, { inject: ['agents'] })) const teardownDone: string[] = [] @@ -967,23 +941,22 @@ describe('agent scope lifecycle', () => { // actually finished (the raw wrapper returns undefined on a repeat call). await handle.dispose() expect(teardownDone).toContain('unregistered') - expect(ctx.agents.get(AgentId('h1'))).toBeUndefined() + expect(ctx.agents.get(SessionId('h1-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('h1-s'))).toBeUndefined() await unload }) it('successful handle disposal retires its caller ownership effect', async () => { const ctx = await harness() - const agentId = AgentId('retired-owner-effect') + const sessionId = SessionId('retired-owner-effect') const handle = await ctx.agents.create({ - agentId, - sessionId: SessionId('retired-owner-effect-s'), + sessionId, agentOptions: { model: 'mock' }, }) - expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${agentId})`) + expect(ctx.fiber.getEffects().map(effect => effect.label)).toContain(`agentLoop.owner(${sessionId})`) await handle.dispose() - expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${agentId})`)).toEqual([]) + expect(ctx.fiber.getEffects().filter(effect => effect.label === `agentLoop.owner(${sessionId})`)).toEqual([]) await ctx.fiber.dispose() }) @@ -994,7 +967,6 @@ describe('agent scope lifecycle', () => { let handle!: Awaited> const owner = await ctx.plugin(Object.assign(async (inner: Context) => { handle = await inner.agents.create({ - agentId: AgentId('manual-first'), sessionId: SessionId('manual-first-s'), agentOptions: { model: 'mock' }, setup(agentCtx) { @@ -1014,7 +986,7 @@ describe('agent scope lifecycle', () => { expect(ownerSettled).toBe(false) gate.resolve(undefined) await Promise.all([disposing, unloading]) - expect(ctx.agents.get(AgentId('manual-first'))).toBeUndefined() + expect(ctx.agents.get(SessionId('manual-first-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('manual-first-s'))).toBeUndefined() await ctx.fiber.dispose() }) @@ -1024,13 +996,11 @@ describe('agent scope lifecycle', () => { const gate = Promise.withResolvers() const cleanupStarted = Promise.withResolvers() const sessionDisposed = Promise.withResolvers() - const agentId = AgentId('quiescent-reuse') - const sessionId = SessionId('quiescent-reuse-s') + const sessionId = SessionId('quiescent-reuse') ctx.on('session/disposed', (session) => { if (session.id === sessionId) sessionDisposed.resolve(undefined) }) const first = await ctx.agents.create({ - agentId, sessionId, agentOptions: { model: 'mock' }, setup(agentCtx) { @@ -1043,10 +1013,10 @@ describe('agent scope lifecycle', () => { const disposing = first.dispose() await Promise.all([sessionDisposed.promise, cleanupStarted.promise]) - expect(ctx.agents.get(agentId)).toBeUndefined() + expect(ctx.agents.get(sessionId)).toBeUndefined() expect(ctx.sessions.get(sessionId)).toBeUndefined() - const replacement = await ctx.agents.create({ agentId, sessionId, agentOptions: { model: 'mock' } }) - expect(ctx.agents.get(agentId)).toBe(replacement.agent) + const replacement = await ctx.agents.create({ sessionId, agentOptions: { model: 'mock' } }) + expect(ctx.agents.get(sessionId)).toBe(replacement.agent) expect(ctx.sessions.get(sessionId)).toBe(replacement.agent.session) gate.resolve(undefined) @@ -1058,7 +1028,6 @@ describe('agent scope lifecycle', () => { it('handle.dispose() awaits an idle-injection flush before unregistering or detaching', async () => { const ctx = await harness() const handle = await ctx.agents.create({ - agentId: AgentId('idle-flush'), sessionId: SessionId('idle-flush-s'), agentOptions: { model: 'mock' }, }) @@ -1077,12 +1046,12 @@ describe('agent scope lifecycle', () => { const disposal = handle.dispose().then(() => { disposed = true }) await new Promise(resolve => setTimeout(resolve, 0)) expect(disposed).toBe(false) - expect(ctx.agents.get(AgentId('idle-flush'))).toBe(handle.agent) + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBe(handle.agent) expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBe(handle.agent.session) gate.resolve(undefined) await disposal - expect(ctx.agents.get(AgentId('idle-flush'))).toBeUndefined() + expect(ctx.agents.get(SessionId('idle-flush-s'))).toBeUndefined() expect(ctx.sessions.get(SessionId('idle-flush-s'))).toBeUndefined() }) }) diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index d9b0a87e1d..1085f01a4c 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -10,11 +10,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { foldRequestHeader } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -57,7 +58,7 @@ async function runTurn(registrationOrder: string[], toolOrder?: SystemPromptConf const adapter = new MockAdapter([textResponse('done')]) const ctx = await harness(adapter, toolOrder) for (const name of registrationOrder) registerNamed(ctx, name) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) return { ctx, agent, adapter } @@ -103,7 +104,7 @@ describe('loop-level canonical tool order', () => { registerNamed(ctx, 'alpha') const errors: Error[] = [] ctx.on('agent/error', (_agent, _turn, _step, error) => void errors.push(error)) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index c275fee88c..c7823a2aa5 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type TurnEndReason } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -45,7 +46,7 @@ describe('agent/turn-stop', () => { textResponse('must not be requested'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-steering'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-steering'), { model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let steered = false @@ -72,7 +73,7 @@ describe('agent/turn-stop', () => { textResponse('must not become a late-steering turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-steering'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-steering'), { model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let injected = false @@ -98,7 +99,7 @@ describe('agent/turn-stop', () => { textResponse('queued follow-up answer'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('terminal-flush-send'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('terminal-flush-send'), { model: 'mock' }) agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) let queued = false @@ -124,8 +125,8 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const stopped = ctx.agentLoop.create(AgentId('stopped'), { model: 'mock' }) - const ordinary = ctx.agentLoop.create(AgentId('ordinary'), { model: 'mock' }) + const stopped = ctx.agentLoop.create(SessionId('stopped'), { model: 'mock' }) + const ordinary = ctx.agentLoop.create(SessionId('ordinary'), { model: 'mock' }) stopped.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(stopped) @@ -145,7 +146,7 @@ describe('agent/turn-stop', () => { ]) const ctx = await harness(adapter) registerEcho(ctx) - const agent = ctx.agentLoop.create(AgentId('owned-listener'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('owned-listener'), { model: 'mock' }) const disposeStop = agent.ctx.on('agent/turn-stop', (): ContinuationStop => ({ action: 'stop' })) await send(agent, 'first turn') @@ -162,7 +163,7 @@ describe('agent/turn-stop', () => { textResponse('healthy later turn'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('bad-policy'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('bad-policy'), { model: 'mock' }) const reasons: TurnEndReason[] = [] const errors: string[] = [] ctx.on('session/event', (session, event) => { diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 9ee324b0c3..4eddb050d5 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -12,7 +12,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. -- `ctx.agents.get(id: AgentId): Agent | undefined` +- `ctx.agents.get(id: SessionId): Agent | undefined` - `ctx.agents.list(): Agent[]` #### Factory seam (creation) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index edb7f59a37..1306e8004d 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -9,7 +9,7 @@ import { Context, getTraceable, Service, symbols } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { Agent, AgentId, AgentOptions } from './types.ts' +import type { Agent, AgentOptions } from './types.ts' export * from './types.ts' export { agentEvents, assembleContextFor } from './dispatch.ts' @@ -33,15 +33,13 @@ declare module 'cordis' { /** * Options for programmatically creating an agent through the registry factory - * ({@link AgentRegistry.create}). The caller supplies the live `sessionId` - * (e.g. an ACP-generated id) and optional session metadata (the validated - * `cwd`, fork lineage); the factory creates the session, the agent, and wires - * them together. + * ({@link AgentRegistry.create}). The caller supplies the single live + * `sessionId` shared by the agent registry and session log (e.g. an + * ACP-generated id), plus optional session metadata (the validated `cwd`, fork + * lineage); the factory creates the session and agent under that identity. */ export interface CreateAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The live session's id (NOT derived from agentId). */ + /** The live agent/session identity. */ readonly sessionId: SessionId /** * Session creation metadata: validated absolute `cwd`, `parentSession` @@ -93,9 +91,7 @@ export interface CreateAgentOptions { * ({@link AgentRegistry.resume}). */ export interface ResumeAgentOptions { - /** The agent's id (the registry handle). */ - readonly agentId: AgentId - /** The persisted session id to load and resume on. */ + /** The persisted session id to load and use as the live agent/session identity. */ readonly resumeSessionId: SessionId /** Per-agent options (model, …). */ readonly agentOptions?: AgentOptions @@ -180,7 +176,7 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug /** All mutable lifecycle state for one exact registry entry. */ interface AgentEntry { - readonly id: AgentId + readonly id: SessionId readonly agent: Agent readonly carrier: Scoped announced: boolean @@ -201,7 +197,7 @@ interface FactorySlot { * {@link setFactory}. */ export class AgentRegistry extends Service { - private store = new Map() + private store = new Map() private factory: FactorySlot | undefined constructor(ctx: Context) { @@ -257,7 +253,7 @@ export class AgentRegistry extends Service { * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - agent id, session id/seed/metadata, and agent options. + * @param options - shared identity, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise { @@ -428,10 +424,10 @@ export class AgentRegistry extends Service { /** * Look up a live agent. - * @param id - the agent id to look up. + * @param id - the shared agent/session id to look up. * @returns the agent, or undefined when no live agent has that id. */ - get(id: AgentId): Agent | undefined { + get(id: SessionId): Agent | undefined { return this.store.get(id)?.agent } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index a990f0d67d..b0fb78ad66 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -32,7 +32,7 @@ * event. A turn/step boundary is a durable fact: it lives in the session log * and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` * emit. A consumer that needs the `Agent` handle (or its short id) at a boundary - * keeps a session-id→agent map from `agent/created`/`agent/disposed`. + * looks up the agent directly by the event's session id. * See `docs/rfc/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md` * and `docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md`. * @@ -45,25 +45,12 @@ * @module @deepseek-ai/dsh-agent/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Context } from 'cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { ContentBlock, LlmCallConfig, Message, MessageSource } from '@deepseek-ai/dsh-llm' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' -/** Identifies one live agent in the registry. */ -export type AgentId = Branded<'AgentId'> - -/** - * Brand a string as an {@link AgentId}. - * @param id - the raw agent id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). - */ -export function AgentId(id: string): AgentId { - return id as AgentId -} -import type { Session } from '@deepseek-ai/dsh-session' - declare module '@deepseek-ai/dsh-system-prompt' { interface AssembleContext { /** @@ -186,7 +173,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' * package should depend on the implementation. */ export interface Agent { - readonly id: AgentId + /** The single identity shared with {@link session}. */ + readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..cba833b212 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -2,11 +2,12 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' + import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { - const id = AgentId(rawId) + const id = SessionId(rawId) return { id, options: {}, @@ -57,7 +58,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) expect(() => ctx.agents.register(stubAgent('vetoed'))).toThrow('creation veto') - expect(ctx.agents.get(AgentId('vetoed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('vetoed'))).toBeUndefined() expect(lifecycle).toEqual(['created:vetoed', 'disposed:vetoed']) }) @@ -158,11 +159,11 @@ describe('AgentRegistry factory seam', () => { const factory: AgentFactory = { async createAgent(ownerCtx, options) { calls.create.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } }, async resume(ownerCtx, options) { calls.resume.push({ ownerCtx, options }) - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } }, } return { factory, calls } @@ -171,15 +172,15 @@ describe('AgentRegistry factory seam', () => { it('requires a factory and delegates through the calling context', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) - await expect(ctx.agents.create({ agentId: AgentId('a'), sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('s') })).rejects.toThrow(/no agent factory/) const { factory, calls } = stubFactory() ctx.agents.setFactory(factory) let callerFiber: Context['fiber'] | undefined await ctx.plugin(Object.assign(async (inner: Context) => { callerFiber = inner.fiber - await inner.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await inner.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await inner.agents.create({ sessionId: SessionId('create-s') }) + await inner.agents.resume({ resumeSessionId: SessionId('resume-s') }) }, { inject: ['agents'] })) expect(calls.create[0]?.ownerCtx.fiber).toBe(callerFiber) expect(calls.resume[0]?.ownerCtx.fiber).toBe(callerFiber) @@ -192,9 +193,9 @@ describe('AgentRegistry factory seam', () => { inner.agents.setFactory(stubFactory().factory) expect(() => inner.agents.setFactory(stubFactory().factory)).toThrow(/already registered/) }, { inject: ['agents'] })) - await expect(ctx.agents.create({ agentId: AgentId('before'), sessionId: SessionId('before-s') })).resolves.toBeDefined() + await expect(ctx.agents.create({ sessionId: SessionId('before-s') })).resolves.toBeDefined() await owner.dispose() - await expect(ctx.agents.create({ agentId: AgentId('after'), sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) + await expect(ctx.agents.create({ sessionId: SessionId('after-s') })).rejects.toThrow(/no agent factory/) }) it('canonicalizes an already traced Service before tracing it for the caller', async () => { @@ -214,18 +215,18 @@ describe('AgentRegistry factory seam', () => { } async createAgent(_ownerCtx: Context, options: CreateAgentOptions) { this.calls().push('create') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.sessionId), dispose: () => Promise.resolve() } } async resume(_ownerCtx: Context, options: ResumeAgentOptions) { this.calls().push('resume') - return { agent: stubAgent(options.agentId), dispose: () => Promise.resolve() } + return { agent: stubAgent(options.resumeSessionId), dispose: () => Promise.resolve() } } } await ctx.plugin(TracedFactory) const traced = (ctx as Context & { tracedFactory: TracedFactory }).tracedFactory ctx.agents.setFactory(traced) - await ctx.agents.create({ agentId: AgentId('create'), sessionId: SessionId('create-s') }) - await ctx.agents.resume({ agentId: AgentId('resume'), resumeSessionId: SessionId('resume-s') }) + await ctx.agents.create({ sessionId: SessionId('create-s') }) + await ctx.agents.resume({ resumeSessionId: SessionId('resume-s') }) const raw = (traced as unknown as { [symbols.original]?: TracedFactory })[symbols.original] expect(states.get(raw!)).toEqual(['create', 'resume']) }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 06669ca85c..cb6e1304c3 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -288,7 +288,12 @@ export class Session { */ readonly header: SessionHeader - constructor(public readonly id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { + /** The session identity, derived from its durable header's single copy. */ + get id(): SessionId { + return this.header.id + } + + constructor(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader) { if (seed) { // Validate the seed to the SAME invariants `append` enforces, so a // replay/fork (`ctx.sessions.create(id, { seed })`) cannot construct a diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index f9d84aadbf..2339657ec8 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -8,7 +8,6 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, defineTool } from '@deepseek-ai/dsh-tools' import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEventMap } from '@deepseek-ai/dsh-session' @@ -59,7 +58,7 @@ async function setup(options: SetupOptions = {}) { /** Mint one production-shaped agent scope that can register scoped tool policy. */ async function mintAgentScope(ctx: Context, name = 'scoped'): Promise<{ scope: Scope; agent: Agent }> { - const agent = { id: AgentId(name) } as Agent + const agent = { id: SessionId(name) } as Agent let scope!: Scope await ctx.plugin(Object.assign((inner: Context) => { scope = createScope(inner, agent) }, { inject: ['tools', 'systemPrompt'] })) diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 6599112c2a..8b8e1cfead 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -6,9 +6,11 @@ import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { PreToolDecision, ToolDefinition, ToolExecution, ToolExecutionInput, ToolExecutionToken } from '@deepseek-ai/dsh-tools' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' + import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Mount the registry (with its systemPrompt dependency) on a fresh context. */ async function mount(): Promise { @@ -20,7 +22,7 @@ async function mount(): Promise { /** Mint a scope whose key doubles as a minimal Agent-like object. */ async function mintAgentScope(ctx: Context, name: string): Promise<{ scope: Scope; key: Agent }> { - const key = { id: name as AgentId } as Agent + const key = { id: name as SessionId } as Agent let scope!: Scope // The scoped context resolves services through the MINTING plugin's // dependency chain — the minter must inject what scope holders will reach @@ -62,7 +64,7 @@ describe('scoped tool registration', () => { it('files a scoped tool in its layer: visible/executable for that scope only', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('shared')) scope.ctx.tools.register(tool('mine')) @@ -195,7 +197,7 @@ describe('scoped execution dispatch', () => { it('an agent.ctx pre-execute listener gates only its own agent (and never subject-less calls)', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent ctx.tools.register(tool('t')) const seen: (string | undefined)[] = [] @@ -213,7 +215,7 @@ describe('scoped execution dispatch', () => { it('applies scoped guards after pre-execute and unwinds duplicate registrations independently', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'a') - const other = { id: 'other' as AgentId } as Agent + const other = { id: 'other' as SessionId } as Agent let bodyCalls = 0 ctx.tools.register({ ...tool('t'), @@ -428,7 +430,7 @@ describe('scoped execution dispatch', () => { it('uses one input snapshot for the normalized error shell', async () => { const ctx = await mount() const { scope, key } = await mintAgentScope(ctx, 'accepted') - const driftAgent = { id: 'drift' as AgentId } as Agent + const driftAgent = { id: 'drift' as SessionId } as Agent ctx.tools.register(tool('parent')) ctx.tools.register(tool('t')) let parent!: ToolExecutionToken diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index a270e7c6d8..dca48d65ab 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { fsHarness, waitForIdle } from './harness.ts' @@ -35,7 +34,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => ctx = await fsHarness(workdir, SYSTEM) // agentLoop.create prepares a session with no cwd, so the provider default // (config.cwd = workdir) is the workspace. - const agent = ctx.agentLoop.create(AgentId('fs-e2e'), { model: 'deepseek-v4-flash' }) + const agent = ctx.agentLoop.create(SessionId('fs-e2e'), { model: 'deepseek-v4-flash' }) agent.send([{ type: 'text', text: 'Create a file named note.txt containing exactly the line: status: draft. ' @@ -65,7 +64,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('fs tools with-key smoke', () => try { ctx = await fsHarness(configDir, SYSTEM) const handle = await ctx.agents.create({ - agentId: AgentId('fs-e2e-cwd'), sessionId: SessionId(`fs-e2e-cwd-${Date.now()}`), meta: { cwd: sessionDir }, agentOptions: { model: 'deepseek-v4-flash' }, diff --git a/packages/guard/repeat-tool-guard/README.md b/packages/guard/repeat-tool-guard/README.md index dc385bc033..e46f113301 100644 --- a/packages/guard/repeat-tool-guard/README.md +++ b/packages/guard/repeat-tool-guard/README.md @@ -24,8 +24,8 @@ The chain key is `(tool name, canonical arguments)` — canonicalization is a de - **Untracked calls are transparent to the chain.** A call excluded by `include`/`exclude` neither increments nor resets the counter, so `grep X → todo_write → grep X` still counts as two consecutive `grep X` when `todo_write` is excluded. This is what makes exclusion useful: bookkeeping tools interleaved into a loop must not launder it. - **Denied calls count.** Detection sits on `tools/post-execute`, which also runs for calls a `tools/pre-execute` listener denied — a model hammering a denied call is exactly the loop worth breaking. -- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no `AgentId` to key on. -- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so chains are keyed by `AgentId`; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain; agent disposal drops its state. +- **Calls without an agent are ignored.** A direct `ctx.tools.execute()` caller has no model to remind and no live agent object to key on. +- **Per-agent keying.** The tool registry is context-level and subagents interleave through the same waterfall, so a `WeakMap` keys each chain by the live agent object; one agent's repetition never trips another's reminder. A user prompt (`agent/prompt-submit`) resets the submitting agent's chain, and object lifetime bounds the weak entry without a disposal listener. - **In-memory only.** A session resumed from persistence starts with a fresh chain — the guard is a heuristic nudge, not a logged invariant, later reminders are the accepted cost. ## Reminder delivery diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 6a5662d693..d51df1d829 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -22,7 +22,7 @@ * exclude: [todo_write] # tool-name patterns transparent to the chain * ``` * - * Chain state is keyed per {@link AgentId} — the tool registry is a + * Chain state is keyed by the live agent object — the tool registry is a * context-level singleton whose waterfalls interleave every agent's calls, so * a shared counter would let one agent's repetition trip another's reminder. * State is in-memory only: a session resumed from persistence starts with a @@ -37,7 +37,7 @@ import type { Context } from 'cordis' import z from 'schemastery' -import type { AgentId, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' +import type { Agent, HookContext, PromptDecision } from '@deepseek-ai/dsh-agent' import type { MessageSource } from '@deepseek-ai/dsh-llm' import type { PostToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' @@ -202,9 +202,7 @@ export function apply(ctx: Context, config: Config): void { throw new Error(`repeat-tool-guard: invalid argumentsPreviewChars ${argumentsPreviewChars} — must be an integer >= 1`) } - // TODO(agent-keyed-repeat-chain): key a WeakMap by the Agent itself; that - // removes the disposal-only status listener and cannot collide on id reuse. - const chains = new Map() + const chains = new WeakMap() /** Whether a tool participates in the chain (untracked calls are transparent: they neither count nor reset). */ function tracked(toolName: string): boolean { @@ -227,9 +225,9 @@ export function apply(ctx: Context, config: Config): void { if (!tracked(exec.name)) return undefined const canonical = canonicalize(exec.arguments) const key = JSON.stringify([exec.name, canonical]) - const chain = chains.get(exec.agent.id) + const chain = chains.get(exec.agent) const count = chain !== undefined && chain.key === key ? chain.count + 1 : 1 - chains.set(exec.agent.id, { key, count }) + chains.set(exec.agent, { key, count }) if (!thresholdSet.has(count)) return undefined const text = count === thresholds[0] ? GENTLE_REMINDER @@ -259,12 +257,7 @@ export function apply(ctx: Context, config: Config): void { // loop. Pure reset hook: always delegates (attaching nothing, vetoing // nothing). ctx.on('agent/prompt-submit', (agent, _content, _source, next): Promise => { - chains.delete(agent.id) + chains.delete(agent) return next() }) - - // Drop state when an agent goes away, bounding the map over harness lifetime. - ctx.on('agent/status', (agent, status) => { - if (status === 'disposed') chains.delete(agent.id) - }) } diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 565f1076b5..9538166fc4 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId } from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' @@ -57,7 +58,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -78,7 +79,7 @@ describe('threshold escalation', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -100,7 +101,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -124,7 +125,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -142,7 +143,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -163,7 +164,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -179,7 +180,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -195,7 +196,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -215,8 +216,8 @@ describe('chain semantics', () => { toolCallResponse('b3', 'probe', { q: 1 }), textResponse('done'), ])) - const agentA = ctx.agentLoop.create(AgentId('a'), { model: 'mock-a' }) - const agentB = ctx.agentLoop.create(AgentId('b'), { model: 'mock-b' }) + const agentA = ctx.agentLoop.create(SessionId('a'), { model: 'mock-a' }) + const agentB = ctx.agentLoop.create(SessionId('b'), { model: 'mock-b' }) agentA.send([{ type: 'text', text: 'go' }]) agentB.send([{ type: 'text', text: 'go' }]) await Promise.all([waitForIdle(ctx, agentA), waitForIdle(ctx, agentB)]) @@ -235,7 +236,7 @@ describe('chain semantics', () => { textResponse('turn two done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) agent.send([{ type: 'text', text: 'again' }]) @@ -256,14 +257,14 @@ describe('chain semantics', () => { // (the loop.spec pattern): a child plugin fiber owns `first`. let first!: ReactLoopAgent const fiber = await ctx.plugin(Object.assign((inner: Context) => { - first = inner.agentLoop.create(AgentId('reused'), { model: 'mock' }) + first = inner.agentLoop.create(SessionId('reused'), { model: 'mock' }) }, { inject: ['agentLoop'] })) first.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() await first.done - const second = ctx.agentLoop.create(AgentId('reused'), { model: 'mock' }) + const second = ctx.agentLoop.create(SessionId('reused'), { model: 'mock' }) second.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, second) @@ -279,7 +280,7 @@ describe('chain semantics', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -295,7 +296,7 @@ describe('chain semantics', () => { toolCallResponse('c1', 'probe', { q: 1 }), // if the direct call had counted, this would be #2 textResponse('done'), ])) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -317,7 +318,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -348,7 +349,7 @@ describe('fold onto the downstream decision', () => { textResponse('done'), ]) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 2cbc995ce3..2f0a2666c9 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -5,10 +5,11 @@ import { join } from 'node:path' import { Context, type Fiber } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -95,7 +96,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'do something' }]) await waitForIdle(ctx, agent) @@ -118,7 +119,7 @@ describe('hooks-claude bridge — UserPromptSubmit', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -143,7 +144,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'danger', description: 'd', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'should not run' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'use danger' }]) await waitForIdle(ctx, agent) @@ -166,7 +167,7 @@ describe('hooks-claude bridge — PreToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'safe', description: 's', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ran ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'use safe' }]) await waitForIdle(ctx, agent) @@ -188,7 +189,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'raw output' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -209,7 +210,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(dir, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -233,7 +234,7 @@ describe('hooks-claude bridge — PostToolUse', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -257,7 +258,7 @@ describe('hooks-claude bridge — SessionStart', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // session-start fires async (detached .then → agent.inject); wait for the // injected context/message to actually land before sending, rather than a // fixed sleep that flakes under load. @@ -294,8 +295,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's // child lookup yields undefined and it simply runs the hook. - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) - ctx.emit('subagent/end', { provider: 'inproc', id: AgentId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) + ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -330,7 +331,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { provider: 'inproc', id: AgentId('child-1') }) + ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() @@ -359,7 +360,7 @@ describe('hooks-claude bridge — load resilience', () => { await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) await ctx.plugin(HooksClaude, { configPath: '/nonexistent/hooks.json' }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The turn ran normally — no hooks, no crash. @@ -385,7 +386,7 @@ describe('hooks-claude bridge — load resilience', () => { const fiber = await ctx.plugin(HooksClaude, { configPath: join(dir, 'hooks.json') }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 7feb46df05..16c48aa149 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' @@ -72,7 +73,7 @@ describe('hooks-claude coverage — config option arms + substitution + skip war const ctx = await harness(path, adapter, { pluginRoot: d, projectDir: d }) ctx.logger.warn = warn as never ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) // substituted command ran @@ -88,7 +89,7 @@ describe('hooks-claude coverage — config option arms + substitution + skip war ctx.logger.warn = warn as never let sawArgs: unknown ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: { command: { type: 'string' } }, async execute(args) { sawArgs = args; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // updatedInput is NOT honored — the tool ran with the ORIGINAL args. @@ -104,7 +105,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ran')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // The prompt proceeded unchanged; no context/message injected. @@ -134,7 +135,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -159,7 +160,7 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -175,7 +176,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) @@ -192,7 +193,7 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { Stop: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // A second model request ran → the empty-reason block forced continuation. @@ -208,9 +209,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const ctx = await harness(path, new MockAdapter([])) // Register a fake child agent under the id the event carries. const injected: string[] = [] - const child = { id: AgentId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-x') }) + ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -224,9 +225,9 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: AgentId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: AgentId('child-y') }) + ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -240,7 +241,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -254,7 +255,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -270,7 +271,7 @@ describe('hooks-claude coverage — default reasons + sparse payloads', () => { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: AgentId('child-z'), stopReason: 'completed' }) + ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -283,7 +284,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => { const path = hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const turnEnd = events(agent).findLast(e => e.type === 'turn/end') @@ -298,7 +299,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => { const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'x' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // ask (no reason) → degrades to deny with the registry's generic message. @@ -313,7 +314,7 @@ describe('hooks-claude coverage — more default/sparse arms', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -342,7 +343,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () // the protocol lib's reference default, not a config knob). HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) @@ -357,7 +358,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) @@ -372,7 +373,7 @@ describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -393,7 +394,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -410,7 +411,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -430,7 +431,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) let ran = false ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(ran).toBe(true) // the mismatched deny was discarded → the tool ran @@ -448,7 +449,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) // NB: no projectDir // The factory create() path honors meta.cwd (the plain agentLoop.create() does not). const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' @@ -466,7 +467,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(path, adapter) // A later listener that blocks every prompt (registered AFTER the bridge). - const { AgentId: AId } = await import('@deepseek-ai/dsh-agent') + const { SessionId: AId } = await import('@deepseek-ai/dsh-session') ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) const agent = ctx.agentLoop.create(AId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) @@ -492,7 +493,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => content: [{ type: 'text' as const, text: 'rewritten-prompt' }], additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) @@ -514,7 +515,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -533,7 +534,7 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const ctx = await harness(path, adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') @@ -557,7 +558,7 @@ describe('hooks-claude coverage — executor reject + no-open-turn', () => { const bash = ctx.bash bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') @@ -573,7 +574,7 @@ describe('hooks-claude coverage — detached-listener catch handlers', () => { const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Make inject throw, forcing the SessionStart .catch path. const original = agent.inject.bind(agent) let threw = false @@ -613,7 +614,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) @@ -649,7 +650,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') - const childHandle = await ctx.agents.create({ agentId: AgentId('child-stop'), sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) + const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { model: 'mock' } }) ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) @@ -670,7 +671,7 @@ describe('hooks-claude coverage — systemMessage is warned, not surfaced', () = const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) @@ -691,7 +692,7 @@ describe('hooks-claude coverage — SessionStart timing is best-effort (no-wait) const path = hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: s }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(path, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // Send immediately — do NOT wait for the session-start inject. agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index e0677306b0..4aa678c36e 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -5,10 +5,11 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -82,7 +83,7 @@ describe('hooks-codex bridge', () => { const ctx = await harness(dir, adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'no' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'run ls' }]) await waitForIdle(ctx, agent) @@ -107,7 +108,7 @@ describe('hooks-codex bridge', () => { // Step 1 has no tool calls → would stop; the Stop hook forces step 2. const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) @@ -124,7 +125,7 @@ describe('hooks-codex bridge', () => { const adapter = new MockAdapter([textResponse('fine')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) // Ran normally; the unknown event was dropped at parse. @@ -135,7 +136,7 @@ describe('hooks-codex bridge', () => { const dir = configDir() // no hooks.json written const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(dir, adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) @@ -161,7 +162,7 @@ describe('hooks-codex bridge', () => { const fiber = await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'm' }) await fiber.dispose() ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // not blocked → the listener is gone @@ -190,7 +191,7 @@ describe('hooks-codex bridge', () => { ctx.llm.registerAdapter(['mock'], new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) // fires agent/session-start + ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) // fires agent/session-start await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await fiber.dispose() diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 774b308fa1..75b04d4c57 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -4,10 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' @@ -52,7 +53,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', '#!/usr/bin/env bash\nexit 2\n') }] }] }) const adapter = new MockAdapter([textResponse('no')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) const te = events(agent).findLast(e => e.type === 'turn/end') @@ -64,7 +65,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'c.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"ctx-x"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('ctx-x') }) @@ -78,7 +79,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.on('agent/prompt-submit', async () => ({ kind: 'block' as const, reason: 'policy veto' })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(0) expect(events(agent).some(e => e.type === 'user/message')).toBe(false) @@ -96,7 +97,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { content: [{ type: 'text' as const, text: 'rewritten-prompt' }], additionalContext: { content: [{ type: 'text' as const, text: 'from-downstream' }], source: { kind: 'plugin' as const, plugin: 'policy' } }, })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const req = JSON.stringify(adapter.requests[0]!.messages) expect(req).toContain('from-bridge') @@ -111,7 +112,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'accept' as const, content: [{ type: 'text' as const, text: 'rewritten-result' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.content.some(b => b.type === 'text' && b.text === 'rewritten-result')).toBe(true) @@ -125,7 +126,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) ctx.on('tools/post-execute', async () => ({ kind: 'block' as const, feedback: [{ type: 'text' as const, text: 'downstream-block' }] })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const result = events(agent).find(e => e.type === 'tool/result') expect(result?.type === 'tool/result' && result.data.isError).toBe(true) @@ -138,7 +139,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', '#!/usr/bin/env bash\necho \'{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"start-ctx"}}\'\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('start-ctx')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -151,7 +152,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -164,7 +165,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('post-ctx')))).toBe(true) }) @@ -176,7 +177,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: {}, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // clean-exit hook allows; commandOf returned '' }) @@ -187,7 +188,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.exitCode).toBe(0) @@ -200,7 +201,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) @@ -223,7 +224,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') @@ -246,7 +247,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(existsSync(marker)).toBe(true) expect(warn).toHaveBeenCalledWith(expect.stringContaining('async hook')) @@ -259,7 +260,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -273,7 +274,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\ntouch "${marker}"\nexit 0\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => existsSync(marker)) // the clean no-output hook has finished agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(events(agent).some(e => e.type === 'context/message')).toBe(false) @@ -285,7 +286,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.inject = (() => { throw new Error('inject boom') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SessionStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SessionStart hook failed')) @@ -298,7 +299,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) }) @@ -311,7 +312,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(true) // matcher didn't match → no hook ran → tool proceeded expect(events(agent).some(e => e.type === 'hook/invoked')).toBe(false) @@ -327,7 +328,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.decision).toBe('stop') // recorded @@ -340,7 +341,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.content.some(b => b.type === 'text' && b.text.includes('blocked by PreToolUse hook'))).toBe(true) @@ -352,7 +353,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const r = events(agent).find(e => e.type === 'tool/result') expect(r?.type === 'tool/result' && r.data.isError).toBe(true) @@ -369,7 +370,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 7 }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'number' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_input: { command: string } } expect(payload.tool_input.command).toBe('') @@ -405,7 +406,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.bash.run = (() => Promise.reject(new Error('executor down'))) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && 'exitCode' in res.data).toBe(false) @@ -419,7 +420,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { Stop: [{ hooks: [{ type: 'command', command: sh(d, 's.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(2) // empty-reason block forced continuation expect(JSON.stringify(adapter.requests[1]!.messages)).toContain('blocked by Stop hook') @@ -432,7 +433,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'ctx.sh', '#!/usr/bin/env bash\necho "extra guidance from a plain hook"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).toContain('extra guidance from a plain hook') }) @@ -448,7 +449,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'b.sh', `#!/usr/bin/env bash\ntouch "${marker}"\necho "stale"\nexit 2\n`) }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => existsSync(marker)) // the exit-2 hook has finished expect(events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('stale')))).toBe(false) @@ -462,7 +463,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'e.sh', '#!/usr/bin/env bash\necho "stale"\nexit 1\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(adapter.requests).toHaveLength(1) // exit 1 is non-blocking → the turn ran expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('stale') @@ -473,7 +474,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { SessionStart: [{ hooks: [{ type: 'command', command: sh(d, 'ss.sh', '#!/usr/bin/env bash\necho "session preamble"\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) await waitFor(() => events(agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes('session preamble')))) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) @@ -487,7 +488,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { hooks(d, { UserPromptSubmit: [{ hooks: [{ type: 'command', command: sh(d, 'j.sh', '#!/usr/bin/env bash\necho \'{"unrelated":"json"}\'\nexit 0\n') }] }] }) const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('unrelated') }) @@ -502,7 +503,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([toolCallResponse('c1', 'shell', { command: 'ls' }), textResponse('done')]) const ctx = await harness(join(d, 'hooks.json'), adapter) ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const payload = JSON.parse(readFileSync(cap, 'utf8')) as { tool_name: string; tool_input: { command: string } } expect(payload.tool_name).toBe('shell') @@ -518,7 +519,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const ctx = await harness(join(d, 'hooks.json'), adapter) let ran = false ctx.tools.register(defineTool({ name: 'shell', description: 'b', parameters: { command: { type: 'string' } }, async execute() { ran = true; return [{ type: 'text', text: 'ok' }] } })) - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(ran).toBe(false) // the matcher fired → the hook denied the tool expect(events(agent).some(e => e.type === 'hook/invoked' && e.data.point === 'PreToolUse')).toBe(true) @@ -530,7 +531,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const adapter = new MockAdapter([textResponse('ok')]) const ctx = await harness(join(d, 'hooks.json'), adapter) const warn = vi.fn(); ctx.logger.warn = warn as never - const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('a1'), { model: 'mock' }) agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) expect(warn).toHaveBeenCalledWith(expect.stringContaining('systemMessage')) expect(JSON.stringify(adapter.requests[0]!.messages)).not.toContain('heads up') @@ -553,7 +554,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { ctx.llm.registerAdapter(['mock'], adapter) ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) const { SessionId } = await import('@deepseek-ai/dsh-session') - const handle = await ctx.agents.create({ agentId: AgentId('a1'), sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) + const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, handle.agent as ReactLoopAgent) expect(existsSync(marker)).toBe(true) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 5222906a2d..1e82ef85b0 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -37,8 +37,8 @@ import { type SessionNotification, type StopReason, } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import { buildChildEnv, disposeChildProcess, spawnFailure } from '@deepseek-ai/dsh-subagent-subprocess' @@ -190,7 +190,7 @@ function toError(value: unknown): Error { * @returns the ready run handle for the child subprocess. */ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise { - const id = AgentId(randomUUID()) + const id = SessionId(randomUUID()) if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 8060a77fd4..d1b9065c60 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -37,7 +38,7 @@ async function setup(script: Script) { await ctx.plugin(Spawn, { providerName: 'spawn' }) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index e089cebe5f..360098e369 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -2,10 +2,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' @@ -43,7 +44,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(fork, { providerName: 'fork' }) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index f6de7200cf..965d9c8e78 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto' import type { Context } from 'cordis' -import { AgentId, type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertSubagentMaxDepth } from '@deepseek-ai/dsh-subagent' @@ -104,7 +104,7 @@ export async function startInProcessRun( throw new SubagentDepthError(childDepth, request.maxDepth) } - const childId = AgentId(randomUUID()) + const childId = SessionId(randomUUID()) const seedLength = options.seed?.length ?? 0 const parentHeader = parent.session.header const parentModel = parent.options.model @@ -127,8 +127,7 @@ export async function startInProcessRun( const flags = { cancelled: false } const handle = await parent.ctx.agents.create({ - agentId: childId, - sessionId: SessionId(randomUUID()), + sessionId: childId, meta: { ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, parentSession: parentHeader.id, diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 3e23123622..93da5b1d51 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import type { ContinuationDecision } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -68,7 +69,7 @@ async function setup(script: Script, options: SetupOptions = {}) { start: (request: SubagentStartRequest) => startInProcessRun(request, {}), }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent, adapter, disposeProvider } } @@ -333,7 +334,7 @@ describe('in-process structured output', () => { await expect(ctx.subagents.start('spawn', structuredRequest(parent, { outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema, }))).rejects.toThrow(/unsupported output schema/) - expect(ctx.agents.get(AgentId('parent'))).toBeDefined() + expect(ctx.agents.get(SessionId('parent'))).toBeDefined() }) it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => { diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 0005246523..aa0f946c89 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -24,7 +25,7 @@ async function setup(script: Script) { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent } } diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index daa032199e..b156f951ed 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -3,8 +3,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import { spawnHarness, waitForIdle } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * With-key smoke for the in-process spawn backend: a REAL parent agent delegates @@ -29,7 +29,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('spawn backend with-key smoke', ( it('a parent delegates to a child that writes a file on disk', async () => { workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-spawn-e2e-')) ctx = await spawnHarness(workdir) - const parent = ctx.agentLoop.create(AgentId('e2e-parent'), { model: 'deepseek-v4-flash' }) + const parent = ctx.agentLoop.create(SessionId('e2e-parent'), { model: 'deepseek-v4-flash' }) parent.send([{ type: 'text', text: 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 9e4e489882..06aa40ca9f 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -5,7 +5,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' @@ -36,7 +37,7 @@ async function setup(script: Script) { await ctx.plugin(SubagentService) await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent, adapter } } @@ -237,7 +238,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('x')]) // A parent WITH a cwd (config agents have none, so create one explicitly). const parentHandle = await ctx.agents.create({ - agentId: AgentId('cwd-parent'), sessionId: SessionId('cwd-parent-session'), meta: { cwd: '/tmp/parent-workspace' }, agentOptions: { model: 'mock' }, @@ -254,7 +254,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([textResponse('explicit model child')]) // A parent with NO model (its own turns would need one supplied per-request). const parentHandle = await ctx.agents.create({ - agentId: AgentId('modelless-parent'), sessionId: SessionId('modelless-parent-session'), agentOptions: {}, }) @@ -318,7 +317,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) const controller = new AbortController() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'q' }], @@ -349,7 +348,7 @@ describe('dsh-subagent-spawn', () => { await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(SubagentService) const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) const parentEffects = parent.ctx.fiber.getEffects().length const published: string[] = [] ctx.on('session/created', () => void published.push('session/created')) @@ -442,7 +441,6 @@ describe('dsh-subagent-spawn', () => { const { ctx } = await setup([]) // A handle-owned parent we can dispose (config agents dispose with the loop fiber). const parentHandle = await ctx.agents.create({ - agentId: AgentId('doomed-parent'), sessionId: SessionId('doomed-s'), agentOptions: { model: 'mock' }, }) @@ -465,7 +463,6 @@ describe('dsh-subagent-spawn', () => { it('parent disposal during the child setup transaction prevents every publication notification', async () => { const { ctx } = await setup([]) const parentHandle = await ctx.agents.create({ - agentId: AgentId('setup-race-parent'), sessionId: SessionId('setup-race-parent-session'), agentOptions: { model: 'mock' }, }) diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 4635de9697..e885c16469 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -18,7 +18,8 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -96,7 +97,7 @@ export interface SubagentRunInfo { /** The provider that established the run. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId } /** Observe-only outcome detail for a settled subagent run. */ @@ -104,7 +105,7 @@ export interface SubagentRunEndInfo { /** The provider that ran it. */ readonly provider: string /** The child agent's id. */ - readonly id: AgentId + readonly id: SessionId /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] /** The child's final assistant output, absent on infrastructure rejection. */ diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 3bdc78569a..8d360b1182 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -6,8 +6,9 @@ * @module @deepseek-ai/dsh-subagent/types */ -import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionId } from '@deepseek-ai/dsh-session' import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' /** @@ -147,7 +148,7 @@ export interface SubagentResult { */ export interface SubagentRun { /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ - readonly id: AgentId + readonly id: SessionId /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index 3d10b425ed..ad7dbaafff 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import { HarnessError } from '@deepseek-ai/dsh-llm' import { carrierKeyOf } from '@deepseek-ai/dsh-scope' import SubagentService, { @@ -12,9 +13,10 @@ import SubagentService, { type SubagentRun, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } @@ -45,7 +47,7 @@ class StubProvider implements SubagentProvider { async start(request: SubagentStartRequest): Promise { this.startCount += 1 return { - id: AgentId(`child:${this.name}:${request.parent.id}`), + id: SessionId(`child:${this.name}:${request.parent.id}`), result: Promise.resolve(this.outcome), async dispose() {}, } @@ -141,7 +143,7 @@ describe('SubagentService', () => { const starting = subagents.start('deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) - ready.resolve({ id: AgentId('child'), result: result.promise, async dispose() {} }) + ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} }) const run = await starting expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) @@ -190,7 +192,7 @@ describe('SubagentService', () => { capabilities: NO_CAPS, inheritsParentContext: false, async start() { - return { id: AgentId('infra-child'), result: failure.promise, async dispose() {} } + return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} } }, }) const failedRun = await subagents.start('infra', baseRequest()) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 5830513cf4..79df080657 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -4,10 +4,12 @@ import Loader from '@cordisjs/plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import SubagentService from '@deepseek-ai/dsh-subagent' import * as mock from '@deepseek-ai/dsh-subagent-mock' import * as tool from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real @@ -20,7 +22,7 @@ import * as tool from '../src/index.ts' /** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ function fakeAgent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { @@ -114,7 +116,7 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('weird-child'), + id: SessionId('weird-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), dispose: async () => {}, }), @@ -141,7 +143,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture-child'), + id: SessionId('capture-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -170,7 +172,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('bare-child'), + id: SessionId('bare-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -297,7 +299,7 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => void disposed(), }), @@ -319,7 +321,7 @@ describe('dsh-tool-subagent', () => { capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('spy-child'), + id: SessionId('spy-child'), result: Promise.resolve({ output: [], stopReason: 'error' as const }), dispose: async () => void disposed(), }), @@ -350,7 +352,7 @@ describe('dsh-tool-subagent', () => { resolveResult({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('spy-child'), + id: SessionId('spy-child'), result, dispose: async () => {}, } @@ -442,7 +444,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture2-child'), + id: SessionId('capture2-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -499,7 +501,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture3-child'), + id: SessionId('capture3-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -528,7 +530,7 @@ describe('dsh-tool-subagent', () => { start: async (request) => { seen = request return { - id: AgentId('capture4-child'), + id: SessionId('capture4-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index abc1a6c5e9..bf7bcbe2fb 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -13,8 +13,8 @@ import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import type { SubagentCapabilities, SubagentProvider, @@ -65,7 +65,7 @@ class MockSubagentProvider implements SubagentProvider { // A deterministic child id derived from the parent — no clock/random (both // banned in deterministic paths here, and unnecessary for a scripted run). - const id = AgentId(`mock-subagent:${this.name}:${request.parent.id}`) + const id = SessionId(`mock-subagent:${this.name}:${request.parent.id}`) const resultFor = (): SubagentResult => ({ output, diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts index 7fd93c62bc..b5e7180782 100644 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -1,13 +1,15 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import * as mock from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A minimal parent — the mock provider only reads `parent.id`. */ function fakeParent(id = 'parent-1'): Agent { - return { id: AgentId(id) } as unknown as Agent + return { id: SessionId(id) } as unknown as Agent } function baseRequest(over: Partial = {}): SubagentStartRequest { diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 739367a699..8376a70425 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -64,7 +65,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Plan recorded.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo'), { model: 'mock' }) agent.send([{ type: 'text', text: 'plan a two-step task' }]) await waitForIdle(ctx, agent) @@ -92,7 +93,7 @@ describe('todo_write tool through the agent loop', () => { textResponse('Done planning.'), ]) const ctx = await harness(adapter) - const agent = ctx.agentLoop.create(AgentId('it-todo-2'), { model: 'mock' }) + const agent = ctx.agentLoop.create(SessionId('it-todo-2'), { model: 'mock' }) agent.send([{ type: 'text', text: 'plan then update' }]) await waitForIdle(ctx, agent) diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 86e8814633..b426b6760c 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -6,7 +6,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import { Session, SessionId } from '@deepseek-ai/dsh-session' import type { TodoItem } from '@deepseek-ai/dsh-session' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import * as tool from '../src/index.ts' /** @@ -20,7 +21,7 @@ import * as tool from '../src/index.ts' /** A parent Agent backed by a real Session — the tool reads `agent.session`. */ function agentWithSession(id = 'parent-1'): Agent & { session: Session } { const session = new Session(SessionId(id)) - return { id: AgentId(id), session } as unknown as Agent & { session: Session } + return { id: SessionId(id), session } as unknown as Agent & { session: Session } } async function setup(): Promise { diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index a3fdc38f97..e795c7e177 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -72,7 +72,6 @@ import { import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { assertNever, CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-bash' import { APPROVAL_POLICIES, effectiveApprovalPolicy, setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' @@ -686,7 +685,6 @@ export function apply(ctx: Context, config: AcpConfig): void { validateMcpServers(params) const sessionId = SessionId(randomUUID()) const handle = await agents.create({ - agentId: AgentId(sessionId), sessionId, meta: { cwd: params.cwd }, agentOptions: agentOptions(config), @@ -757,7 +755,6 @@ export function apply(ctx: Context, config: AcpConfig): void { } } const handle = await agents.resume({ - agentId: AgentId(sessionId), resumeSessionId: sessionId, agentOptions: agentOptions(config), }) diff --git a/packages/ui/acp/tests/approval.spec.ts b/packages/ui/acp/tests/approval.spec.ts index ed035aaf80..36846ac728 100644 --- a/packages/ui/acp/tests/approval.spec.ts +++ b/packages/ui/acp/tests/approval.spec.ts @@ -4,9 +4,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { CallId } from '@deepseek-ai/dsh-llm' -import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { type Agent } from '@deepseek-ai/dsh-agent' + import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' import { makeBridgeHarness, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * The bridge's `approval/request` answerer: an ask for an agent the bridge @@ -31,7 +33,7 @@ describe('acp bridge — approval answerer', () => { ): Promise<{ agent: Agent; request: ApprovalRequest }> { await h.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await h.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = h.ctx.agents.get(AgentId(sessionId)) + const agent = h.ctx.agents.get(SessionId(sessionId)) if (agent === undefined) throw new Error('newSession created no agent') // In production an ask always fires mid-turn (tool execution); open one so // request()'s turn-enclosure precondition holds for the direct drive below. diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index be05a09644..7e780348ba 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** * End-to-end bridge specs over an in-memory transport: a real @@ -98,7 +98,7 @@ describe('acp bridge', () => { required: [], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') const toolResultBlock = toolResult?.type === 'tool/result' ? toolResult.data.content[0] : undefined const toolResultText = toolResultBlock?.type === 'text' ? toolResultBlock.text : undefined expect(toolResultText).toBe('{"answers":[{"id":"language","selected":["Python"]}]}') @@ -127,7 +127,7 @@ describe('acp bridge', () => { required: ['custom'], }, }) - const toolResult = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'tool/result') + const toolResult = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'tool/result') expect(JSON.stringify(toolResult)).toContain('apollo') }) @@ -136,7 +136,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const result = await harness.ctx.userInteraction.ask({ agent, @@ -167,7 +167,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: 'TypeScript', custom: 'Use Zig' } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -184,7 +184,7 @@ describe('acp bridge', () => { harness.onElicitation = () => ({ action: 'accept', content: { choice: ['Tests', 'Docs'] } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ agent, @@ -201,7 +201,7 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] })) .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' }) @@ -225,7 +225,7 @@ describe('acp bridge', () => { harness = await makeBridgeHarness({ storageDir, withAskUser: true }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! const alreadyAborted = new AbortController() alreadyAborted.abort() @@ -265,8 +265,8 @@ describe('acp bridge', () => { expect(b.sessionId).toBeTruthy() expect(a.sessionId).not.toBe(b.sessionId) // Both agents are live and independently registered. - expect(harness.ctx.agents.get(AgentId(a.sessionId))).toBeDefined() - expect(harness.ctx.agents.get(AgentId(b.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(a.sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(b.sessionId))).toBeDefined() }) it('rejects a non-absolute cwd but accepts any absolute cwd (per-session workspace)', async () => { @@ -281,7 +281,7 @@ describe('acp bridge', () => { const res = await harness.client.newSession({ cwd: '/tmp', mcpServers: [] }) expect(res.sessionId).toBeTruthy() // The session header records that cwd, so its bash tools run there. - expect(harness.ctx.agents.get(AgentId(res.sessionId))!.session.header.cwd).toBe('/tmp') + expect(harness.ctx.agents.get(SessionId(res.sessionId))!.session.header.cwd).toBe('/tmp') }) it('rejects non-empty additionalDirectories', async () => { @@ -321,7 +321,7 @@ describe('acp bridge', () => { ], }) expect(result.stopReason).toBe('end_turn') - const user = harness.ctx.agents.get(AgentId(sessionId))!.session.events.find(event => event.type === 'user/message') + const user = harness.ctx.agents.get(SessionId(sessionId))!.session.events.find(event => event.type === 'user/message') expect(JSON.stringify(user)).toContain('resource_link') }) diff --git a/packages/ui/acp/tests/dispose.spec.ts b/packages/ui/acp/tests/dispose.spec.ts index f02a7b0649..b5d98c0881 100644 --- a/packages/ui/acp/tests/dispose.spec.ts +++ b/packages/ui/acp/tests/dispose.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse } from './harness.ts' describe('acp bridge — disposal & HMR safety', () => { @@ -17,7 +16,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // Start a prompt that hangs in the model stream. const promptDone = harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) @@ -62,10 +61,10 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeDefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeDefined() await harness.acpFiber.dispose() // tear down ONLY the bridge - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -92,7 +91,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // Start a prompt that hangs in the model stream. The prompt RPC will never // return (its transport is severed), so do not await it. void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) @@ -115,7 +114,7 @@ describe('acp bridge — disposal & HMR safety', () => { // and its session removed from the store, not merely idled (the old // behavior). The services live on the root ctx, so they survive this. await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId(sessionId))).toBeUndefined() await harness.dispose() }) @@ -128,7 +127,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -145,7 +144,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const session = harness.ctx.agents.get(AgentId(sessionId))!.session + const session = harness.ctx.agents.get(SessionId(sessionId))!.session await harness.ctx.fiber.dispose() const before = harness.updates.length @@ -169,12 +168,12 @@ describe('acp bridge — disposal & HMR safety', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - const liveEvents = harness.ctx.agents.get(AgentId(sessionId))!.session.events.length + const liveEvents = harness.ctx.agents.get(SessionId(sessionId))!.session.events.length expect(liveEvents).toBeGreaterThan(0) // Tear down JUST the bridge (the AgentHandle dispose runs to quiescence). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() // Re-load the session from disk: every live event (incl. the closing // turn/end) was flushed before the session was detached. @@ -201,7 +200,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! void harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => {}) await new Promise(r => setTimeout(r, 30)) expect(agent.status).toBe('running') @@ -211,7 +210,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Dispose JUST the bridge: a fiber unload that must STILL honor the ordered // teardown (the composite effect runs its disposer chain as a unit). await harness.acpFiber.dispose() - expect(harness.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(sessionId))).toBeUndefined() // The loop's own `turn/end {disposed}` is on disk (re-load: the world, not // self-report) — NOT a crash-recovery `interrupted` substitute. @@ -230,21 +229,21 @@ describe('acp bridge — disposal & HMR safety', () => { // queryable, with its session still in the store. const harness = await makeBridgeHarness({ storageDir, script: [] }) const handleA = await harness.ctx.agents.create({ - agentId: AgentId('sib-a'), sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, + sessionId: SessionId('sib-a'), agentOptions: { model: 'mock' }, }) const handleB = await harness.ctx.agents.create({ - agentId: AgentId('sib-b'), sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, + sessionId: SessionId('sib-b'), agentOptions: { model: 'mock' }, }) - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBe(handleA.agent) - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBe(handleA.agent) + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) await handleA.dispose() // A is gone — unregistered AND its session removed from the store. - expect(harness.ctx.agents.get(AgentId('sib-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('sib-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('sib-a'))).toBeUndefined() expect(handleA.agent.status).toBe('disposed') // B is wholly unaffected. - expect(harness.ctx.agents.get(AgentId('sib-b'))).toBe(handleB.agent) + expect(harness.ctx.agents.get(SessionId('sib-b'))).toBe(handleB.agent) expect(harness.ctx.sessions.get(SessionId('sib-b'))).toBeDefined() expect(handleB.agent.status).not.toBe('disposed') await harness.dispose() @@ -262,7 +261,7 @@ describe('acp bridge — disposal & HMR safety', () => { const harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')] }) harness.ctx.on('agent/disposed', () => { throw new Error('boom disposed listener') }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('guard-a'), sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, + sessionId: SessionId('guard-a'), agentOptions: { model: 'mock' }, }) handle.agent.send([{ type: 'text', text: 'go' }]) await handle.agent.whenIdle() @@ -270,7 +269,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Dispose: the throwing listener must NOT break the chain before detach. await handle.dispose() - expect(harness.ctx.agents.get(AgentId('guard-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('guard-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('guard-a'))).toBeUndefined() // detach still ran await harness.dispose() }) @@ -283,7 +282,7 @@ describe('acp bridge — disposal & HMR safety', () => { // observe the same quiescence boundary. const harness = await makeBridgeHarness({ storageDir, script: ['hang'] }) const handle = await harness.ctx.agents.create({ - agentId: AgentId('conc-a'), sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, + sessionId: SessionId('conc-a'), agentOptions: { model: 'mock' }, }) // Drive a turn that hangs in the model stream, so the loop is mid-turn when // disposed — its exit runs a final session/flush we can gate to hold the @@ -313,7 +312,7 @@ describe('acp bridge — disposal & HMR safety', () => { // Release the flush; both resolve together and the session is gone. releaseFlush() await Promise.all([first, second]) - expect(harness.ctx.agents.get(AgentId('conc-a'))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId('conc-a'))).toBeUndefined() expect(harness.ctx.sessions.get(SessionId('conc-a'))).toBeUndefined() await harness.dispose() }) diff --git a/packages/ui/acp/tests/edges.spec.ts b/packages/ui/acp/tests/edges.spec.ts index e86fb9fc94..329016a69e 100644 --- a/packages/ui/acp/tests/edges.spec.ts +++ b/packages/ui/acp/tests/edges.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' @@ -27,7 +26,7 @@ describe('acp bridge — demux & config edges', () => { await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const before = harness.updates.length - const { agent: foreign } = await harness.ctx.agents.create({ agentId: AgentId('foreign'), sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) + const { agent: foreign } = await harness.ctx.agents.create({ sessionId: SessionId('foreign-session'), agentOptions: { model: 'mock' } }) foreign.send([{ type: 'text', text: 'hi' }]) await foreign.whenIdle() await new Promise(r => setTimeout(r, 10)) diff --git a/packages/ui/acp/tests/load.spec.ts b/packages/ui/acp/tests/load.spec.ts index 2fbc95b3d9..efc9399c75 100644 --- a/packages/ui/acp/tests/load.spec.ts +++ b/packages/ui/acp/tests/load.spec.ts @@ -4,7 +4,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, toolCallResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' /** Concatenate the text of all agent_message_chunk updates. */ @@ -194,7 +193,7 @@ describe('acp bridge — session/load replay', () => { release() // resume() finishes AFTER teardown expect(await loadResult).toBe('rejected') // No live agent was installed for the closed connection. - expect(loader.ctx.agents.get(AgentId(sessionId))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId(sessionId))).toBeUndefined() }) it('rejects load when the requested cwd does not match the persisted session cwd', async () => { @@ -215,11 +214,11 @@ describe('acp bridge — session/load replay', () => { await loader.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) await expect(loader.client.loadSession({ sessionId: 'elsewhere', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/cwd mismatch/) - expect(loader.ctx.agents.get(AgentId('elsewhere'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('elsewhere'))).toBeUndefined() const res = await loader.client.loadSession({ sessionId: 'elsewhere', cwd: `${otherCwd}/.`, mcpServers: [] }) expect(res).toBeDefined() - expect(loader.ctx.agents.get(AgentId('elsewhere'))!.session.header.cwd).toBe(otherCwd) + expect(loader.ctx.agents.get(SessionId('elsewhere'))!.session.header.cwd).toBe(otherCwd) }) it('rejects load for a non-absolute cwd (still required to be absolute)', async () => { @@ -254,7 +253,7 @@ describe('acp bridge — session/load replay', () => { // Rejected BEFORE resume (metadata-only check) — no agent was registered, so // the id is not wedged: a later attempt hits the same clean rejection, not a // duplicate-registration error. - expect(loader.ctx.agents.get(AgentId('legacy'))).toBeUndefined() + expect(loader.ctx.agents.get(SessionId('legacy'))).toBeUndefined() await expect(loader.client.loadSession({ sessionId: 'legacy', cwd: process.cwd(), mcpServers: [] })) .rejects.toThrow(/no absolute persisted cwd/) }) diff --git a/packages/ui/acp/tests/multi-session.spec.ts b/packages/ui/acp/tests/multi-session.spec.ts index ca11934046..0881fe4199 100644 --- a/packages/ui/acp/tests/multi-session.spec.ts +++ b/packages/ui/acp/tests/multi-session.spec.ts @@ -3,8 +3,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { AgentId } from '@deepseek-ai/dsh-agent' import { makeBridgeHarness, textResponse, type BridgeHarness, type CapturedUpdate } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Text of the agent_message_chunk updates scoped to one session id. */ function messageTextFor(updates: { sessionId?: string; update: CapturedUpdate }[], sessionId: string): string { @@ -102,8 +102,8 @@ describe('acp bridge — RFC 011 multi-session isolation', () => { await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const a = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId const b = (await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })).sessionId - const agentA = harness.ctx.agents.get(AgentId(a))! - const agentB = harness.ctx.agents.get(AgentId(b))! + const agentA = harness.ctx.agents.get(SessionId(a))! + const agentB = harness.ctx.agents.get(SessionId(b))! // Wait deterministically for BOTH agents to enter `running` (not a fixed // sleep — agent startup latency is unbounded on a loaded worker). diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index f559ca36d9..000b80336d 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -3,7 +3,6 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { errorResponse, @@ -13,6 +12,7 @@ import { toolCallResponse, type BridgeHarness, } from './harness.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** Boilerplate: initialize + create one session, returning its id. */ async function newSession(h: BridgeHarness, clientCapabilities: Record = {}): Promise { @@ -279,7 +279,7 @@ describe('acp bridge — turn outcomes', () => { // OWN turn with the real model answer. harness = await makeBridgeHarness({ storageDir, script: [textResponse('real answer')] }) const sessionId = await newSession(harness) - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! // On the queued prompt, synchronously inject a one-shot context turn (idle // inject writes turn/start{injection} → context/message → turn/end). Fire // once so it lands between install and the prompt turn. @@ -335,7 +335,7 @@ describe('acp bridge — turn outcomes', () => { await harness.client.cancel({ sessionId }) const res = await promptDone expect(res.stopReason).toBe('cancelled') - const agent = harness.ctx.agents.get(AgentId(sessionId))! + const agent = harness.ctx.agents.get(SessionId(sessionId))! await agent.whenIdle() // At most ONE turn ran (the cancelled one) — the cancel cleared the queue, so // no second turn was batched or leaked. (A best-effort abort that left queued diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 86af6645f3..4dbfeb0fa2 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -16,7 +16,6 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -255,7 +254,6 @@ export class HarnessSdkServer { private async createSession(sessionId: string): Promise { const handle = await this.ctx.agents.create({ - agentId: AgentId(sessionId), sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, agentOptions: { model: this.model }, diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 1cde550f2e..9260a59aae 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -5,7 +5,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId, type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' +import { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' + import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -135,7 +136,6 @@ describe('HarnessSdkServer', () => { expect(llmServer.requests).toHaveLength(2) const orphanHandle = await ctx.agents.create({ - agentId: AgentId('orphan-agent'), sessionId: SessionId('orphan-session'), meta: { cwd: storageDir }, agentOptions: { model: 'dsagent-model' }, @@ -170,8 +170,8 @@ describe('HarnessSdkServer', () => { } as unknown as Agent const mainHandle = { agent: mainAgent, dispose: vi.fn(() => Promise.resolve()) } const otherHandle = { agent: otherAgent, dispose: vi.fn(() => Promise.resolve()) } - const create = vi.fn(async (options: { agentId: AgentId }) => - String(options.agentId) === 'main' ? mainHandle : otherHandle) + const create = vi.fn(async (options: { sessionId: SessionId }) => + String(options.sessionId) === 'main' ? mainHandle : otherHandle) const ctx = { on: vi.fn(() => () => undefined), agents: { create, get: () => undefined }, @@ -263,20 +263,18 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, transport) const parentHandle = await ctx.agents.create({ - agentId: AgentId('parent-agent'), sessionId: SessionId('main'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) const handle = await ctx.agents.create({ - agentId: AgentId('child-agent'), sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', - id: AgentId('child-agent'), + id: SessionId('child-session'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], }) @@ -285,7 +283,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'spawn', - agentId: 'child-agent', + agentId: 'child-session', parentSessionId: 'main', childSessionId: 'child-session', status: 'ok', @@ -311,19 +309,16 @@ describe('HarnessSdkServer', () => { let failedHandle: AgentHandle | undefined try { parentHandle = await ctx.agents.create({ - agentId: AgentId('fallback-parent-agent'), sessionId: SessionId('fallback-parent'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) handle = await ctx.agents.create({ - agentId: AgentId('fallback-child-agent'), sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { model: 'deepseek' }, }) failedHandle = await ctx.agents.create({ - agentId: AgentId('failed-child-agent'), sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, @@ -333,18 +328,18 @@ describe('HarnessSdkServer', () => { await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('fallback-child-agent'), + id: SessionId('fallback-child-session'), stopReason: 'max-tokens', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('failed-child-agent'), + id: SessionId('failed-child-session'), stopReason: 'error', }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', - id: AgentId('missing-child-agent'), + id: SessionId('missing-child-agent'), stopReason: 'error', }) @@ -352,7 +347,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'fallback-child-agent', + agentId: 'fallback-child-session', parentSessionId: 'fallback-parent', childSessionId: 'fallback-child-session', status: 'error', @@ -364,7 +359,7 @@ describe('HarnessSdkServer', () => { method: 'subagent.finished', params: { provider: 'fork', - agentId: 'failed-child-agent', + agentId: 'failed-child-session', childSessionId: 'failed-child-session', status: 'error', stopReason: 'error', diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 27f0d20be7..82da64cd81 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -11,11 +11,11 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | -| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` | +| `@deepseek-ai/dsh-agent-core` | the spine, pre-creating one agent under the `main` config label from this app's `model`, with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | +| `stdio-chat` (in-package module) | the readline UI, holding the app-owned agent object directly and rendering it as `main` | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. @@ -25,14 +25,14 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | Key | Default | Routed to | |---|---|---| -| `model` | (required) | the pre-created `main` agent's model | +| `model` | (required) | the pre-created agent's model | | `persona` | — | the deployment persona template (may reference `{{model}}`), routed to `dsh-system-prompt` | | `toolOrder` | — | explicit model-facing tool order (a name list with one `''` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-agent` was started. Resumed sessions keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id. Resumed sessions register under the exact `resumeSessionId` and keep the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 127851eaf5..f01c48a615 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -3,11 +3,11 @@ * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal * chat needs — a console logger, the readline UI (the in-package `stdio-chat` * module), JSONL session - * persistence, and a pre-created `main` agent the UI drives. + * persistence, and one pre-created agent the UI drives under its `main` label. * * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the - * console (stdout is just the terminal) and always pre-creates the `main` agent - * the readline UI sends to. The leaf supplies the swappable backends (the LLM + * console (stdout is just the terminal) and always pre-creates one agent the + * readline UI labels `main`. The leaf supplies the swappable backends (the LLM * adapter, the bash executor), optional product tools, the optional `hmr` * dev-reload plugin, and this app's {@link Config} (model, prompt, persistence * root, welcome banner). @@ -41,7 +41,6 @@ import type { Context } from 'cordis' import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' import * as agentCore from '@deepseek-ai/dsh-agent-core' @@ -54,7 +53,7 @@ export const name = 'stdio-agent' /** * App config: the swappable per-demo values, each routed to where the app wires - * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through + * it. `model`/`resumeSessionId` configure the pre-created agent (through * {@link @deepseek-ai/dsh-agent-core}'s forwarded `agents` list); `persona` is * the deployment persona (forwarded to the system-prompt plugin); `toolOrder` * is the explicit model-facing tool order (forwarded to the system-prompt plugin); @@ -63,7 +62,7 @@ export const name = 'stdio-agent' * `welcome` is the UI banner. */ export interface Config { - /** Model name for the `main` agent (must have a registered adapter). */ + /** Model name for the pre-created agent (must have a registered adapter). */ model: string /** Deployment persona (the system-prompt plugin's `persona` config). */ persona?: string @@ -78,7 +77,7 @@ export interface Config { /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-core. */ skills?: agentCore.SkillConfig /** - * If set, the `main` agent RESUMES this persisted session id instead of + * If set, the pre-created agent RESUMES this persisted session id instead of * starting fresh. Sourced from an env var in the leaf `cordis.yml` * (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`). */ @@ -103,9 +102,9 @@ export const Config: z = z.object({ /** * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-core bundle pre-creating the `main` agent from this - * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL - * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is + * (infra), then the agent-core bundle pre-creating one agent from this app's + * `model`/`resumeSessionId` with the deployment `persona`, then the JSONL + * backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is * a leaf concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { @@ -115,7 +114,7 @@ export function apply(ctx: Context, config: Config): void { ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, ...config.tools !== undefined ? { tools: config.tools } : {}, agents: [{ - id: AgentId('main'), + id: 'main', model: config.model, cwd: process.cwd(), ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, @@ -125,5 +124,5 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) + ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 8e3c1f467b..cb9ce0bc10 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -19,7 +19,7 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' import { UserInteractionError, type AskUserQuestionAnswer, @@ -36,15 +36,10 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - // TODO(fixed-stdio-agent): this app-internal plugin is mounted only for the - // precreated `main` agent; remove configurability and its config-only test. - /** Id of the agent stdin drives (`send`/`steer`) and whose status gates the EOF exit; rendering is global. Defaults to `'main'`. */ - agent?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), - agent: z.string().default('main'), }) /** @@ -98,23 +93,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Loader validation, so it must be self-contained rather than trusting the // cast — `config.welcome as string` would otherwise be `undefined` on `{}`. const welcome = config.welcome ?? 'ready.' - const agentId = AgentId(config.agent ?? 'main') const { input, output, exit } = runtime - // Render label lookup: the `turn/start` session event carries only the turn - // number, so to print the short agent id (`[main turn 1]`) we map the - // session's id to its agent's id. The session id is not reliably the agent id - // (a session can be created with an explicit/client-supplied id), so build the - // map from `agent/created` rather than parsing the id string. Seed from the - // registry's current agents first: an agent registered before this plugin - // installed (e.g. the pre-created `main` agent, or any agent surviving an HMR - // reload of just this fiber) already fired its `agent/created`, so the live - // listener alone would miss it and its turns would fall back to the raw - // session id. - const labelBySession = new Map() - for (const agent of ctx.agents.list()) labelBySession.set(agent.session.header.id, agent.id) - ctx.on('agent/created', (agent) => { labelBySession.set(agent.session.header.id, agent.id) }) - ctx.on('agent/disposed', (agent) => { labelBySession.delete(agent.session.header.id) }) + // This app owns exactly one pre-created agent. Hold the live object directly: + // its per-run id is intentionally fresh, while `main` remains only the + // terminal's fixed display label. + let target: Agent | undefined = ctx.agents.list()[0] + ctx.on('agent/created', (agent) => { target ??= agent }) + ctx.on('agent/disposed', (agent) => { + if (target === agent) target = undefined + }) // Transcript rendering off the durable `session/event` feed — the assistant // token stream, turn/step boundaries, tool activity, and todos all come from @@ -136,7 +124,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt output.write(chunk.text) } } else if (event.type === 'turn/start') { - const label = labelBySession.get(session.header.id) ?? session.header.id + const label = target?.session === session ? 'main' : session.id output.write(`\n[${label} turn ${event.data.turn}] `) } else if (event.type === 'turn/end') { if (inReasoning) output.write('\x1B[0m') @@ -187,7 +175,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // Work submitted: wait until a turn has run and the agent is idle. if (submittedWork) { if (!sawRunning) return - const agent = ctx.agents.get(agentId) + const agent = target if (agent && agent.status !== 'idle') return // a turn is still running } // Let any final output flush, then exit. The handle is tracked so the @@ -201,7 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const disposeStatusListener = ctx.on('agent/status', (subject, status) => { - if (subject.id !== agentId) return + if (subject !== target) return if (status === 'running') sawRunning = true if (status === 'idle') maybeExit() }) @@ -354,9 +342,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const text = line.trim() if (!text) return - const agent = ctx.agents.get(agentId) + const agent = target if (!agent) { - ctx.logger.error('ui-stdio: agent "%s" is not running', agentId) + ctx.logger.error('ui-stdio: main agent is not running') return } submittedWork = true diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts index a958c435c1..6116aa356f 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -16,7 +16,7 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its label map from the registry at install; this suite only + // The UI seeds its target object from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. agents: { list: vi.fn(() => []) }, userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 0668d25fb0..5c5c0d2667 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -4,7 +4,8 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId, agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' + import type { Message } from '@deepseek-ai/dsh-llm' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' @@ -82,9 +83,12 @@ describe('dsh-stdio-agent app', () => { expect(ctx.get('sessionPersistence')).toBeDefined() expect(ctx.get('userInteraction')).toBeDefined() expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() - // The pre-created `main` agent the UI drives. - const agent = ctx.get('agents')?.get(AgentId('main')) + // The sole pre-created agent the UI drives. `main` is its stable config + // label; each fresh process mints a durable combined agent/session id. + const agent = ctx.get('agents')?.list()[0] expect(agent).toBeDefined() + expect(agent?.id).toBe(agent?.session.id) + expect(agent?.id).toMatch(/^main-session-/) expect(agent?.session.header.cwd).toBe(process.cwd()) await ctx.fiber.dispose() }) @@ -99,7 +103,7 @@ describe('dsh-stdio-agent app', () => { stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) await new Promise(resolve => setTimeout(resolve, 80)) expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.get(AgentId('main'))).toBeDefined() + expect(ctx.get('agents')?.list()).toHaveLength(1) await ctx.fiber.dispose() }) @@ -116,7 +120,7 @@ describe('dsh-stdio-agent app', () => { it('forwards resumeSessionId onto the pre-created agent when set', async () => { // A resume id defers agent creation until persistence loads; with no backing - // session the resume is contained + logged, so no `main` agent registers — + // session the resume is contained + logged, so no agent registers — // the branch that maps resumeSessionId through is what this covers. const ctx = await mount({ model: 'mock', @@ -125,7 +129,7 @@ describe('dsh-stdio-agent app', () => { resumeSessionId: 'no-such-session', skills: await isolatedSkillsConfig(), }) - expect(ctx.get('agents')?.get(AgentId('main'))).toBeUndefined() + expect(ctx.get('agents')?.list()).toEqual([]) await ctx.fiber.dispose() }) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 93683d0768..69ca5f8837 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -57,17 +57,16 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { status, sent, steered, - // A minimal session stub: the UI reads only `session.header.id` (to map the - // session back to its agent id for the turn-boundary label). - session: { header: { id: `${id}-session` } }, + // A minimal session stub with the agent's shared durable identity. + session: { id, header: { id } }, send: (content: ContentBlock[]) => void sent.push(content), steer: (content: ContentBlock[]) => void steered.push(content), } as never } /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ -function makeSession(agentId: string): Session { - return { header: { id: `${agentId}-session` } } as Session +function makeSession(id: string): Session { + return { id, header: { id } } as Session } /** An `assistant/chunk` session event carrying one raw stream chunk. */ @@ -75,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there', agent: 'main' } +const CONFIG: Config = { welcome: 'hi there' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -99,12 +98,11 @@ describe('createStdioChat rendering', () => { expect(out.text()).toBe('hi there\n> ') }) - it('falls back to default welcome/agent when called with empty config', async () => { + it('falls back to the default welcome when called with empty config', async () => { // createStdioChat is exported and may be driven directly (bypassing the - // Loader's schemastery validation), so it must default welcome/agent itself. + // Loader's schemastery validation), so it must default the welcome itself. const { out } = await setup({}) expect(out.text()).toBe('ready.\n> ') - // And it drives the default agent id 'main'. }) it('detects readline terminal mode from both stream TTY flags', async () => { @@ -156,9 +154,9 @@ describe('createStdioChat rendering', () => { it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - // agent/created populates the session-id → agent-id label map. + // agent/created supplies the app-owned target object. ctx.emit('agent/created', agent) - const session = makeSession('main') + const session = agent.session ctx.emit('session/event', session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, } as SessionEvent) @@ -169,21 +167,20 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\n> ') }) - it('falls back to the session id as the label when no agent is mapped', async () => { + it('uses the session id as the label for a non-target session', async () => { const { ctx, out } = await setup() - // No agent/created emitted, so the label map is empty — the header id shows. + // No target exists, so the event's durable identity is the label. ctx.emit('session/event', makeSession('orphan'), { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[orphan-session turn 1] ') + expect(out.text()).toContain('[orphan turn 1] ') }) - it('seeds labels for agents already registered before the UI installs', async () => { + it('uses an agent already registered before the UI installs as its target', async () => { // The pre-created `main` agent (and any agent surviving an HMR reload of just // this fiber) fired its `agent/created` before the UI's listener existed, so // the live listener alone would miss it. Seeding from `ctx.agents.list()` at - // install time is what keeps its turn header showing `[main turn N]` instead - // of the raw session id. + // install time preserves the terminal's fixed `[main turn N]` label. const ctx = new Context() await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) @@ -193,7 +190,7 @@ describe('createStdioChat rendering', () => { await ctx.plugin(Object.assign((inner: Context) => { createStdioChat(inner, CONFIG, runtime) }, { inject: ['agents', 'userInteraction'] })) - ctx.emit('session/event', makeSession('main'), { + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 5, trigger: { kind: 'message' } }, } as SessionEvent) expect(out.text()).toContain('[main turn 5] ') @@ -209,17 +206,28 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('\x1B[2mmid\x1B[0m') }) - it('drops the label mapping on agent/disposed', async () => { + it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') ctx.emit('agent/created', agent) ctx.emit('agent/disposed', agent) - // After disposal the map no longer resolves the agent id — fall back to the - // session header id. - ctx.emit('session/event', makeSession('main'), { + // After disposal the event belongs to a non-target session, so its durable + // identity is rendered directly. + ctx.emit('session/event', agent.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, } as SessionEvent) - expect(out.text()).toContain('[main-session turn 1] ') + expect(out.text()).toContain('[main turn 1] ') + }) + + it('keeps the target when a different agent is disposed', async () => { + const { ctx, out } = await setup() + const target = makeAgent('target') + ctx.emit('agent/created', target) + ctx.emit('agent/disposed', makeAgent('other')) + ctx.emit('session/event', target.session, { + type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, + } as SessionEvent) + expect(out.text()).toContain('[main turn 1] ') }) it('renders tool/call and tool/result session events', async () => { @@ -666,11 +674,11 @@ describe('createStdioChat input', () => { const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) input.feed('nobody home') await new Promise(r => setImmediate(r)) - expect(spy).toHaveBeenCalledWith('ui-stdio: agent "%s" is not running', 'main') + expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running') }) - it('drives the agent named in config, not a hardcoded id', async () => { - const { ctx, input } = await setup({ welcome: 'w', agent: 'worker' }) + it('drives the app-owned agent without a duplicate id config', async () => { + const { ctx, input } = await setup({ welcome: 'w' }) const agent = makeAgent('worker') ctx.agents.register(agent) input.feed('hi') diff --git a/packages/util/brand/README.md b/packages/util/brand/README.md index 8f7943def7..be25edd55e 100644 --- a/packages/util/brand/README.md +++ b/packages/util/brand/README.md @@ -4,7 +4,7 @@ The `Branded` nominal-typing primitive — a tiny, **type-only** package (no ## What `Branded` is -A brand makes structurally-identical strings non-interchangeable at the type level: an `AgentId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. +A brand makes structurally-identical strings non-interchangeable at the type level: a `SessionId` cannot be passed where a `CallId` is expected, even though both are plain `string`s at runtime. ```ts import type { Branded } from '@deepseek-ai/dsh-brand' @@ -21,6 +21,6 @@ Construction goes through the per-id factory in the OWNING package (a plain cast ## Policy: brand ids that cross package boundaries -A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), `SessionId` in `dsh-session`, `AgentId` in `dsh-agent`, `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.** +A package brands the ids it OWNS — `CallId` in `dsh-llm` (tool-call correlation), the shared agent/session `SessionId` in `dsh-session`, and `BashTaskId`/`OwnerToken` in `dsh-bash`. Branding is for ids that cross package boundaries and could plausibly be confused; **not every string needs a brand.** This package owns ONLY the primitive — no concrete id, no runtime code beyond the (erased) type. Keeping the primitive dependency-free is the point: a capability package can brand its ids without depending on an unrelated package. `dsh-bash`, for example, brands `BashTaskId`/`OwnerToken` by depending on `dsh-brand` alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. diff --git a/packages/util/brand/src/index.ts b/packages/util/brand/src/index.ts index 051ced94b7..d8c28846b3 100644 --- a/packages/util/brand/src/index.ts +++ b/packages/util/brand/src/index.ts @@ -4,15 +4,15 @@ * cross-boundary id. * * A brand makes structurally-identical strings non-interchangeable at the type - * level: an `AgentId` cannot be passed where a `CallId` is expected, even + * level: a `SessionId` cannot be passed where a `CallId` is expected, even * though both are plain strings at runtime. Construction goes through a per-id * factory in the OWNING package (a plain cast inside — zero runtime cost); * comparison, logging, and serialization all behave as ordinary strings. * * Policy: a package brands the ids it owns — `CallId` in dsh-llm (tool-call - * correlation), `SessionId` in dsh-session, `AgentId` in dsh-agent, - * `BashTaskId`/`OwnerToken` in dsh-bash. Branding is for ids that cross package - * boundaries and could plausibly be confused; not every string needs a brand. + * correlation), and `SessionId` in dsh-session; `BashTaskId`/`OwnerToken` live + * in dsh-bash. Branding is for ids that cross package boundaries and could + * plausibly be confused; not every string needs a brand. * This package owns ONLY the primitive — no concrete id, no runtime code beyond * the (erased) type — so the brand vocabulary stays dependency-free and a * package can brand its ids without depending on an unrelated capability diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index 0585889617..ac7112a0f0 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -4,7 +4,6 @@ import Loader from '@cordisjs/plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { WorkflowRunId, WorkflowService } from '@deepseek-ai/dsh-workflow' import type { WorkflowResult, WorkflowRun, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' @@ -12,6 +11,7 @@ import { CallId } from '@deepseek-ai/dsh-llm' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as toolWorkflow from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A controllable engine standing in behind ctx.workflows (the tool's only seam). */ class StubEngine extends WorkflowService { @@ -51,7 +51,7 @@ async function setup(config?: { toolName?: string; maxResultChars?: number }) { await ctx.plugin(StubEngine) await ctx.plugin(toolWorkflow, config ?? {}) const engine = ctx.workflows as StubEngine - const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent return { ctx, engine, parent } } @@ -237,7 +237,7 @@ describe('dsh-tool-workflow', () => { await ctx.plugin(SubagentService) await ctx.plugin(WorkerWorkflowEngine, { disposeGraceMs: 30 }) await ctx.plugin(toolWorkflow, {}) - const parent = { id: AgentId('caller'), options: {} } as unknown as Agent + const parent = { id: SessionId('caller'), options: {} } as unknown as Agent const controller = new AbortController() const pending = execute(ctx, { script: 'await new Promise(() => {})\nreturn 1', diff --git a/packages/workflow/workflow-workerthread/src/runtime.ts b/packages/workflow/workflow-workerthread/src/runtime.ts index 94282cb173..7dbdccbb43 100644 --- a/packages/workflow/workflow-workerthread/src/runtime.ts +++ b/packages/workflow/workflow-workerthread/src/runtime.ts @@ -38,8 +38,8 @@ */ import * as vm from 'node:vm' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools' import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools' import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow' @@ -319,7 +319,7 @@ export class WorkflowExecution { await run.dispose() throw this.cancelledError() } - const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: AgentId(run.id) } + const info: WorkflowAgentInfo = { seq, label, ...phase !== undefined ? { phase } : {}, childId: SessionId(run.id) } this.observer.agentStart(info) try { let result diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index 0e1727f877..2e13ddcb8b 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -37,7 +38,7 @@ async function setup(script: Script) { await ctx.plugin(spawn, { providerName: 'spawn' }) await ctx.plugin(WorkerWorkflowEngine, {}) ctx.llm.registerAdapter(['mock'], adapter) - const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + const parent = ctx.agentLoop.create(SessionId('parent'), { model: 'mock' }) return { ctx, parent, adapter } } @@ -73,7 +74,7 @@ return { prose, verdict: judged.verdict, confidence: judged.confidence }`, // Both children were disposed to quiescence — no live child agents remain. expect(childIds.length).toBe(2) for (const childId of childIds) { - expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + expect(ctx.agents.get(SessionId(childId))).toBeUndefined() } }) diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 7f34396d52..673a43ee0a 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -6,10 +6,10 @@ import { expect, it, vi } from 'vitest' import { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' // A fresh thread compiles the source runtime. Leave contention headroom on // shared CI runners without weakening any engine-level timeout assertion. @@ -19,7 +19,7 @@ it('runs the default config through the source worker', async () => { const ctx = new Context() const subagents = await ctx.plugin(SubagentService) const engine = await ctx.plugin(WorkerWorkflowEngine, {}) - const parent = { id: AgentId('workflow-compat-parent'), options: {} } as unknown as Agent + const parent = { id: SessionId('workflow-compat-parent'), options: {} } as unknown as Agent try { const run = ctx.workflows.start({ script: 'return 6 * 7', diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 1d6f61432a..169d80cda7 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -1,10 +1,11 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import AgentRegistry from '@deepseek-ai/dsh-agent' + import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -62,7 +63,6 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key it('runs a two-phase script in a worker thread over real children, one through the structured runtime', async () => { ctx = await harness() const parentHandle = await ctx.agents.create({ - agentId: AgentId('wf-worker-e2e-parent'), sessionId: 'wf-worker-e2e-session' as never, agentOptions: { model: 'deepseek-v4-flash' }, }) @@ -95,7 +95,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('worker workflow engine with-key expect(childIds.length).toBe(2) // The children were disposed to quiescence after collection. for (const childId of childIds) { - expect(ctx.agents.get(AgentId(childId))).toBeUndefined() + expect(ctx.agents.get(SessionId(childId))).toBeUndefined() } await parentHandle.dispose() }, 240_000) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 4ad00ee02f..09fb606d69 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -3,17 +3,17 @@ import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' -import { AgentId } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import type { WorkflowMeta, WorkflowResult, WorkflowResultInfo, WorkflowRunInfo } from '@deepseek-ai/dsh-workflow' import * as workerEngineModule from '../src/index.ts' import WorkerWorkflowEngine, { HostToWorkerType, WorkerToHostType, type Config } from '../src/index.ts' +import { SessionId } from '@deepseek-ai/dsh-session' /** A minimal parent stand-in: the engine only threads it through to the provider. */ function fakeParent(): Agent { - return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent + return { id: SessionId('workflow-parent'), options: {} } as unknown as Agent } // Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on @@ -117,7 +117,7 @@ class StubProvider implements SubagentProvider { } if (request.signal.aborted) throw new Error('child start aborted before publication') return { - id: AgentId(`stub-child-${index}`), + id: SessionId(`stub-child-${index}`), result: terminal.promise, dispose: () => { controlled.disposeCalls += 1 @@ -366,7 +366,7 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('reject-child'), + id: SessionId('reject-child'), result: Promise.reject(new Error('backend exploded')), dispose: () => Promise.resolve(), }), @@ -400,7 +400,7 @@ describe('dsh-workflow-workerthread', () => { stopReason: 'completed', } as unknown as SubagentResult const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({ - id: AgentId('raw-invalid-child'), + id: SessionId('raw-invalid-child'), result: Promise.resolve(invalid), dispose: () => Promise.resolve(), }) @@ -423,7 +423,7 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('bad-dispose-child'), + id: SessionId('bad-dispose-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -444,7 +444,7 @@ describe('dsh-workflow-workerthread', () => { capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: false }, inheritsParentContext: false, start: async () => ({ - id: AgentId('trap-child'), + id: SessionId('trap-child'), result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -771,7 +771,7 @@ describe('dsh-workflow-workerthread', () => { settle({ output: [], stopReason: 'aborted' }) }, { once: true }) return { - id: AgentId('signal-only-child'), + id: SessionId('signal-only-child'), result, dispose: () => Promise.resolve(), } @@ -1092,7 +1092,7 @@ describe('dsh-workflow-workerthread', () => { expect(request.signal.reason).toBe('workflow worker gone') ready.resolve({ - id: AgentId('late-ready-child'), + id: SessionId('late-ready-child'), result: Promise.resolve({ output: [], stopReason: 'aborted' }), dispose: () => { disposeCalls += 1 @@ -1128,7 +1128,7 @@ describe('dsh-workflow-workerthread', () => { handle.cancel('reentered from worker-death signal cleanup') }, { once: true }) return { - id: AgentId('doomed-child'), + id: SessionId('doomed-child'), result: new Promise(() => { /* never settles; the reap is the teardown */ }), dispose: () => Promise.reject(new Error('dispose exploded during reap')), } diff --git a/packages/workflow/workflow/src/types.ts b/packages/workflow/workflow/src/types.ts index 981a2da172..35ea4fb053 100644 --- a/packages/workflow/workflow/src/types.ts +++ b/packages/workflow/workflow/src/types.ts @@ -7,7 +7,8 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' -import type { Agent, AgentId } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' /** Identifies one workflow run. */ export type WorkflowRunId = Branded<'WorkflowRunId'> @@ -145,7 +146,7 @@ export interface WorkflowAgentInfo { /** The phase this agent belongs to (the `phase` option, else the current `phase()` title). */ phase?: string /** The child agent's id on the subagent seam. */ - childId: AgentId + childId: SessionId } /** How one `agent()` call settled: clean result, child failure (script sees `null`), or run cancellation. */ From 61136b22bbfabae49227843f924cc4832a07fe51 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:09:09 +0800 Subject: [PATCH 009/163] refactor: remove UI identity translations --- docs/config-catalog.md | 4 +- docs/rfc/INDEX.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 38 ++++++++++++++ .../2026-06-20-unify-agent-and-session-id.md | 38 -------------- packages/ui/acp/README.md | 4 +- packages/ui/acp/src/index.ts | 52 ++++++++----------- packages/ui/acp/tests/approval.spec.ts | 9 ++-- packages/ui/acp/tests/bridge.spec.ts | 3 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 34 ++++-------- packages/ui/jsonrpc/tests/server.spec.ts | 20 ++++--- 12 files changed, 100 insertions(+), 110 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md delete mode 100644 docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 94d613bc4e..4ae020f0be 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -11,7 +11,7 @@ A `Requires:` line lists the service keys the plugin `inject`s: its `cordis.yml` ## `@deepseek-ai/dsh-acp` -Requires: `agents` · `sessions` · `sessionPersistence` · `tools` · `userInteraction` +Requires: `agents` · `sessionPersistence` · `tools` · `userInteraction` ```ts config-catalog /** Plugin config: the agent template ACP sessions are created from. */ @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:249`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:246`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-agent` diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a5334a63e1..5ed8c6ae6d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -17,7 +17,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| -| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | @@ -87,6 +86,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Unify the agent id and the session id](implemented/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 5fe8224644..4fe7f58168 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -8,7 +8,7 @@ The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared age **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). **Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. @@ -62,6 +62,6 @@ The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` a ## Consequences -- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above. - **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. - **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md new file mode 100644 index 0000000000..79d7332553 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -0,0 +1,38 @@ +# RFC: Unify the agent id and the session id + +Status: implemented + +## Problem + +The agent factory previously carried two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced/persisted-log identity. `CreateAgentOptions` took both; `ResumeAgentOptions` took `agentId` plus `resumeSessionId`; in-process subagents minted two independent UUIDs despite recording lineage separately. + +ACP already used the same value for both identities. Where they diverged, stdio kept `labelBySession` solely to recover an agent label from session events, and hooks exposed both values for authors to reconcile. No production path reattached one live agent object to several sessions or drove one session through several agent ids. + +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) had no reservation side tables: create and resume used one `AgentCreationTransaction`, and agent/session entries used the same final-entry collision rule. Separate ids therefore did not duplicate asynchronous liveness, rollback, or quiescence machinery. Identity unification was only an API and representation simplification: it deleted one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle. + +Session itself repeated the same fact as `Session.id` and `Session.header.id`. Construction rejected a header whose id differed, so the aliases were constrained equal; the durable boundary nevertheless had to validate the duplicate, and production consumers chose between its two homes. + +## Decision + +An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; subagent creation mints one combined id; and `Session.id` derives from `header.id`. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between the ids are gone. + +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. + +`agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. + +## Alternatives considered + +**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. + +## Verification + +- Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. +- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. ACP verifies exact `Agent` ownership from the forward session map; JSON-RPC caches only disposable-child parent lineage. +- The config-driven resume-or-create policy is explicit and covered across a durable restart. +- A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Consequences + +This forecloses latent multi-session-actor and session-handoff designs and makes persisted client-chosen session identity the registry identity. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md deleted file mode 100644 index 6e3eea1515..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ /dev/null @@ -1,38 +0,0 @@ -# RFC: Unify the agent id and the session id - -Status: proposed - -## Problem - -The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced/persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. - -ACP already uses the same value for both identities. Where they diverge, stdio keeps `labelBySession` solely to recover an agent label from session events, and hooks expose both values for authors to reconcile. No production path reattaches one live agent object to several sessions or drives one session through several agent ids. - -The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no reservation side tables: create and resume use one `AgentCreationTransaction`, and agent/session entries use the same final-entry collision rule. Separate ids therefore do not duplicate asynchronous liveness, rollback, or quiescence machinery. Identity unification is only an API and representation simplification: it deletes one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle. - -Session itself repeats the same fact as `Session.id` and `Session.header.id`. Construction rejects a header whose id differs, so the aliases are constrained equal; the durable boundary must nevertheless validate the duplicate, and production consumers choose between its two homes. - -## Proposal - -Make an agent's registry id equal its session id. `CreateAgentOptions` accepts one id used for both final registry entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; Session keeps one identity home by deriving `id` from `header.id` or removing the alias. Keep the existing creation transaction, final-entry collision checks, and exact-entry detach semantics; remove only maps and fields whose sole job is translating between the ids. - -The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. - -`agent/created` and `agent/disposed` remain outside this proposal. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. - -## Alternatives considered - -**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. - -## Acceptance criteria - -- Agent create/resume and subagent creation carry one identity; `Session` stores it in one place. -- The existing creation transaction keeps final-entry collision, exact-entry detach, rollback, and quiescence guarantees without adding identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session id translation. -- The config-driven resume-or-create policy is explicit and covered across a durable restart. -- `agent/created`/`agent/disposed` are removed only if a post-change production search finds no listener; otherwise they and their publication semantics stay. -- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. - -## Risks - -This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index f4947e5d25..31a865c068 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -`inject: ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session. +`inject: ['agents', 'sessionPersistence', 'tools', 'userInteraction']` — programs against the interface packages only (never `dsh-agent-loop`). `sessionPersistence` is required because `initialize` advertises `loadSession: true`; `tools` lets a tool own how its calls render (`presentCall`/`presentResult`) — the bridge looks the definition up by name and falls back to a generic presentation when a tool declares none (see Tool-call presentation). `userInteraction` lets agent-owned `ask_user_question` calls become ACP form elicitations routed to the owning session. ### Config @@ -36,7 +36,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: ## Multi-session -The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` (forward) with a `WeakMap` reverse map so agent-scoped approval events demultiplex in O(1). Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership: the `approval/request` answerer resolves the owning session through the reverse map and prompts only there. +The bridge multiplexes N sessions over one connection. Live sessions are held in a `Map` keyed by the shared agent/session id. Agent-scoped events derive that id from `agent.session.id` and verify the record owns the exact agent object, so a foreign same-id object cannot claim the bridge's session. Every `session/event` is routed strictly to its owning record, so concurrent sessions never cross-settle or interleave their `session/update` notifications. State is per session: one in-flight prompt each, `session/cancel` aborts and settles only its own agent/prompt, and disposal drains every live session in parallel to quiescence. Permission prompts follow the same ownership and prompt only the matching session. ## Session config options diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index e795c7e177..369c8ae166 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -18,12 +18,11 @@ * turn about to start) + settle the in-flight prompt * * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to - * its own `ReactLoopAgent`. Sessions are keyed by id in `sessions` (forward) with an - * `agent→sessionId` reverse map for O(1) demux of `agent/*` events; every - * `session/event` and `agent/*` event is routed strictly to its owning session - * record, so two sessions streaming at once never interleave their - * `session/update` notifications. Permission prompts ride the same ownership - * map: the bridge answers `approval/request` for its own agents over + * its own `ReactLoopAgent`. Sessions are keyed by their shared agent/session id; + * every `session/event` and `agent/*` event is routed strictly to its owning + * session record, so two sessions streaming at once never interleave their + * `session/update` notifications. Permission prompts use the same identity: the + * bridge answers `approval/request` for its own agents over * `session/request_permission` (see the approval answerer below) — whether a * call ASKS is policy (a hook or plugin returning `ask`), not the bridge's. * @@ -106,9 +105,7 @@ export const name = 'acp' // because `initialize` advertises `loadSession: true`. `tools` lets a tool own // how its calls render (`presentCall`/`presentResult`); the bridge looks up the // definition by name and falls back to a generic presentation when absent. -// TODO(acp-session-inject): drop `sessions`; this bridge never reads -// ctx.sessions, and agent/session ownership is already behind ctx.agents. -export const inject = ['agents', 'sessions', 'sessionPersistence', 'tools', 'userInteraction'] +export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction'] /** * Build an ACP "invalid params" error whose human detail rides in the message. @@ -268,7 +265,6 @@ export const Config: Schema = Schema.object({ * map keyed by id (RFC 011 multi-session). */ interface SessionRecord { - sessionId: SessionId agent: Agent /** * The owned-agent disposer (from the {@link AgentHandle} the factory returned). @@ -353,15 +349,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // this warn sink so a throwing tool presenter is logged, not propagated. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) - // TODO(derive-acp-session-id): derive an event's id from agent.session and - // verify sessions.get(id)?.agent === agent; then remove this reverse map and - // SessionRecord.sessionId, whose sole read duplicates the same identity. - // Live sessions keyed by id (RFC 011 multi-session), plus an agent→sessionId - // reverse map so `agent/*` events (which carry only the Agent) demux in O(1). - // The forward record and weak reverse entry are installed together; removing - // the record releases its strong Agent reference, so the WeakMap entry expires. + // Live sessions keyed by their shared agent/session id (RFC 011 multi-session). const sessions = new Map() - const bySession = new WeakMap() // Session ids whose `session/load` is mid-`resume()` (the slot is reserved // before the async resume so a pipelined load/new for the SAME id can't create // two agents). Distinct ids load concurrently; a given id loads once at a time. @@ -382,20 +371,26 @@ export function apply(ctx: Context, config: AcpConfig): void { // `notify` never observes it unset — no undefined guard needed. let conn: AgentSideConnection + /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ + const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const rec = sessions.get(agent.session.id) + return rec?.agent === agent ? rec : undefined + } + userInteraction.registerProvider({ async ask(request: AskUserQuestionRequest): Promise { if (request.agent === undefined) { throw new UserInteractionError('ACP user questions must come from an agent-owned request', 'NO_AGENT') } - const sessionId = bySession.get(request.agent) - if (sessionId === undefined) { + const rec = ownedRecord(request.agent) + if (rec === undefined) { throw new UserInteractionError('ACP user question has no matching session', 'NO_SESSION') } const answers: AskUserQuestionAnswerItem[] = [] for (const question of request.questions) { const options = question.options ?? [] const response = await withAbort(conn.unstable_createElicitation( - elicitationForQuestion(sessionId, question, options), + elicitationForQuestion(rec.agent.session.id, question, options), ), request.signal).catch((error: unknown) => { if (error instanceof UserInteractionError) throw error throw new UserInteractionError('ACP elicitation request failed', 'ASK_FAILED', { cause: error }) @@ -496,7 +491,7 @@ export function apply(ctx: Context, config: AcpConfig): void { const rec = sessions.get(session.header.id) if (rec === undefined) return try { - streamSessionEventUpdate(rec.sessionId, event, notify, rec.presenter, { + streamSessionEventUpdate(rec.agent.session.id, event, notify, rec.presenter, { enabled: rec.terminalEnabled, cwd: session.header.cwd, }, { includeUserMessages: false }) @@ -527,12 +522,12 @@ export function apply(ctx: Context, config: AcpConfig): void { // allow_always is a grant-storage design the approval RFC defers, so the // prompt never offers a durable grant the harness could not honor. ctx.on('approval/request', (req, next) => { - const sessionId = bySession.get(req.agent) + const rec = ownedRecord(req.agent) // The protocol requires `toolCall` (the prompt renders attached to it), so // a request without a callId has nothing to attach to — delegate. - if (sessionId === undefined || req.callId === undefined) return next() + if (rec === undefined || req.callId === undefined) return next() return conn.requestPermission({ - sessionId, + sessionId: rec.agent.session.id, toolCall: { toolCallId: req.callId }, options: [ { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, @@ -639,8 +634,7 @@ export function apply(ctx: Context, config: AcpConfig): void { // turn) leaves the switch pending — it runs no step, so nothing executes // or assembles under a stale value. ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { - const sessionId = bySession.get(agent) - const rec = sessionId === undefined ? undefined : sessions.get(sessionId) + const rec = ownedRecord(agent) if (rec !== undefined) flushPendingSwitches(rec) return next() }) @@ -699,9 +693,7 @@ export function apply(ctx: Context, config: AcpConfig): void { await handle.dispose() throw internalError('connection closed during session/new') } - bySession.set(handle.agent, sessionId) sessions.set(sessionId, { - sessionId, agent: handle.agent, dispose: () => handle.dispose(), presenter: makePresenter(handle.agent), @@ -773,13 +765,11 @@ export function apply(ctx: Context, config: AcpConfig): void { throw invalidParams('connection closed during session/load') } const agent = handle.agent - bySession.set(agent, sessionId) // Snapshot the terminal capability ONCE for this session (used by both // the replay below and the post-load live stream) so a later // `initialize` can't desync the call/result of a tool card. const terminalEnabled = terminalOutputCap const record: SessionRecord = { - sessionId, agent, dispose: () => handle.dispose(), presenter: makePresenter(agent), diff --git a/packages/ui/acp/tests/approval.spec.ts b/packages/ui/acp/tests/approval.spec.ts index 36846ac728..65679cd905 100644 --- a/packages/ui/acp/tests/approval.spec.ts +++ b/packages/ui/acp/tests/approval.spec.ts @@ -90,9 +90,12 @@ describe('acp bridge — approval answerer', () => { await harness.ctx.plugin(ApprovalService) harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) - // Not created through the bridge: no bySession entry, so the answerer must - // call next() — nobody else answers, so the seam fails closed. - const foreign = { session: { events: [{ type: 'turn/start' }], append: () => ({}) } } as unknown as Agent + const { agent } = await ownedAgentRequest(harness) + // Even an impostor that claims the bridge-owned session id must delegate: + // ownership requires the exact Agent object stored in the session record. + const foreign = { + session: { id: agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, + } as unknown as Agent await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'echo', callId: CallId('c') })) .resolves.toBe('unavailable') expect(harness.permissionRequests).toHaveLength(0) diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index 7e780348ba..e7170c380f 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -205,7 +205,8 @@ describe('acp bridge', () => { await expect(harness.ctx.userInteraction.ask({ questions: [{ id: 'x', question: 'No agent?' }] })) .rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_AGENT' }) - await expect(harness.ctx.userInteraction.ask({ agent: { id: 'other' } as typeof agent, questions: [{ id: 'x', question: 'No session?' }] })) + const impostor = { session: { id: agent.session.id } } as typeof agent + await expect(harness.ctx.userInteraction.ask({ agent: impostor, questions: [{ id: 'x', question: 'No session?' }] })) .rejects.toMatchObject({ code: 'NO_SESSION' }) harness.onElicitation = () => ({ action: 'cancel' }) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 007bd1a5f1..933023a2d9 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`) and demuxes `subagent/end` through the registry. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 4dbfeb0fa2..cfa083c394 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -58,11 +58,6 @@ interface SessionRecord { activePrompt: boolean } -interface SubagentRecord { - childSessionId: string - parentSessionId: string | undefined -} - /** * The SDK server over a booted harness context. Constructing it subscribes to * the context's `session/event`, `session/created`, `agent/created`, and @@ -76,7 +71,7 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly subagentSessions = new Map() + private readonly subagentParents = new Map() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -100,29 +95,22 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache agent → session lineage on creation: by the time `subagent/end` - // fires the child agent may already be disposed and gone from the registry. + // Cache parent lineage on creation: by the time `subagent/end` fires the + // child agent may already be disposed and gone from the registry. The child + // session id needs no cache because it is the shared agent/session id. this.disposers.push(ctx.on('agent/created', (agent) => { - this.subagentSessions.set(String(agent.id), { - childSessionId: String(agent.session.id), - parentSessionId: agent.session.header.parentSession === undefined - ? undefined - : String(agent.session.header.parentSession), - }) + const parentSessionId = agent.session.header.parentSession + if (parentSessionId !== undefined) this.subagentParents.set(agent.id, parentSessionId) })) this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { - const rec = this.subagentSessions.get(String(info.id)) const agent = this.ctx.agents.get(info.id) - const childSessionId = rec?.childSessionId ?? (agent === undefined ? undefined : String(agent.session.id)) - const parentSessionId = rec?.parentSessionId ?? ( - agent?.session.header.parentSession === undefined ? undefined : String(agent.session.header.parentSession) - ) - if (childSessionId === undefined) return + const parentSessionId = this.subagentParents.get(info.id) ?? agent?.session.header.parentSession + this.subagentParents.delete(info.id) this.transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), - ...(parentSessionId === undefined ? {} : { parentSessionId }), - childSessionId, + ...(parentSessionId === undefined ? {} : { parentSessionId: String(parentSessionId) }), + childSessionId: String(info.id), status: info.stopReason === 'completed' ? 'ok' : 'error', stopReason: info.stopReason, ...(info.lastAssistantMessage === undefined ? {} : { lastAssistantMessage: info.lastAssistantMessage }), @@ -195,7 +183,7 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.subagentSessions.clear() + this.subagentParents.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 9260a59aae..433119e046 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -272,6 +272,9 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) + // The backend may dispose the child before publishing its run outcome; + // only the cached parent lineage should be needed at this point. + await handle.dispose() await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), @@ -292,7 +295,6 @@ describe('HarnessSdkServer', () => { }, }) - await handle.dispose() await parentHandle.dispose() await server.shutdown() } finally { @@ -301,7 +303,7 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live agent lineage for uncached subagent end events', async () => { + it('falls back to live lineage and treats the shared id as the child session id', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -365,10 +367,16 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }, }) - expect(transport.notifications.some(n => - n.method === 'subagent.finished' - && n.params?.agentId === 'missing-child-agent', - )).toBe(false) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'fork', + agentId: 'missing-child-agent', + childSessionId: 'missing-child-agent', + status: 'error', + stopReason: 'error', + }, + }) await server.shutdown() } finally { From 17c99efcc14c76bbc548f64ec536d95a2dbb25c5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:22:20 +0800 Subject: [PATCH 010/163] fix: complete unified subagent identity --- docs/module-graph.md | 12 ++++-- docs/rfc/INDEX.md | 2 +- .../architecture/2026-06-20-branded-ids.md | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 38 +++++++++++++++++++ .../2026-06-20-unify-agent-and-session-id.md | 38 ------------------- packages/subagent/subagent-acp/package.json | 2 + packages/subagent/subagent-acp/src/run.ts | 16 ++++---- .../subagent-acp/tests/mock-acp-server.ts | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 3 +- packages/subagent/subagent/package.json | 2 + packages/support/subagent-mock/package.json | 2 + packages/workflow/workflow/package.json | 1 + pnpm-lock.yaml | 9 +++++ 13 files changed, 77 insertions(+), 54 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md delete mode 100644 docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md diff --git a/docs/module-graph.md b/docs/module-graph.md index e4d0b3367d..8ac71b11cc 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -179,6 +179,7 @@ flowchart TD pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm + pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_llm @@ -215,6 +216,7 @@ flowchart TD pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -265,6 +267,7 @@ flowchart TD pkg_agent_core --> pkg_tools pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent @@ -285,6 +288,7 @@ flowchart TD pkg_hooks_claude --> pkg_tools pkg_subagent_mock --> pkg_agent pkg_subagent_mock --> pkg_llm + pkg_subagent_mock --> pkg_session pkg_subagent_mock --> pkg_subagent pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm @@ -362,14 +366,14 @@ flowchart TD | [`invariants`](../packages/support/invariants) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -380,11 +384,11 @@ flowchart TD | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`agent-core`](../packages/core/agent-core) | `core` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tools`](../packages/core/tools) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index a5334a63e1..5ed8c6ae6d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -17,7 +17,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| -| [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Drop unconsumed skill provider events](proposed/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](proposed/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | @@ -87,6 +86,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | +| [Unify the agent id and the session id](implemented/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 5fe8224644..4fe7f58168 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -8,7 +8,7 @@ The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared age **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias that the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal calls the "bash owner-token alias hole". +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). **Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. @@ -62,6 +62,6 @@ The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-bash` a ## Consequences -- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unify-the-agent-id-and-the-session-id](../../proposed/simplification/2026-06-20-unify-agent-and-session-id.md) proposal (both touch the session-id / owner-token boundary); if that proposal lands, `OwnerToken` still stays distinct from the unified id for the decoupling reason above. +- **Mechanical churn across two surfaces.** Propagating brands touches the bash seam (interface + impl + consumer) and the ACP session-id surface plus the persistence coordinator. The churn is broad but low-severity: a missed site is a compile error, not a silent bug. The change is observably type-only — no snapshot or e2e behavioral diff. It sits next to the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md) because both touch the session-id / owner-token boundary; `OwnerToken` stays distinct from the unified id for the decoupling reason above. - **Brands do not validate.** A brand is a confusability guard, not a correctness proof: a *wrong* session id that is still a well-formed string passes the type checker exactly as before. This RFC does not close that gap (see Out of scope) — it only stops the *category* error of passing the wrong *kind* of id. - **The "where to stop" line stays a judgment call.** Branding `BashTaskId` but not `ToolName`, `OwnerToken` but not `ModelId`, is a taste call about which strings "could plausibly be confused." Reasonable reviewers may want more or fewer; the policy in `brand.ts` is the tie-breaker, and this RFC errs toward the ids that are model-facing or used for access control. diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md new file mode 100644 index 0000000000..be1052830a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -0,0 +1,38 @@ +# RFC: Unify the agent id and the session id + +Status: implemented + +## Problem + +The agent factory previously carried two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced/persisted-log identity. `CreateAgentOptions` took both; `ResumeAgentOptions` took `agentId` plus `resumeSessionId`; in-process subagents minted two independent UUIDs despite recording lineage separately. + +ACP already used the same value for both identities. Where they diverged, stdio kept `labelBySession` solely to recover an agent label from session events, and hooks exposed both values for authors to reconcile. No production path reattached one live agent object to several sessions or drove one session through several agent ids. + +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) had no reservation side tables: create and resume used one `AgentCreationTransaction`, and agent/session entries used the same final-entry collision rule. Separate ids therefore did not duplicate asynchronous liveness, rollback, or quiescence machinery. Identity unification was only an API and representation simplification: it deleted one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle. + +Session itself repeated the same fact as `Session.id` and `Session.header.id`. Construction rejected a header whose id differed, so the aliases were constrained equal; the durable boundary nevertheless had to validate the duplicate, and production consumers chose between its two homes. + +## Decision + +An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process and ACP subagent creation use the child session id; and `Session.id` derives from `header.id`. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between the ids are gone. + +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. + +`agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. + +## Alternatives considered + +**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. + +## Verification + +- Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. +- The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend uses the child server's returned session id as its run id. +- The config-driven resume-or-create policy is explicit and covered across a durable restart. +- A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. +- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. + +## Consequences + +This forecloses latent multi-session-actor and session-handoff designs and makes persisted client-chosen session identity the registry identity. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md deleted file mode 100644 index 6e3eea1515..0000000000 --- a/docs/rfc/proposed/simplification/2026-06-20-unify-agent-and-session-id.md +++ /dev/null @@ -1,38 +0,0 @@ -# RFC: Unify the agent id and the session id - -Status: proposed - -## Problem - -The agent factory carries two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced/persisted-log identity. `CreateAgentOptions` takes both; `ResumeAgentOptions` takes `agentId` plus `resumeSessionId`; in-process subagents mint two independent UUIDs despite recording lineage separately. - -ACP already uses the same value for both identities. Where they diverge, stdio keeps `labelBySession` solely to recover an agent label from session events, and hooks expose both values for authors to reconcile. No production path reattaches one live agent object to several sessions or drives one session through several agent ids. - -The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) has no reservation side tables: create and resume use one `AgentCreationTransaction`, and agent/session entries use the same final-entry collision rule. Separate ids therefore do not duplicate asynchronous liveness, rollback, or quiescence machinery. Identity unification is only an API and representation simplification: it deletes one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle. - -Session itself repeats the same fact as `Session.id` and `Session.header.id`. Construction rejects a header whose id differs, so the aliases are constrained equal; the durable boundary must nevertheless validate the duplicate, and production consumers choose between its two homes. - -## Proposal - -Make an agent's registry id equal its session id. `CreateAgentOptions` accepts one id used for both final registry entries; resume registers the agent under the resumed session id; subagent creation mints one combined id; Session keeps one identity home by deriving `id` from `header.id` or removing the alias. Keep the existing creation transaction, final-entry collision checks, and exact-entry detach semantics; remove only maps and fields whose sole job is translating between the ids. - -The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. - -`agent/created` and `agent/disposed` remain outside this proposal. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. - -## Alternatives considered - -**Keep separate routing and log identities.** A stable configured label plus a fresh durable conversation is useful, but it does not require two live identities: the label can remain configuration/display metadata while the combined per-run `SessionId` owns routing and persistence. Keeping two ids would preserve translation maps and permit impossible pairings without adding lifecycle capability. - -## Acceptance criteria - -- Agent create/resume and subagent creation carry one identity; `Session` stores it in one place. -- The existing creation transaction keeps final-entry collision, exact-entry detach, rollback, and quiescence guarantees without adding identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage need no agent/session id translation. -- The config-driven resume-or-create policy is explicit and covered across a durable restart. -- `agent/created`/`agent/disposed` are removed only if a post-change production search finds no listener; otherwise they and their publication semantics stay. -- Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. - -## Risks - -This forecloses latent multi-session-actor and session-handoff designs, makes persisted client-chosen session identity the registry identity, and touches every factory fixture. If separate routing identity becomes a real requirement, it needs an explicit lifecycle design rather than an unconstrained caller-supplied pair. diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index e73d861a79..4c34ebe3fc 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "@deepseek-ai/dsh-subagent-subprocess": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -35,6 +36,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-subprocess": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.4", diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 1e82ef85b0..1cc220204f 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -23,7 +23,6 @@ */ import { spawn } from 'node:child_process' -import { randomUUID } from 'node:crypto' import { Readable, Writable } from 'node:stream' import { ClientSideConnection, @@ -190,8 +189,6 @@ function toError(value: unknown): Error { * @returns the ready run handle for the child subprocess. */ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise { - const id = SessionId(randomUUID()) - if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP @@ -260,7 +257,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe ), ) - let sessionId: string | undefined + let sessionId: SessionId | undefined // Resolves when a cancel is requested, so `result` can settle `aborted` even // if the child never cooperates with `session/cancel` (it ignores the notify, // or the prompt wedges). The result path races this against the ACP drive: the @@ -309,7 +306,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe clientCapabilities: {}, }) const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId + sessionId = SessionId(session.sessionId) if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), spawnFailed.then((err): never => { throw err }), @@ -321,6 +318,11 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started') throw toError(error) } + // The startup race can fulfill only after newSession assigned the id; this + // guard keeps that cross-closure invariant explicit for TypeScript. + /* v8 ignore next */ + if (sessionId === undefined) throw new Error('ACP child published without a session id') + const runId = sessionId const result: Promise = (async (): Promise => { try { @@ -332,7 +334,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // succeeds, transport/process failure rejects the in-flight prompt RPC. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. - const promptResult = await conn.prompt({ sessionId: sessionId as string, prompt: toAcpPrompt(request.prompt) }) + const promptResult = await conn.prompt({ sessionId: runId, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ @@ -366,7 +368,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let disposal: Promise | undefined return { - id, + id: runId, result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index fb200f3505..5145941526 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -99,7 +99,7 @@ function makeAgent(conn: AgentSideConnection): Agent { writeFileSync(NEWSESSION_GATE.ready, 'at-newSession') while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10)) } - return { sessionId: randomUUID() } + return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() } }, authenticate(_params: AuthenticateRequest): Promise { // No auth methods advertised; nothing to do. diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index eed3cfe1ed..115d5c3e97 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -122,8 +122,9 @@ describe('buildChildEnv', () => { describe('dsh-subagent-acp', () => { it('drives a child process to completion and returns its streamed output', async () => { - const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn' }) + const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' }) const run = await ctx.subagents.start('acp', request('do X')) + expect(run.id).toBe('acp-child-session') const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('hello from acp child') diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index eb0dbf8da0..cf1ebf9485 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -32,6 +33,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "cordis": "^4.0.0-rc.6" } diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json index 8980cc35d2..528691daef 100644 --- a/packages/support/subagent-mock/package.json +++ b/packages/support/subagent-mock/package.json @@ -24,6 +24,7 @@ "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.6" }, @@ -33,6 +34,7 @@ "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@cordisjs/plugin-loader": "^1.0.0-rc.4", "cordis": "^4.0.0-rc.6" diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index a6c004d6d0..4d79d672a1 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -25,6 +25,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", "cordis": "^4.0.0-rc.6" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d02b4a9ca6..6214564eae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -827,6 +827,9 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -852,6 +855,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent @@ -1085,6 +1091,9 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent From 9ebb40b84b404b3568a53cd6fab1b966e2c82c74 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:28:04 +0800 Subject: [PATCH 011/163] fix: keep SDK subagent notifications local --- .../2026-06-20-unify-agent-and-session-id.md | 2 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 8 +++++++- packages/ui/jsonrpc/tests/server.spec.ts | 16 +++++----------- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index 10521447e0..bb77717e3b 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -28,7 +28,7 @@ The config-driven path keeps `agents[].id` as a stable configuration label, not - Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. - The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend uses the child server's returned session id as its run id; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only disposable-child parent lineage. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend uses the child server's returned session id as its run id; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only local disposable-child parent lineage while leaving remote runs outside its local-session notification pair. - The config-driven resume-or-create policy is explicit and covered across a durable restart. - A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 933023a2d9..2324227ff7 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. Runs from remote providers are not reported through this local-session notification pair because they create no local `session/created`/`subagent.started` edge. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index cfa083c394..3c803a6c8c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -104,8 +104,14 @@ export class HarnessSdkServer { })) this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { const agent = this.ctx.agents.get(info.id) - const parentSessionId = this.subagentParents.get(info.id) ?? agent?.session.header.parentSession + const cachedParentSessionId = this.subagentParents.get(info.id) this.subagentParents.delete(info.id) + // This protocol reports LOCAL child sessions, paired with the + // session/created-driven subagent.started notification above. A remote + // provider may use a real remote SessionId for its run, but that session + // does not exist in this harness and therefore has no paired start event. + if (cachedParentSessionId === undefined && agent === undefined) return + const parentSessionId = cachedParentSessionId ?? agent?.session.header.parentSession this.transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 433119e046..e7f591b88f 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -303,7 +303,7 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live lineage and treats the shared id as the child session id', async () => { + it('falls back to live lineage and ignores runs without a local child session', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -367,16 +367,10 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }, }) - expect(transport.notifications).toContainEqual({ - method: 'subagent.finished', - params: { - provider: 'fork', - agentId: 'missing-child-agent', - childSessionId: 'missing-child-agent', - status: 'error', - stopReason: 'error', - }, - }) + expect(transport.notifications.some(n => + n.method === 'subagent.finished' + && n.params?.agentId === 'missing-child-agent', + )).toBe(false) await server.shutdown() } finally { From 225796c90dc5a6ea9b4afa2a9f4081f9d48606ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:32:35 +0800 Subject: [PATCH 012/163] refactor: hide the concrete agent loop --- docs/architecture.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 26 ++++----- docs/cordis-catalog/services.md | 8 ++- docs/core-data-structures/core.md | 2 +- docs/event-producer-consumer.md | 26 ++++----- .../2026-06-21-subagent-capability-seam.md | 2 +- examples/coding-agent/tests/code-mode.e2e.ts | 6 +- examples/coding-agent/tests/harness.ts | 6 +- examples/coding-agent/tests/resume.e2e.ts | 5 +- examples/cordis-agent/tests/harness.ts | 6 +- .../bash/tool-bash/tests/integration.spec.ts | 8 +-- .../tests/compact-loop-repro.spec.ts | 6 +- .../cordis/tool-cordis/src/api-catalog.ts | 4 +- .../tool-cordis/tests/integration.spec.ts | 6 +- packages/core/README.md | 2 +- packages/core/agent-loop/README.md | 10 ++-- packages/core/agent-loop/src/index.ts | 7 +-- packages/core/agent-loop/tests/agent.spec.ts | 42 +++++++------- packages/core/agent-loop/tests/cancel.spec.ts | 22 +++++--- .../tests/config-session-id.spec.ts | 16 +++--- .../agent-loop/tests/coverage-edges.spec.ts | 16 ++++-- .../agent-loop/tests/interception.spec.ts | 10 ++-- packages/core/agent-loop/tests/loop.spec.ts | 22 +++++--- .../core/agent-loop/tests/properties.spec.ts | 12 ++-- .../tests/request-reconstruction.spec.ts | 10 ++-- packages/core/agent-loop/tests/resume.spec.ts | 24 ++++---- .../agent-loop/tests/review-fixes.spec.ts | 56 ++++++++++--------- .../agent-loop/tests/scope-lifecycle.spec.ts | 8 +-- .../core/agent-loop/tests/tool-order.spec.ts | 6 +- .../core/agent-loop/tests/turn-stop.spec.ts | 6 +- packages/core/agent/src/types.ts | 5 +- .../tests/repeat-tool-guard.spec.ts | 12 ++-- .../hooks/hooks-claude/tests/bridge.spec.ts | 8 +-- .../hooks/hooks-claude/tests/coverage.spec.ts | 14 ++--- .../hooks/hooks-codex/tests/bridge.spec.ts | 8 +-- .../hooks/hooks-codex/tests/coverage.spec.ts | 10 ++-- .../todo/tool-todo/tests/integration.spec.ts | 6 +- packages/ui/acp/README.md | 2 +- packages/ui/acp/src/index.ts | 2 +- 40 files changed, 234 insertions(+), 217 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a1f0cae9a8..272191c9c6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | -| `ctx.agentLoop` | `dsh-agent-loop` | shipped `ReactLoopAgent` driver | +| `ctx.agentLoop` | `dsh-agent-loop` | shipped concrete `Agent` driver | ### Capability Services diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4ae020f0be..20d90d1e8b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -129,7 +129,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:323`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a5e018079a..32d697f1eb 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -23,7 +23,7 @@ An agent's fully composed scoped world was published in the AgentRegistry. Its s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:304`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:303`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -35,7 +35,7 @@ An agent was removed from the registry. The concrete AgentLoop lifecycle emits t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:318`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:593`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:592`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -61,7 +61,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:426`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:425`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -73,7 +73,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:444`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:443`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -85,7 +85,7 @@ A message entered the agent's inbox (queued or steering). Content and the resolv Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:348`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:347`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -97,7 +97,7 @@ Waterfall: shape the step's call configuration — model switching, sampling ove Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:473`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:472`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -113,7 +113,7 @@ The seed is a frozen empty list; a contributing listener returns a NEW array — Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:525`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:524`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -125,7 +125,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:369`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -137,7 +137,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:333`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -149,7 +149,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:540`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:539`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -161,7 +161,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:558`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:557`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -173,7 +173,7 @@ Serial terminal-stop checkpoint after the ordinary `agent/turn-continuation` wat Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:575`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4d162a55f5..89a8dc4edb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -11,15 +11,17 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## `ctx.agentLoop` — `AgentLoop` -Concrete ReactLoopAgent factory and driver service. +Concrete agent factory and driver service. ```ts cordis-catalog -create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent +create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:337`](../../packages/core/agent-loop/src/index.ts) +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:336`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index c6b74ed3c0..897b04240b 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -248,7 +248,7 @@ The fourteen event variants (`turn/start`, `turn/end`, `step/start`, `step/end`, ## The agent handle -`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is `ReactLoopAgent` in dsh-agent-loop; nothing outside the loop depends on the implementation. +`Agent` is the surface every plugin (UI, hooks, orchestrators) programs against. The concrete implementation is package-internal to dsh-agent-loop; nothing outside the loop depends on it. Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 80389608cc..ba0d9722b2 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:348`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:473`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:525`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:576`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:303`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:318`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:592`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:425`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:443`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:347`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:472`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:524`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:368`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:332`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:539`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:557`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:575`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:70`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:123`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:138`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 652eac5522..3c42e83691 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -10,7 +10,7 @@ The harness has a long-deferred seam for **subagents** — an agent delegating w The distinctive requirement — the one that shapes the whole design — is that **multiple subagent implementations must coexist at runtime**. A parent may want a cheap in-process child for a scoped subtask AND an isolated out-of-process child (over ACP) in the same session. The transports we foresee: -- **in-process** — a child `ReactLoopAgent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); +- **in-process** — a child concrete `Agent` on the same `Context` (the cheapest, and nearly free given the existing agent factory); - **ACP** — act as an ACP *client* driving another agent process (which can be another instance of ourselves); - later: **A2A**, the **Codex app-server**, and the **Claude Code Agent SDK** — each the same out-of-process "start a child, prompt it, stream updates, cancel" shape as the ACP backend. diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/coding-agent/tests/code-mode.e2e.ts index 22446f0dc3..176b2898d6 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/coding-agent/tests/code-mode.e2e.ts @@ -8,9 +8,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' @@ -58,7 +58,7 @@ async function codeModeHarness(cwd: string): Promise { return harness } -function waitForIdle(harness: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(harness: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = harness.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/harness.ts b/examples/coding-agent/tests/harness.ts index dd0bc42a1b..6535e30ac3 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/coding-agent/tests/harness.ts @@ -4,8 +4,8 @@ import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' @@ -70,7 +70,7 @@ export async function codingHarness(workdir: string, options: CodingHarnessOptio return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/coding-agent/tests/resume.e2e.ts index 48e135ad92..486e32608c 100644 --- a/examples/coding-agent/tests/resume.e2e.ts +++ b/examples/coding-agent/tests/resume.e2e.ts @@ -3,7 +3,6 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import type { Context } from 'cordis' -import type { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' @@ -41,7 +40,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses const first = (await ctx.agents.create({ sessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent first.send([{ type: 'text', text: `Remember this code for later: ${SECRET}. Just acknowledge it.` }]) await waitForIdle(ctx, first) await ctx.fiber.dispose() @@ -54,7 +53,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('resume: continue a persisted ses const resumed = (await ctx.agents.resume({ resumeSessionId: SESSION_ID, agentOptions: { model: 'deepseek-v4-flash' }, - })).agent as ReactLoopAgent + })).agent expect(resumed.session.id).toBe(SESSION_ID) // The prior user turn is in the rehydrated log before the model is asked. expect(JSON.stringify(resumed.session.deriveMessages())).toContain(SECRET) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts index 78e5b0bb93..c9ccd59767 100644 --- a/examples/cordis-agent/tests/harness.ts +++ b/examples/cordis-agent/tests/harness.ts @@ -3,8 +3,8 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' @@ -34,7 +34,7 @@ export async function cordisHarness(): Promise { return ctx } -export function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +export function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 3ab3ae6628..b8275fedba 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -5,9 +5,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import { BashTaskId } from '@deepseek-ai/dsh-bash' import * as ToolBash from '@deepseek-ai/dsh-tool-bash' @@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 52a2ecd134..c9d9e34b26 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -7,9 +7,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { isToolPairingBalanced } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { SurfaceEvent } from '@deepseek-ai/dsh-session' @@ -105,7 +105,7 @@ async function harness(toolSteps: number): Promise<{ ctx: Context; compact: Repr return { ctx, compact } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5721bd97d9..23a8c8e1d0 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -54,9 +54,9 @@ export interface TypeApiEntry { export const SERVICE_API: readonly ServiceApiEntry[] = [ { key: 'agentLoop', - summary: 'Concrete ReactLoopAgent factory and driver service.', + summary: 'Concrete agent factory and driver service.', methods: [ - 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent', + 'create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent', 'async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise', 'async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise', ], diff --git a/packages/cordis/tool-cordis/tests/integration.spec.ts b/packages/cordis/tool-cordis/tests/integration.spec.ts index 0d4c6c5c18..25adece9bc 100644 --- a/packages/cordis/tool-cordis/tests/integration.spec.ts +++ b/packages/cordis/tool-cordis/tests/integration.spec.ts @@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as ToolCordis from '../src/index.ts' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { REVERSE_TOOL_CODE } from './helpers.ts' @@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/core/README.md b/packages/core/README.md index b132b04d49..d822627136 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,7 +9,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-loop/` | The concrete `Agent` plugin and loop driver | `ctx.agentLoop` | | `agent-core/` | Bundle plugin: the default executor-less/UI-less spine as code | (loads the spine) | `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f40577bb95..f5b25c0e25 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -1,6 +1,6 @@ # dsh-agent-loop -THE concrete agent plugin: `ReactLoopAgent` and the loop driver. Implements the `Agent` interface and drives the session/turn/step lifecycle. +THE concrete agent plugin and loop driver. Its package-internal implementation satisfies the `Agent` interface and drives the session/turn/step lifecycle. This is the only package in the harness that contains concrete loop logic. Everything else is an abstract service or a plugin against extension seams — new behavior goes into plugins, not here. @@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity. +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): Agent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): @@ -42,11 +42,9 @@ interface Config { Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. -### Exported concrete class +### Internal concrete driver -- `ReactLoopAgent` — the concrete `Agent` implementation. Its inbox is a JavaScript native-private field, and one prepared session can be claimed by only one concrete driver. Everything observable happens through session events and the `agent/*` event taxonomy. - -`Inbox`, `runLoop`, and the instance-bound publication/start controls are package-internal. The package root does not export them, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than constructing or starting the driver internals. `ReactLoopAgent.send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. +The concrete `Agent` class, its `Inbox`, `runLoop`, and instance-bound publication/start controls are package-internal. The package root exports only the plugin/service/config contract, and the package exports map exposes no `./src/*` escape hatch; lifecycle owners create agents through `ctx.agents` rather than naming, constructing, or starting driver internals. The concrete `send()` and running `steer()` materialize content plus resolved source once as detached, deeply frozen lossless JSON, then share that accepted record between `agent/queued` and the inbox; malformed data throws before either boundary. One prepared session can be claimed by only one concrete driver, and everything observable happens through session events and the `agent/*` event taxonomy. ### Loop lifecycle (`loop.ts`) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33276c01f8..c9c97fb206 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -12,6 +12,7 @@ import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import { agentEvents } from '@deepseek-ai/dsh-agent' import type { + Agent, AgentFactory, AgentHandle, AgentOptions, @@ -32,8 +33,6 @@ import { } from './agent.ts' import type { PreparedReactLoopAgent } from './agent.ts' -export { ReactLoopAgent } from './agent.ts' - /** Fiber states that cannot own or serve a new lifecycle. */ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.UNLOADING, @@ -333,7 +332,7 @@ export interface Config { })[] } -/** Concrete ReactLoopAgent factory and driver service. */ +/** Concrete agent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] @@ -389,7 +388,7 @@ export class AgentLoop extends Service implements AgentFactory { * @param meta - optional fresh-session workspace metadata. * @returns the published running agent. */ - create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): ReactLoopAgent { + create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent { const loopCtx = this.runtime.ctx const transaction = new AgentCreationTransaction(loopCtx, this.ctx, this.ownership, id) try { diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 56d6489fbe..7969d18200 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -4,11 +4,15 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' -import { bindReactLoopAgentContext, prepareReactLoopAgent } from '../src/agent.ts' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { bindReactLoopAgentContext, prepareReactLoopAgent, type ReactLoopAgent } from '../src/agent.ts' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -21,7 +25,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -32,7 +36,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopAgent['status']): Promise { +function waitForStatus(ctx: Context, agent: Agent, expected: Agent['status']): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === expected) { @@ -43,11 +47,11 @@ function waitForStatus(ctx: Context, agent: ReactLoopAgent, expected: ReactLoopA }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -describe('ReactLoopAgent', () => { +describe('Agent', () => { it('rejects access before context binding and a second driver for one session', async () => { const ctx = new Context() await ctx.plugin(SessionStore) @@ -70,7 +74,7 @@ describe('ReactLoopAgent', () => { expect(agent.options).toBe(options) expect(agent.id).toBe('owned-bindings') expect(agent.session.id).toBe(agent.id) - expect(() => { bindReactLoopAgentContext(agent, new Context()) }).toThrow(/context is already bound/) + expect(() => { bindReactLoopAgentContext(agent as ReactLoopAgent, new Context()) }).toThrow(/context is already bound/) await ctx.fiber.dispose() }) @@ -78,14 +82,14 @@ describe('ReactLoopAgent', () => { it('send() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.send([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -93,14 +97,14 @@ describe('ReactLoopAgent', () => { it('steer() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.steer([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -108,14 +112,14 @@ describe('ReactLoopAgent', () => { it('inject() throws after disposal', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(() => { agent.inject([{ type: 'text', text: 'too late' }]) }).toThrow('disposed') }) @@ -251,7 +255,7 @@ describe('ReactLoopAgent', () => { }) it('disposer is idempotent (double-stop)', async () => { - // Create a bare ReactLoopAgent and start it through the package-internal + // Create a bare Agent and start it through the package-internal // test seam. Then call its disposer twice — the second call hits the // early-return branch. const ctx = new Context() @@ -367,7 +371,7 @@ describe('ReactLoopAgent', () => { it('whenIdle() subscribed while running resolves via done when the agent is then disposed', async () => { // Covers the waiter's disposed arm: whenIdle() queues an internal waiter // while running (not the fast path), then the disposer settles it and chains - // `done` (loop exit), not an eager resolve. A bare ReactLoopAgent + direct + // `done` (loop exit), not an eager resolve. A bare Agent + direct // internal driver disposer keeps the emit synchronous. const ctx = new Context() await ctx.plugin(LlmService) @@ -401,7 +405,7 @@ describe('ReactLoopAgent', () => { // it. Regression for the round-3 whenIdle finding. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -422,7 +426,7 @@ describe('ReactLoopAgent', () => { // only after `done` — i.e. the loop has actually exited. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -430,7 +434,7 @@ describe('ReactLoopAgent', () => { await new Promise(r => setTimeout(r, 30)) let doneResolved = false - void agent.done.then(() => { doneResolved = true }) + void driverDone(agent).then(() => { doneResolved = true }) await fiber.dispose() // sets status disposed, aborts, drains the loop expect(agent.status).toBe('disposed') diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 82b1f6c58c..d88d58a340 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -16,11 +16,15 @@ import LlmService, { type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -33,12 +37,12 @@ async function harness(adapter: MockAdapter) { return ctx } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } /** Resolve on the agent's next idle transition (event-based, not status poll). */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -47,7 +51,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { } /** All user-message texts recorded in the log (to assert what actually ran). */ -function userTexts(agent: ReactLoopAgent): string[] { +function userTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .flatMap(e => e.type === 'user/message' ? e.data.content : []) @@ -207,7 +211,7 @@ describe('Agent.cancel()', () => { sessionId: SessionId('dispose-prefix-session'), agentOptions: { model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -220,7 +224,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await new Promise(resolve => setTimeout(resolve, 0)) await disposalDone - await agent.done + await driverDone(agent) // No step opened, no model call ran, and the turn closed disposed. expect(streamed).toBe(false) @@ -337,7 +341,7 @@ describe('Agent.cancel()', () => { sessionId: SessionId('dispose-step-start-session'), agentOptions: { model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent let disposalDone: Promise | undefined let streamed = false @@ -348,7 +352,7 @@ describe('Agent.cancel()', () => { send(agent, 'go') await disposalDone - await agent.done + await driverDone(agent) expect(streamed).toBe(false) expect(adapter.requests).toHaveLength(0) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 134ea68c32..428581bb58 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -7,16 +7,16 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] afterEach(async () => { for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true }) }) -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -57,7 +57,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg')])) - const a1 = ctx1.agents.list()[0] as ReactLoopAgent + const a1 = ctx1.agents.list()[0] as Agent expect(a1.id).toBe(a1.session.id) expect(a1.session.id).toMatch(idPattern) expect(ctx1.agents.get(SessionId('cfg'))).toBeUndefined() @@ -76,7 +76,7 @@ describe('config-driven session id', () => { await ctx2.plugin(AgentLoop, { agents: [{ id: 'cfg', model: 'mock' }] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('cfg2')])) - const a2 = ctx2.agents.list()[0] as ReactLoopAgent + const a2 = ctx2.agents.list()[0] as Agent expect(a2.id).toBe(a2.session.id) expect(a2.session.id).toMatch(idPattern) expect(a2.session.id).not.toBe(a1.session.id) @@ -100,7 +100,7 @@ describe('config-driven session id', () => { await ctx1.plugin(AgentLoop, { agents: [] }) await ctx1.plugin(SessionPersistenceJsonl, { root }) ctx1.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first')])) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sticky-1') })).agent a1.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -119,10 +119,10 @@ describe('config-driven session id', () => { ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) // The deferred resume runs on a microtask after the backend is available. - let resumed: ReactLoopAgent | undefined + let resumed: Agent | undefined for (let i = 0; i < 50 && !resumed; i++) { await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(SessionId('sticky-1')) as ReactLoopAgent | undefined + resumed = ctx2.agents.get(SessionId('sticky-1')) } expect(resumed).toBeDefined() // The live session id IS the resumed id (NOT a fresh ${id}-session-), diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 4e82e398f1..708cff504b 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -5,11 +5,15 @@ import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter) { const ctx = new Context() await ctx.plugin(LlmService) @@ -22,7 +26,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -33,7 +37,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -213,7 +217,7 @@ describe('disposed vs aborted branching', () => { it('handles dispose during model streaming producing reason "disposed"', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -224,7 +228,7 @@ describe('disposed vs aborted branching', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during hang - await agent.done + await driverDone(agent) // The review-fixes test for 'HIGH: disposed status' already covers // this assertion path. The reason is 'disposed' because isDisposed() is diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 8c95be0d82..bbae57d7f9 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -4,9 +4,9 @@ import LlmService, { CallId, type Message } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool, type PostToolDecision, type PreToolDecision } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationDecision, type PromptDecision, type SessionStartSource } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' /** @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -41,11 +41,11 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c4e49e0fa5..49a7c6a328 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -4,11 +4,15 @@ import LlmService, { CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + async function harness(adapter: MockAdapter, persona = '') { const ctx = new Context() await ctx.plugin(LlmService) @@ -26,7 +30,7 @@ async function harness(adapter: MockAdapter, persona = '') { * invoke this right after send(), when the loop hasn't woken yet (status is * still 'idle' synchronously), so polling the current status would lie. */ -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -37,7 +41,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -175,7 +179,7 @@ describe('agent loop', () => { agentOptions: { model: 'mock' }, }) - const agent = handle.agent as ReactLoopAgent + const agent = handle.agent send(agent, 'hi') await waitForIdle(ctx, agent) @@ -911,7 +915,7 @@ describe('agent loop', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -922,7 +926,7 @@ describe('agent loop', () => { expect(agent.status).toBe('running') await fiber.dispose() - await agent.done + await driverDone(agent) expect(agent.status).toBe('disposed') expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() @@ -942,7 +946,7 @@ describe('agent loop', () => { }) ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agents.list()[0]! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent).toBeDefined() expect(agent.id).toBe(agent.session.id) expect(agent.id).toMatch(/^config-agent-session-/) @@ -965,7 +969,7 @@ describe('agent loop', () => { agents: [{ id: 'config-agent', model: 'mock', cwd: '/work/project' }], }) - const agent = ctx.agents.list()[0]! as ReactLoopAgent + const agent = ctx.agents.list()[0]! expect(agent.session.header.cwd).toBe('/work/project') }) diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index b3653d6540..a4539587d5 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -17,9 +17,9 @@ import { LlmAdapter } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import fc from 'fast-check' /** A never-exhausting adapter: every model call returns the same short reply. */ @@ -48,7 +48,7 @@ async function harness() { } /** Resolve on the agent's next transition to idle (event-based, not polled). */ -function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function nextIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -61,7 +61,7 @@ function nextIdle(ctx: Context, agent: ReactLoopAgent): Promise { /** Record every status transition for the legal-machine assertion. Returns * the seen list plus a disposer for the listener (per the registry convention). */ -function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; dispose: () => void } { +function recordStatus(ctx: Context, agent: Agent): { seen: string[]; dispose: () => void } { const seen: string[] = [] const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent) seen.push(status) @@ -69,13 +69,13 @@ function recordStatus(ctx: Context, agent: ReactLoopAgent): { seen: string[]; di return { seen, dispose } } -function userMessageTexts(agent: ReactLoopAgent): string[] { +function userMessageTexts(agent: Agent): string[] { return agent.session.events .filter(e => e.type === 'user/message') .map(e => (e.data as { content: { type: string; text?: string }[] }).content.map(b => b.text ?? '').join('')) } -function turnNumbers(agent: ReactLoopAgent): number[] { +function turnNumbers(agent: Agent): number[] { return agent.session.events .filter(e => e.type === 'turn/start') .map(e => (e.data as { turn: number }).turn) diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 202a8f6795..03b9a058ea 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -15,9 +15,9 @@ import type { GenerateOptions } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, persona = 'stable base') { @@ -32,7 +32,7 @@ async function harness(adapter: MockAdapter, persona = 'stable base') { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -43,7 +43,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -229,7 +229,7 @@ describe('request stability across the loop', () => { seed: [...agent.session.events], agentOptions: { model: 'mock' }, }) - const agent2 = handle.agent as ReactLoopAgent + const agent2 = handle.agent send(agent2, 'second') await waitForIdle(ctx2, agent2) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index f5809575ab..630331c39c 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -8,10 +8,10 @@ import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -51,7 +51,7 @@ async function persistSession(sessionId: SessionId): Promise { return root } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -124,7 +124,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: create a no-cwd session and run a turn. const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('nocwd-sess') })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) await ctx1.fiber.dispose() @@ -140,7 +140,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('nocwd-sess') })).agent expect(a2.session.header.cwd).toBeUndefined() await ctx2.fiber.dispose() }) @@ -151,7 +151,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const { ctx: ctx1, root } = await persistentHarness(adapter1) const sources1: string[] = [] ctx1.on('agent/session-start', (_agent, source) => void sources1.push(source)) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('start-sess') })).agent expect(sources1).toEqual(['startup']) a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) @@ -443,7 +443,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('forked-sess') })).agent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') expect(a2.session.header.seedLength).toBe(seed.length) @@ -457,7 +457,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // disk, since a crash before the next turn would otherwise lose it. const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -482,7 +482,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // drop it on reload (the bug this guards). const adapter1 = new MockAdapter([textResponse('answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('inject-sess'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'q' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) a1.inject([{ type: 'text', text: 'background task 42 finished' }], { source: { kind: 'plugin', plugin: 'tool-bash' } }) @@ -500,7 +500,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(AgentLoop, { agents: [] }) await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('inject-sess') })).agent const flat = JSON.stringify(a2.session.deriveMessages()) expect(flat).toContain('background task 42 finished') await ctx2.fiber.dispose() @@ -510,7 +510,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { // Lifecycle 1: run one full turn, persisting it. const adapter1 = new MockAdapter([textResponse('first answer')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent as ReactLoopAgent + const a1 = (await ctx1.agents.create({ sessionId: SessionId('sess-resume'), meta: { cwd: '/w' } })).agent a1.send([{ type: 'text', text: 'first question' }], { source: { kind: 'user' } }) await waitForIdle(ctx1, a1) const events1 = [...a1.session.events] @@ -530,7 +530,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], adapter2) - const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent as ReactLoopAgent + const a2 = (await ctx2.agents.resume({ resumeSessionId: SessionId('sess-resume') })).agent // The resumed session carries the prior history… expect(a2.session.id).toBe('sess-resume') expect(a2.session.events.length).toBe(events1.length) diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index 942199e9eb..2664e38c04 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -4,13 +4,17 @@ import LlmService, { CallId, ContentBlock, MessageSource, StreamChunk } from '@d import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type ContinuationDecision } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationDecision } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { prepareReactLoopAgent } from '../src/agent.ts' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' +function driverDone(agent: Agent): Promise { + return (agent as Agent & { done: Promise }).done +} + /** Regression tests for agent-loop boundary, identity, and lifecycle contracts. */ async function harness(adapter: MockAdapter) { @@ -25,7 +29,7 @@ async function harness(adapter: MockAdapter) { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -36,7 +40,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function send(agent: ReactLoopAgent, text: string) { +function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } @@ -324,7 +328,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -337,7 +341,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done + await driverDone(agent) expect(statuses).toEqual(['running', 'disposed']) expect(reasons).toEqual([{ kind: 'disposed' }]) @@ -347,7 +351,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('scoped'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -359,7 +363,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() - await agent.done // must not hang + await driverDone(agent) // must not hang expect(agent.status).toBe('disposed') expect(ctx.agents.get(SessionId('scoped'))).toBeUndefined() // unregistered despite the throw @@ -697,7 +701,7 @@ describe('turn and step boundary recovery', () => { } /** Count turn/step boundary events for balance assertions. */ - function boundaryCounts(agent: ReactLoopAgent) { + function boundaryCounts(agent: Agent) { const e = [...agent.session.events] return { turnStart: e.filter(x => x.type === 'turn/start').length, @@ -871,7 +875,7 @@ describe('turn and step boundary recovery', () => { // balanced with reason disposed (no error event for a disposal). const adapter = new MockAdapter(['hang']) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -882,7 +886,7 @@ describe('turn and step boundary recovery', () => { send(agent, 'go') await new Promise(r => setTimeout(r, 30)) await fiber.dispose() // dispose during the hanging step - await agent.done + await driverDone(agent) const e = [...agent.session.events] const turnStarts = e.filter(x => x.type === 'turn/start').length @@ -900,7 +904,7 @@ describe('turn and step boundary recovery', () => { // and must preserve reason=disposed rather than rewrite it as a plugin error. const adapter = new MockAdapter([textResponse('never reached')]) const ctx = await balancedHarness(adapter) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-prestep-dispose-throw'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -919,7 +923,7 @@ describe('turn and step boundary recovery', () => { ctx.on('agent/error', (_a, _t, _s, error) => void errorEmits.push(error)) send(agent, 'go') - await agent.done + await driverDone(agent) const e = [...agent.session.events] // Balanced: one turn/start, one turn/end carrying disposed (NOT error). @@ -1148,7 +1152,7 @@ describe('disposal and cancellation during pre-step assembly', () => { // calls stop() synchronously, setting status=disposed), then release the // block. The loop must check isDisposed() after assembly and end the turn // `disposed` — no LLM call. Don't await fiber.dispose() before releasing - // the blocker: the dispose chain awaits agent.done, which hangs until the + // the blocker: the dispose chain awaits driverDone(agent), which hangs until the // loop unblocks. const adapter = new MockAdapter(['hang']) let releaseAssemble!: () => void @@ -1170,7 +1174,7 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1183,15 +1187,15 @@ describe('disposal and cancellation during pre-step assembly', () => { await new Promise(r => setTimeout(r, 50)) // Start disposal — stop() sets status=disposed synchronously, then the - // disposer's await agent.done hangs because the loop is blocked in the + // disposer's await driverDone(agent) hangs because the loop is blocked in the // waterfall. Do NOT await yet; release the blocker first. const disposalDone = fiber.dispose() // Now release the blocked waterfall — the loop unblocks, checks - // isDisposed(), and exits, which resolves agent.done and disposalDone. + // isDisposed(), and exits, which resolves driverDone(agent) and disposalDone. releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) unlisten() const e = [...agent.session.events] @@ -1226,7 +1230,7 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-cancel-assemble'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1241,7 +1245,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releaseAssemble() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) unlisten() const e = [...agent.session.events] @@ -1281,7 +1285,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1296,7 +1300,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releasePreStep() await disposalDone - await agent.done + await driverDone(agent) // After the pre-step seam finishes, the post-seam cancel/dispose check // catches disposal. The step was never opened, no LLM call was made. @@ -1333,7 +1337,7 @@ describe('disposal and cancellation during pre-step assembly', () => { await blocker }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-cancel-prestep'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1348,7 +1352,7 @@ describe('disposal and cancellation during pre-step assembly', () => { releasePreStep() await waitForIdle(ctx, agent) await fiber.dispose() - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) @@ -1383,7 +1387,7 @@ describe('disposal and cancellation during pre-step assembly', () => { return next() }) - let agent!: ReactLoopAgent + let agent!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { agent = inner.agentLoop.create(SessionId('a-dispose-no-leak'), { model: 'mock' }) }, { inject: ['agentLoop'] })) @@ -1394,7 +1398,7 @@ describe('disposal and cancellation during pre-step assembly', () => { const disposalDone = fiber.dispose() releaseAssemble() await disposalDone - await agent.done + await driverDone(agent) const e = [...agent.session.events] expect(e.filter(x => x.type === 'turn/start')).toHaveLength(1) diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index c68d96e525..57dae254c8 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -8,7 +8,7 @@ import AgentRegistry, { agentEvents, assembleContextFor } from '@deepseek-ai/dsh import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeOf } from '@deepseek-ai/dsh-scope' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -28,7 +28,7 @@ async function harness(adapter: MockAdapter = new MockAdapter([textResponse('ok' return (await harnessWithLoop(adapter)).ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { @@ -718,7 +718,7 @@ describe('agent scope lifecycle', () => { const ctx = await harness() let ownerCtx!: Context let creating!: ReturnType - let announced!: ReactLoopAgent + let announced!: Agent const statuses: string[] = [] let scopeDisposed = false let observerSawLive = false @@ -727,7 +727,7 @@ describe('agent scope lifecycle', () => { }) ctx.on('agent/session-start', (agent) => { if (agent.id !== SessionId('session-start-dispose-s')) return - announced = agent as ReactLoopAgent + announced = agent disposeCurrentLifecycle(ownerCtx) }) ctx.on('agent/session-start', (agent) => { diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 1085f01a4c..3b7df63965 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -14,9 +14,9 @@ import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-ses import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['toolOrder']) { @@ -31,7 +31,7 @@ async function harness(adapter: MockAdapter, toolOrder?: SystemPromptConfig['too return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/core/agent-loop/tests/turn-stop.spec.ts b/packages/core/agent-loop/tests/turn-stop.spec.ts index c7823a2aa5..90bc0f9558 100644 --- a/packages/core/agent-loop/tests/turn-stop.spec.ts +++ b/packages/core/agent-loop/tests/turn-stop.spec.ts @@ -4,9 +4,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry, { type ContinuationStop } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent, type ContinuationStop } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import { MockAdapter, textResponse, toolCallResponse } from './mock-adapter.ts' @@ -23,7 +23,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function send(agent: ReactLoopAgent, text = 'go'): Promise { +function send(agent: Agent, text = 'go'): Promise { agent.send([{ type: 'text', text }]) return agent.whenIdle() } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index b0fb78ad66..edd38e31b6 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -168,9 +168,8 @@ export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' /** * The agent handle — the surface every plugin (UI, hooks, orchestrators) - * programs against. The concrete implementation lives in - * `@deepseek-ai/dsh-agent-loop` (class `ReactLoopAgent`); nothing outside the loop - * package should depend on the implementation. + * programs against. The concrete implementation is package-internal to + * `@deepseek-ai/dsh-agent-loop`; nothing outside that package depends on it. */ export interface Agent { /** The single identity shared with {@link session}. */ diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 9538166fc4..4f7015d8c4 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -4,9 +4,9 @@ import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as RepeatToolGuard from '@deepseek-ai/dsh-repeat-tool-guard' import type { Config } from '@deepseek-ai/dsh-repeat-tool-guard' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -34,12 +34,12 @@ async function harness(config: Config = {}): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } /** Every `context/message` in the agent's log, flattened to joined text + source for terse assertions. */ -function reminders(agent: ReactLoopAgent): { text: string; source: unknown }[] { +function reminders(agent: Agent): { text: string; source: unknown }[] { return [...agent.session.events] .filter((e): e is SessionEvent<'context/message'> => e.type === 'context/message') .map(e => ({ @@ -255,14 +255,14 @@ describe('chain semantics', () => { ])) // Loop agents are torn down by disposing the scope that created them // (the loop.spec pattern): a child plugin fiber owns `first`. - let first!: ReactLoopAgent + let first!: Agent const fiber = await ctx.plugin(Object.assign((inner: Context) => { first = inner.agentLoop.create(SessionId('reused'), { model: 'mock' }) }, { inject: ['agentLoop'] })) first.send([{ type: 'text', text: 'go' }]) await waitForIdle(ctx, first) await fiber.dispose() - await first.done + await first.whenIdle() const second = ctx.agentLoop.create(SessionId('reused'), { model: 'mock' }) second.send([{ type: 'text', text: 'go' }]) diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 2f0a2666c9..2c26d64f5a 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -8,9 +8,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -58,7 +58,7 @@ async function harnessWithFiber(configDir: string, adapter: MockAdapter): Promis return { ctx, hooks } } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } @@ -66,7 +66,7 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } -function events(agent: ReactLoopAgent): SessionEvent[] { +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 16c48aa149..7997e0fd87 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -7,9 +7,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -42,10 +42,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOp ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -451,8 +451,8 @@ describe('hooks-claude coverage — continue:false, context arm, no-cwd', () => const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: workspace }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) - expect(events(handle.agent as ReactLoopAgent).some(e => e.type === 'context/message' + await waitForIdle(ctx, handle.agent) + expect(events(handle.agent).some(e => e.type === 'context/message' && e.data.content.some(b => b.type === 'text' && b.text.includes(`dir=${workspace}`)))).toBe(true) await handle.dispose() }) @@ -616,7 +616,7 @@ describe('hooks-claude coverage — hook runs in the session cwd, not the server const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) // the marker landed in the SESSION dir const { readFileSync } = await import('node:fs') diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 4aa678c36e..84e877af97 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -8,9 +8,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -54,14 +54,14 @@ async function harness(dir: string, adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { dispose(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll `predicate` until true or the deadline passes (detached hook effects can't be awaited directly). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 75b04d4c57..018f109a83 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -7,9 +7,9 @@ import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import * as HooksCodex from '@deepseek-ai/dsh-hooks-codex' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -33,10 +33,10 @@ async function harness(configPath: string, adapter: MockAdapter, opts: { stderrS ctx.llm.registerAdapter(['mock'], adapter) return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const d = ctx.on('agent/status', (s, st) => { if (s === agent && st === 'idle') { d(); resolve() } }) }) } -function events(agent: ReactLoopAgent): SessionEvent[] { return [...agent.session.events] } +function events(agent: Agent): SessionEvent[] { return [...agent.session.events] } /** Poll until `predicate` holds or the deadline passes — robust to detached * emit-listener hooks firing on a `.then` (a fixed sleep flakes under load). */ async function waitFor(predicate: () => boolean, timeout = 5000, interval = 10): Promise { @@ -556,7 +556,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { const { SessionId } = await import('@deepseek-ai/dsh-session') const handle = await ctx.agents.create({ sessionId: SessionId('s1'), meta: { cwd: sessionDir }, agentOptions: { model: 'mock' } }) handle.agent.send([{ type: 'text', text: 'go' }]) - await waitForIdle(ctx, handle.agent as ReactLoopAgent) + await waitForIdle(ctx, handle.agent) expect(existsSync(marker)).toBe(true) expect(readFileSync(marker, 'utf8').trim().endsWith(sessionDir.split('/').pop()!)).toBe(true) await handle.dispose() diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 8376a70425..ba2df4f6ea 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -5,9 +5,9 @@ import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -30,7 +30,7 @@ async function harness(adapter: MockAdapter): Promise { return ctx } -function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { +function waitForIdle(ctx: Context, agent: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (subject, status) => { if (subject === agent && status === 'idle') { diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 31a865c068..9f4e8b7c0b 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-acp -The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own `ReactLoopAgent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. +The **Agent Client Protocol (ACP)** bridge: exposes DeepSeek Harness SDK agents as an ACP server over JSON-RPC stdio, so editors (Zed and other ACP clients) can drive them — streaming render, tool-call display, and resumable sessions. Zed is the current target client: baseline ACP behavior should remain reasonable for other clients, but bridge capabilities and compatibility decisions are evaluated against Zed first. **N concurrent sessions per connection** (see [ACP multi-session](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md)): each maps to its own concrete `Agent`, and every event is demuxed strictly by session id so two sessions streaming at once never interleave. It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 369c8ae166..7c97ad715c 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -18,7 +18,7 @@ * turn about to start) + settle the in-flight prompt * * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to - * its own `ReactLoopAgent`. Sessions are keyed by their shared agent/session id; + * its own concrete `Agent`. Sessions are keyed by their shared agent/session id; * every `session/event` and `agent/*` event is routed strictly to its owning * session record, so two sessions streaming at once never interleave their * `session/update` notifications. Permission prompts use the same identity: the From f85b831bd2cd0d37ca814473abc17ccfb2a6c287 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:41:23 +0800 Subject: [PATCH 013/163] refactor: hide subagent implementation helpers --- ...claude-code-and-codex-subagent-backends.md | 2 +- .../subagent-acp/tests/subagent-acp.spec.ts | 3 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 4 +- .../tests/subagent-inprocess.spec.ts | 24 ++---- .../tests/subagent-spawn.spec.ts | 8 +- .../subagent/subagent-subprocess/README.md | 8 +- .../subagent/subagent-subprocess/src/index.ts | 6 +- .../tests/subagent-subprocess.spec.ts | 84 +++++++------------ 9 files changed, 54 insertions(+), 87 deletions(-) diff --git a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md index 911d28cf1f..c05ae8d67f 100644 --- a/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md +++ b/docs/rfc/proposed/feature/2026-07-07-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ Two sibling provider packages, structural variants of the ACP backend, plus one - `@deepseek-ai/dsh-subagent-claude-code` — drives a Claude Code child through `@anthropic-ai/claude-agent-sdk`'s `query()` (the SDK runs in the parent process and spawns its bundled `claude` CLI as the subprocess). Provider name `claude-code`: the child is the Claude Code *product*, not an Anthropic model adapter — "claude" stays reserved for a future `dsh-llm` adapter. - `@deepseek-ai/dsh-subagent-codex` — spawns `codex app-server` and drives one thread/turn over its JSON-RPC-over-stdio protocol with a hand-rolled newline-JSON client (~200–300 lines) in the package. -- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`SENSITIVE_ENV_PATTERN`/`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. +- `@deepseek-ai/dsh-subagent-process` — a pure library (the `subagent-inprocess` precedent) extracting what `dsh-subagent-acp` already carries and both new backends need: the credential env scrub (`buildChildEnv`), the EOF → SIGTERM → SIGKILL dispose ladder, and new isolated-config-dir helpers (`mkdtemp` create, best-effort remove). The ACP backend migrates onto it; `bash-local`'s sibling copy is left alone to bound the change. Both providers copy the ACP backend's seam posture verbatim: fresh child per `start`, exactly one prompt round-trip, capabilities all `false`, `inheritsParentContext: false`, `request.parent`/`request.agentOptions` ignored, `id = SessionId(randomUUID())`, `result` never rejects — child-level failure flattens to a stop reason and the original error goes to `ctx.logger` via an `onError` spec callback. Model exposure is zero new code: `dsh-tool-subagent` is loaded once per provider with a distinct `toolName` (`subagent_claude_code`, `subagent_codex`). No new session events are needed — the only model-visible artifact is the tool result, so reconstructability holds exactly as it did for ACP. To be explicit about the boundary: the session log reconstructs the model-visible transcript, not workspace mutation history — a child granted write access mutates files as an ambient side effect outside the log, exactly as the bash tools and the ACP backend already do; replay reproduces requests, not the disk. diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 115d5c3e97..cc4d8307f8 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -6,7 +6,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import SubagentService from '@deepseek-ai/dsh-subagent' -import { buildChildEnv, SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subagent-subprocess' +import { buildChildEnv } from '@deepseek-ai/dsh-subagent-subprocess' import type { Agent } from '@deepseek-ai/dsh-agent' import * as acp from '../src/index.ts' import { acpStopReason, acpContentText, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' @@ -112,7 +112,6 @@ describe('buildChildEnv', () => { // The explicitly-supplied key survives (an opt-in for the child's creds). expect(env.DEEPSEEK_API_KEY).toBe('explicit') // A normal ambient var is forwarded. - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(env.PATH).toBe(process.env.PATH) } finally { delete process.env.DSH_ACP_TEST_SECRET_TOKEN diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 48fd0a201d..83c2ba3500 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -26,7 +26,7 @@ After fulfillment, the caller owns the run. Provider-plugin unload does not revo `InProcessRunOptions` is `{ seed?: SessionEvent[] }`. Spawn omits it. Fork supplies a balanced completed-turn prefix and records its length so the result reader never mistakes a seeded parent message for child output. -`depthOf(agent)` reads `AgentOptions.subagentDepth`, treating absence as top-level depth zero and rejecting malformed stored values. `SubagentDepthError` reports an attempted child depth above `maxDepth`; an unrepresentable depth above the safe-integer domain is a `RangeError`. +Depth enforcement is internal to `startInProcessRun`: it reads `AgentOptions.subagentDepth`, treats absence as top-level depth zero, rejects malformed stored values, and reports an attempted child depth above `maxDepth`. An unrepresentable depth above the safe-integer domain is a `RangeError`. ## Structured output diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 965d9c8e78..9676c74b5b 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -36,7 +36,7 @@ declare module '@deepseek-ai/dsh-agent' { * @param agent - the agent whose options carry the depth. * @returns its non-negative safe-integer depth. */ -export function depthOf(agent: Agent): number { +function depthOf(agent: Agent): number { const depth = agent.options.subagentDepth if (depth === undefined) return 0 if (!Number.isSafeInteger(depth) || depth < 0 || Object.is(depth, -0)) { @@ -46,7 +46,7 @@ export function depthOf(agent: Agent): number { } /** Thrown when starting a child would exceed the requested depth cap. */ -export class SubagentDepthError extends Error { +class SubagentDepthError extends Error { constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) this.name = 'SubagentDepthError' diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index aa0f946c89..c594e27fa8 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -10,7 +10,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop' import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' -import { depthOf, SubagentDepthError, startInProcessRun } from '../src/index.ts' +import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -37,19 +37,6 @@ function text(blocks: readonly { type: string; text?: string }[]): string { return blocks.filter(block => block.type === 'text').map(block => block.text).join('') } -describe('depthOf', () => { - it('reads zero for a top-level agent and an explicit child depth', async () => { - const { parent } = await setup([]) - expect(depthOf(parent)).toBe(0) - expect(depthOf({ options: { subagentDepth: 3 } } as unknown as Agent)).toBe(3) - }) - - it.each([Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1])('rejects malformed depth %s', (value) => { - expect(() => depthOf({ options: { subagentDepth: value } } as unknown as Agent)) - .toThrow('non-negative safe integer') - }) -}) - describe('startInProcessRun', () => { it('returns only after publication, drives a fresh child, and disposes it', async () => { const { ctx, parent } = await setup([textResponse('driver answer')]) @@ -58,7 +45,7 @@ describe('startInProcessRun', () => { const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('driver answer') - expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + expect(ctx.agents.get(run.id)!.options.subagentDepth).toBe(1) await run.dispose() await run.dispose() expect(ctx.agents.get(run.id)).toBeUndefined() @@ -83,7 +70,12 @@ describe('startInProcessRun', () => { await expect(startInProcessRun({ ...request(parent), maxDepth: -1 }, {})) .rejects.toThrow('non-negative safe integer') await expect(startInProcessRun({ ...request(parent), maxDepth: 0 }, {})) - .rejects.toBeInstanceOf(SubagentDepthError) + .rejects.toMatchObject({ name: 'SubagentDepthError' }) + for (const value of [Number.NaN, 1.5, -1, -0, Number.MAX_SAFE_INTEGER + 1]) { + const malformed = { options: { subagentDepth: value } } as unknown as Agent + await expect(startInProcessRun(request(malformed), {})) + .rejects.toThrow('agent subagentDepth must be a non-negative safe integer') + } const maxParent = { options: { subagentDepth: Number.MAX_SAFE_INTEGER } } as unknown as Agent await expect(startInProcessRun(request(maxParent), {})).rejects.toBeInstanceOf(RangeError) }) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 06aa40ca9f..9b9e2a8fd2 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -13,7 +13,7 @@ import * as Invariants from '@deepseek-ai/dsh-invariants' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { MockAdapter, maxTokensResponse, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import * as spawn from '../src/index.ts' -import { depthOf, STRUCTURED_OUTPUT_TOOL, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' +import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' type Script = ConstructorParameters[0] @@ -118,11 +118,11 @@ describe('dsh-subagent-spawn', () => { it('stamps child depth = parent depth + 1 (via the merged AgentOptions field)', async () => { const { ctx, parent } = await setup([textResponse('x')]) - expect(depthOf(parent)).toBe(0) + expect(parent.options.subagentDepth).toBeUndefined() const run = await start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) await run.result const child = ctx.agents.get(run.id)! - expect(depthOf(child)).toBe(1) + expect(child.options.subagentDepth).toBe(1) await run.dispose() }) @@ -130,7 +130,7 @@ describe('dsh-subagent-spawn', () => { const { ctx, parent } = await setup([]) // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. await expect(start(ctx, 'spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) - .rejects.toThrow(SubagentDepthError) + .rejects.toThrow('subagent depth 1 exceeds maxDepth 0') }) it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { diff --git a/packages/subagent/subagent-subprocess/README.md b/packages/subagent/subagent-subprocess/README.md index ccc68bf31b..c8b8a06437 100644 --- a/packages/subagent/subagent-subprocess/README.md +++ b/packages/subagent/subagent-subprocess/README.md @@ -6,7 +6,7 @@ Every tunable is a **parameter**: the dispose ladder takes its grace periods per ## What it exports -### `SENSITIVE_ENV_PATTERN` / `buildChildEnv(extra)` +### `buildChildEnv(extra)` The credential env scrub (same pattern as the [bash executor](../../bash/bash-local/README.md)): the child env is the ambient env minus credential-shaped vars (`/KEY|SECRET|TOKEN/i`), with `extra` layered on top AFTER the scrub. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive, so the child CLI runs normally; the parent's own secrets never leak implicitly, while an explicitly supplied credential (the child's OWN key in a backend's `env` config) still reaches the child. @@ -14,10 +14,6 @@ The credential env scrub (same pattern as the [bash executor](../../bash/bash-lo Spawn-failure capture: a promise that resolves (never rejects) with the child's first `error` event. A spawn failure such as `ENOENT` is an event, not a thrown exception — without a listener Node crashes the parent process — so call this in the same tick as `spawn()` and race it in the run's result path; a bad command then settles as an ordinary child-level failure. For a child that spawns cleanly the promise never settles. -### `waitForExit(child)` / `exitsWithin(child, ms)` - -Exit waits over a `ChildProcess`: resolve once the child exits by any code or signal (immediately if it is already gone), or race that against a timer (`true` = exited in time). The race cleans up after itself on both outcomes — the pending timer is `unref()`ed and cleared on exit, the exit listener removed on timeout — so repeated calls (the dispose ladder's tiers, a poll loop) never accumulate listeners on the child. - ### `disposeChildProcess(child, graces)` The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited — quiescence reached, not merely requested (see [defensive patterns](../../../docs/defensive-patterns.md)): @@ -28,6 +24,8 @@ The three-tier dispose ladder. Resolves only once the child has ACTUALLY exited The two graces (`DisposeLadderGraces`) come from the consuming plugin's `disposeEofGraceMs`/`disposeGraceMs` Config fields; the EOF window is deliberately a separate — usually wider — grace than the signal tier, since a cooperative child's EOF teardown may itself await a signal-trapping grandchild plus a final flush. +The exit waits are internal to this ladder. They clean up their timer and listener on either outcome, so escalation never accumulates listeners on the child. + ### `createIsolatedConfigDir(prefix, pinnedPath?)` A per-run isolated config directory for an external CLI child (the target of `CLAUDE_CONFIG_DIR` / `CODEX_HOME`-style redirection), so child behavior is a function of deployment config alone — never of whatever `~/.claude` / `~/.codex`-style state exists on the host. Returns an `IsolatedConfigDir` handle: `path` goes into the child env, `remove()` runs on dispose. diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 35d7383456..2ee2745985 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -32,7 +32,7 @@ import { join } from 'node:path' * the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental * `AWS_SECRET_ACCESS_KEY` does not. */ -export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i +const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** * The ambient env minus credential-shaped vars, plus the caller's explicit @@ -72,7 +72,7 @@ export function spawnFailure(child: ChildProcess): Promise { * already gone. * @param child - the child process to await. */ -export function waitForExit(child: ChildProcess): Promise { +function waitForExit(child: ChildProcess): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() return new Promise(resolve => child.once('exit', () => { resolve() })) } @@ -87,7 +87,7 @@ export function waitForExit(child: ChildProcess): Promise { * @returns `true` if the child exits within `ms` (immediately if it is * already gone), `false` on timeout. */ -export function exitsWithin(child: ChildProcess, ms: number): Promise { +function exitsWithin(child: ChildProcess, ms: number): Promise { if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(true) return new Promise((resolve) => { const onExit = (): void => { diff --git a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts index 2766ed0a41..8e19a8a4a1 100644 --- a/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts +++ b/packages/subagent/subagent-subprocess/tests/subagent-subprocess.spec.ts @@ -9,10 +9,7 @@ import { buildChildEnv, createIsolatedConfigDir, disposeChildProcess, - exitsWithin, - SENSITIVE_ENV_PATTERN, spawnFailure, - waitForExit, } from '../src/index.ts' // `rm` is wrapped (real-passthrough by default) so ONE test can inject a @@ -47,6 +44,8 @@ interface FakeChildScript { diesOn?: LethalTrigger /** Delay (ms) between the lethal trigger and the exit event. */ delayMs?: number + /** Complete the scripted exit inside the triggering call. */ + synchronousExit?: boolean /** `false` models a child spawned without a stdin pipe. */ stdin?: boolean } @@ -80,11 +79,13 @@ class FakeChild extends EventEmitter { // SIGKILL is uncatchable — it always fells the child; any other trigger // only when the scenario scripts it as the lethal one. if (trigger !== 'SIGKILL' && this.script.diesOn !== trigger) return - setTimeout(() => { + const exit = (): void => { if (trigger === 'eof') this.exitCode = 0 else this.signalCode = trigger this.emit('exit', this.exitCode, this.signalCode) - }, this.script.delayMs ?? 0) + } + if (this.script.synchronousExit === true) exit() + else setTimeout(exit, this.script.delayMs ?? 0) } } @@ -93,7 +94,7 @@ function asChild(fake: FakeChild): ChildProcess { return fake as unknown as ChildProcess } -describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { +describe('buildChildEnv', () => { it('drops credential-shaped ambient vars (KEY/SECRET/TOKEN, case-insensitive)', () => { process.env.DSH_PROC_TEST_API_KEY = 'leak' process.env.dsh_proc_test_secret = 'leak' @@ -111,7 +112,6 @@ describe('buildChildEnv / SENSITIVE_ENV_PATTERN', () => { }) it('forwards normal ambient vars', () => { - expect(SENSITIVE_ENV_PATTERN.test('PATH')).toBe(false) expect(buildChildEnv({}).PATH).toBe(process.env.PATH) }) @@ -149,7 +149,7 @@ describe('spawnFailure', () => { const fake = new FakeChild({ diesOn: 'SIGTERM' }) const failure = spawnFailure(asChild(fake)) fake.kill('SIGTERM') - await waitForExit(asChild(fake)) + await new Promise(resolve => fake.once('exit', () => { resolve() })) // A clean lifecycle emits `exit`, never `error` — the capture stays // pending forever, so a race against it is decided by the other arms. const settled = await Promise.race([ @@ -160,51 +160,6 @@ describe('spawnFailure', () => { }) }) -describe('waitForExit / exitsWithin', () => { - it('resolves immediately for a child that already exited by code', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves immediately for a child that already died by signal', async () => { - const fake = new FakeChild() - fake.signalCode = 'SIGTERM' - await expect(waitForExit(asChild(fake))).resolves.toBeUndefined() - }) - - it('resolves on the exit event of a live child', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - const exited = waitForExit(asChild(fake)) - fake.kill('SIGTERM') - await expect(exited).resolves.toBeUndefined() - expect(fake.signalCode).toBe('SIGTERM') - }) - - it('exitsWithin resolves true immediately for an already-exited child (no listener attached)', async () => { - const fake = new FakeChild() - fake.exitCode = 0 - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves true when the child exits inside the window', async () => { - const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) - fake.kill('SIGTERM') - await expect(exitsWithin(asChild(fake), 1000)).resolves.toBe(true) - // The once-listener fired and the grace timer was cleared — nothing lingers. - expect(fake.listenerCount('exit')).toBe(0) - }) - - it('exitsWithin resolves false on timeout for a child that never exits', async () => { - const fake = new FakeChild() // nothing short of SIGKILL fells it; no signal sent - await expect(exitsWithin(asChild(fake), 20)).resolves.toBe(false) - // The timeout arm removed its exit listener: repeated waits (a poll loop, - // the ladder's tiers) never accumulate listeners on the same child. - expect(fake.listenerCount('exit')).toBe(0) - }) -}) - describe('disposeChildProcess', () => { it('returns immediately for an already-exited child (no EOF, no signals)', async () => { const fake = new FakeChild() @@ -230,12 +185,28 @@ describe('disposeChildProcess', () => { expect(fake.exitCode).toBe(0) }) + it('recognizes a child that exits synchronously on stdin EOF', async () => { + const fake = new FakeChild({ diesOn: 'eof', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 1000, disposeGraceMs: 1000 }) + expect(fake.exitCode).toBe(0) + expect(fake.listenerCount('exit')).toBe(0) + }) + it('tier 2: a child that ignores EOF but honors SIGTERM dies on the middle rung', async () => { const fake = new FakeChild({ diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) expect(fake.stdinEnded).toBe(true) expect(fake.kills).toEqual(['SIGTERM']) expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) + }) + + it('recognizes a child that exits synchronously on SIGTERM', async () => { + const fake = new FakeChild({ diesOn: 'SIGTERM', synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) + expect(fake.kills).toEqual(['SIGTERM']) + expect(fake.signalCode).toBe('SIGTERM') + expect(fake.listenerCount('exit')).toBe(0) }) it('tier 3: a SIGTERM-trapping child is SIGKILLed, and dispose resolves only after the exit', async () => { @@ -247,6 +218,13 @@ describe('disposeChildProcess', () => { expect(fake.signalCode).toBe('SIGKILL') }) + it('recognizes a child already gone when the final exit wait begins', async () => { + const fake = new FakeChild({ synchronousExit: true }) + await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 20 }) + expect(fake.kills).toEqual(['SIGTERM', 'SIGKILL']) + expect(fake.signalCode).toBe('SIGKILL') + }) + it('walks the ladder for a child spawned without a stdin pipe', async () => { const fake = new FakeChild({ stdin: false, diesOn: 'SIGTERM', delayMs: 5 }) await disposeChildProcess(asChild(fake), { disposeEofGraceMs: 20, disposeGraceMs: 1000 }) From 65521b589f3ffa1a912bb3d8db05a6e2ce204a85 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:45:34 +0800 Subject: [PATCH 014/163] refactor: hide remaining subagent helpers --- ...t-variables-and-tool-guidance-ownership.md | 2 +- ...7-05-subagent-provider-lifecycle-events.md | 2 +- .../2026-06-22-fork-snapshot-scenarios.md | 2 +- packages/subagent/subagent-fork/README.md | 2 +- packages/subagent/subagent-fork/src/index.ts | 2 +- .../subagent-fork/tests/subagent-fork.spec.ts | 42 ++++++++----------- packages/subagent/tool-subagent/src/index.ts | 4 +- 7 files changed, 25 insertions(+), 31 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 0961830c1a..aae4564513 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -38,7 +38,7 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag (`providerWording`): the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag: the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md index 46e7ea4e71..4a96ef7419 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md +++ b/docs/rfc/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md @@ -4,7 +4,7 @@ Status: implemented ## Problem -[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description (`providerWording`), so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. +[The prompt-variables RFC](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) makes `dsh-tool-subagent` DERIVE its model-facing wording from its provider: `SubagentProvider.inheritsParentContext` (spawn/ACP `false`, fork `true`) drives both the tool description and the `prompt` parameter description, so the fork tool stops lying about context inheritance. That fix created a cross-fiber data dependency: a tool's description is fixed at TOOL REGISTRATION (deliberately — the description is where tool-choice guidance lives), but the provider arrives on its own plugin fiber, on no particular schedule. The first implementation resolved the provider at the tool plugin's `apply` time and threw when it was absent — an implicit load-order requirement ("list the backend before the tool in cordis.yml"). Review reproduced the failure that requirement hides: the cordis Loader starts sibling entries CONCURRENTLY (`Promise.all` over the group) and `Entry.init()` does not await activation, so a backend whose activation is delayed leaves the tool's fiber permanently failed even when "listed first". The ordering the requirement leaned on is not a contract the Loader offers — "async state is not synchronous state" ([defensive patterns](../../../defensive-patterns.md)). diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md index b2c39047b9..34bebd1194 100644 --- a/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -17,7 +17,7 @@ Record two scenarios against the real API, both replayed keyless in the default ### Why a completed turn-1 is required -The fork backend seeds the child with the parent's **balanced completed-turn prefix** ([`completedTurnPrefix`](../../../../packages/subagent/subagent-fork)). A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. +The fork backend seeds the child with the parent's **balanced completed-turn prefix**. A parent that forks on its very first turn has no completed turn to inherit, so the seed is empty (≡ a fresh spawn, `seedLength` 0) — which would NOT exercise the slice. Both scenarios therefore use a two-prompt input: the first prompt completes a turn (establishing a codeword the child is later asked to recall), the second delegates the fork. The recalled codeword in the child's transcript is incidental to the model's behavior; the load-bearing artifact is the child fixture's recorded `seedLength`, which the replay slice consumes. ## Consequences diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md index bf90ecdf52..0909dc0bf1 100644 --- a/packages/subagent/subagent-fork/README.md +++ b/packages/subagent/subagent-fork/README.md @@ -6,7 +6,7 @@ The fork provider creates an in-process child seeded with the parent's completed The parent's current tool-calling turn is still open when a subagent starts: its log contains the assistant tool call but not the matching tool result or `turn/end`. Copying that raw log would give the child an invalid, unbalanced session. -Fork therefore uses `completedTurnPrefix(parent.session.events)`: the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. +Fork therefore computes the contiguous prefix ending at the last `turn/end`. The child sees all completed parent turns and none of the in-flight turn. If the parent has not completed a turn yet, the seed is empty and the child behaves like a fresh spawn. The seed transfers conversation history only. The child still receives a fresh flat registration scope; it does not inherit the parent's tool restrictions or authority. diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index ebf64e7b70..22be47cc56 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -54,7 +54,7 @@ export const Config: z = z.object({ * @param parent - the agent whose session log to slice. * @returns the seed events, contiguous from seq 0; empty when no turn has completed. */ -export function completedTurnPrefix(parent: Agent): SessionEvent[] { +function completedTurnPrefix(parent: Agent): SessionEvent[] { const events = parent.session.events const lastEnd = events.findLast(e => e.type === 'turn/end') if (lastEnd === undefined) return [] diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 360098e369..bfb0bedac7 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -14,7 +14,6 @@ import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent import type { StreamChunk } from '@deepseek-ai/dsh-llm' import * as fork from '../src/index.ts' import { STRUCTURED_OUTPUT_TOOL } from '@deepseek-ai/dsh-subagent-inprocess' -import { completedTurnPrefix } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -52,28 +51,6 @@ function text(blocks: { type: string; text?: string }[]): string { return blocks.filter(b => b.type === 'text').map(b => b.text).join('') } -describe('completedTurnPrefix', () => { - it('returns an empty prefix for a parent that has never completed a turn', async () => { - const { parent } = await setup([]) - expect(completedTurnPrefix(parent)).toEqual([]) - }) - - it('returns the balanced prefix up to and including the last turn/end', async () => { - const { parent } = await setup([textResponse('first'), textResponse('second')]) - parent.send([{ type: 'text', text: 'q1' }]) - await parent.whenIdle() - parent.send([{ type: 'text', text: 'q2' }]) - await parent.whenIdle() - - const prefix = completedTurnPrefix(parent) - // Ends exactly at the last turn/end; seq is contiguous from 0. - expect(prefix.at(-1)?.type).toBe('turn/end') - expect(prefix.map(e => e.seq)).toEqual(prefix.map((_, i) => i)) - // Both completed turns are present. - expect(prefix.filter(e => e.type === 'turn/end')).toHaveLength(2) - }) -}) - describe('dsh-subagent-fork', () => { it('emits subagent/start only after the seeded child is published', async () => { const { ctx, parent } = await setup([textResponse('child answer')]) @@ -96,7 +73,6 @@ describe('dsh-subagent-fork', () => { // The parent has never completed a turn → empty prefix → the provider omits // the seed → the child runs fresh. Exercises the `seed.length > 0` false arm. const { ctx, parent } = await setup([textResponse('fresh child')]) - expect(completedTurnPrefix(parent)).toEqual([]) const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) const result = await run.result expect(result.stopReason).toBe('completed') @@ -104,6 +80,24 @@ describe('dsh-subagent-fork', () => { const child = ctx.agents.get(run.id)! // Only the child's own turn — no seeded parent turns. expect(child.session.events.filter(e => e.type === 'turn/end')).toHaveLength(1) + expect(child.session.header.seedLength).toBeUndefined() + await run.dispose() + }) + + it('seeds every completed parent turn through the last turn/end', async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('second'), textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + parent.send([{ type: 'text', text: 'q2' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = await start(ctx, 'fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.seedLength).toBe(parentPrefixLen) + expect(child.session.events.slice(0, parentPrefixLen).at(-1)?.type).toBe('turn/end') + expect(child.session.events.slice(0, parentPrefixLen).filter(e => e.type === 'turn/end')).toHaveLength(2) await run.dispose() }) diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index d07e6726dd..426753dbb9 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -161,13 +161,13 @@ function stopReasonError(result: SubagentResult): string | undefined { * A fresh child needs a standalone prompt; a forked child already sees the * conversation's completed turns — telling the model to restate everything * (or, worse, that the child "does not see this conversation") would be false - * for a fork. Exported for tests. + * for a fork. * @param inheritsConversation - whether the child's conversation is seeded * with the parent's completed turns; this says nothing about tool, service, * scope, or authority inheritance. * @returns the tool `description` and the `prompt` parameter description. */ -export function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { +function providerWording(inheritsConversation: boolean): { description: string; promptDescription: string } { if (inheritsConversation) { return { description: From 5c82310f47a57f5031e3301042a53a0768a53b30 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 03:58:34 +0800 Subject: [PATCH 015/163] refactor: prune unused llm contract fields --- docs/cordis-catalog/services.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- ...0-remove-redundant-snapshot-log-goldens.md | 2 +- .../error-finish/replay.override.json | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 8 ++-- .../llm/llm-deepseek/tests/adapter.spec.ts | 7 +-- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/assembler.ts | 8 ++-- packages/llm/llm/src/index.ts | 6 +-- packages/llm/llm/tests/assembler.spec.ts | 43 ++++++------------- packages/llm/llm/tests/service.spec.ts | 5 ++- packages/support/llm-replay/src/index.ts | 4 +- .../llm-replay/tests/llm-replay.spec.ts | 10 ++--- 13 files changed, 37 insertions(+), 64 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 0aea3f1589..95df09d45e 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -159,7 +159,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:88`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:86`](../../packages/llm/llm/src/index.ts) ## `ctx.sandbox` — `SandboxProvider` (abstract seam) diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index b424a253bd..4bfa3660bf 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -30,7 +30,7 @@ The replay seam is the provider-agnostic `llm/stream` waterfall ([packages/llm/l ``` { kind: 'chunks', chunks: StreamChunk[] } -| { kind: 'throw', chunks: StreamChunk[], message: string, code: string, status?: number } +| { kind: 'throw', chunks: StreamChunk[], message: string, code: string } | { kind: 'hang' } ``` diff --git a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md index 2a6f8b7ae3..a7c56af2bd 100644 --- a/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md +++ b/docs/rfc/implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md @@ -6,7 +6,7 @@ Status: implemented Model-driving ACP snapshot scenarios ship both `session.jsonl` and `session.golden.jsonl`. For normal recorded scenarios, `session.jsonl` is the replay fixture harvested from a real run, and the replay test normalizes the newly persisted log and compares it to `session.golden.jsonl`. In the current fixtures, the normalized recorded log and normalized golden are identical for the ordinary recorded scenarios. -Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string, "status"?: number }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. +Authored override scenarios (`error-finish`, `cancel`) currently use `replay.override.json` to drive model behavior and keep `session.jsonl` as a minimal dummy fixture, while `session.golden.jsonl` holds the expected persisted log. The override file is a JSON array of `ReplayEntry` objects: `{ "kind": "chunks", "chunks": StreamChunk[] }`, `{ "kind": "throw", "chunks": StreamChunk[], "message": string, "code": string }`, or `{ "kind": "hang" }`. That split is also unnecessary: when an override sidecar exists, `llm-replay` replaces the derived script and does not need `session.jsonl` for model chunks, so `session.jsonl` can still be the expected session-log artifact for the scenario. ## Decision diff --git a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json index eea32f25ca..cfa0d84227 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/replay.override.json +++ b/examples/acp-agent/tests/snapshots/error-finish/replay.override.json @@ -1,3 +1,3 @@ [ - { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH", "status": 401 } + { "kind": "throw", "chunks": [], "message": "simulated provider error (HTTP 401)", "code": "AUTH" } ] diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index e0627c5695..c90404720b 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -79,9 +79,9 @@ export class DeepSeekAdapter extends LlmAdapter { const parsed = await response.json() as WireError if (parsed.error?.message) message = parsed.error.message } catch { - // Paranoid by design: `code` and the HTTP status are ALREADY captured - // above (and passed to LlmError below), so the only thing this `try` - // can add is a richer provider-supplied message. A malformed, empty, + // Paranoid by design: the stable `code` and status-line message are + // already captured above, so the only thing this `try` can add is a + // richer provider-supplied message. A malformed, empty, // or non-JSON error body is a normal thing for gateways/proxies to // return on a 5xx/429 — swallowing the parse failure keeps the usable // status-line message instead of letting a JSON.parse throw mask the @@ -89,7 +89,7 @@ export class DeepSeekAdapter extends LlmAdapter { // is the sole statement, and any non-parse failure (e.g. body already // consumed) is equally non-actionable here. } - throw new LlmError(message, code, response.status) + throw new LlmError(message, code) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 46f123a1c7..1f1aef3f05 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -158,7 +158,7 @@ describe('DeepSeekAdapter against a mock server', () => { status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior, behavior]) + const server = await mockServer([behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -166,11 +166,6 @@ describe('DeepSeekAdapter against a mock server', () => { assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) - // The numeric HTTP status is carried on the error for explicit handling. - await expect( - assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).status), - ).resolves.toBe(status) }) it('keeps the status-line message for JSON error bodies without a message', async () => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 42694ba5ea..7c847447b8 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -42,7 +42,7 @@ Every product adapter must identify the application on every provider HTTP reque - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. ### Real adapters diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index 1b8ba6e60c..fb34afe0b9 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -38,12 +38,10 @@ export class BlockAssembler { private _finish: FinishReason | undefined /** - * Feed one chunk. Returns the completed block when the chunk closes one - * (an explicit `block-end`), otherwise undefined. + * Feed one chunk into the assembly state. * @param chunk - the next raw chunk, in stream order. - * @returns the authoritative block from the first `block-end` at its index; undefined for every other chunk. */ - push(chunk: StreamChunk): ContentBlock | undefined { + push(chunk: StreamChunk): void { switch (chunk.type) { case 'block-start': { if (!this.partials.has(chunk.index)) { @@ -79,7 +77,7 @@ export class BlockAssembler { // re-close could rewrite a block already flushed downstream. if (partial.block) return partial.block = chunk.block - return chunk.block + return } case 'usage': { this._usage = chunk.usage diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 667e9bca78..e32894efdd 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -42,12 +42,10 @@ declare module 'cordis' { /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the - * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy; - * `status` carries the HTTP status when the error originated from a non-2xx - * provider response (absent for protocol/usage errors that have no HTTP status). + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { + constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) this.name = 'LlmError' } diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index d9a4fe33f3..0674c55fb8 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -29,12 +29,12 @@ describe('BlockAssembler', () => { expect(assembler.message().role).toBe('assistant') }) - it('returns the completed block from push() on block-end', () => { + it('records the completed block from block-end', () => { const assembler = new BlockAssembler() - expect(assembler.push({ type: 'block-start', index: 0, blockType: 'text' })).toBeUndefined() - expect(assembler.push({ type: 'text-delta', index: 0, text: 'hi' })).toBeUndefined() - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-start', index: 0, blockType: 'text' }) + assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('tolerates deltas without explicit block-start/end', () => { @@ -57,8 +57,8 @@ describe('BlockAssembler', () => { // push a delta first to guarantee the partial exists assembler.push({ type: 'text-delta', index: 0, text: 'hi' }) // block-end's ensure() must find the existing partial (the second branch path) - const block = assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) - expect(block).toEqual({ type: 'text', text: 'hi' }) + assembler.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'hi' } }) + expect(assembler.blocks()).toEqual([{ type: 'text', text: 'hi' }]) }) it('throws from assemble() when a partial has an unhandled blockType', () => { @@ -130,7 +130,7 @@ describe('assertNever', () => { it('BlockAssembler.push rejects chunks outside the closed StreamChunk union', () => { const assembler = new BlockAssembler() - expect(() => assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk)) + expect(() => { assembler.push({ type: 'rogue-chunk' } as unknown as StreamChunk) }) .toThrow('unreachable variant in BlockAssembler.push') }) }) @@ -140,32 +140,13 @@ describe('BlockAssembler regressions (property-test findings)', () => { // Found by fast-check (the property-testing RFC): two block-ends at the same index made the // streamed prefix (first block) disagree with final blocks() (second // block). The first close must win — same straggler rule as post-close - // deltas — so the prefix returned incrementally by push() and the final - // blocks() stay identical. + // deltas — so later chunks cannot rewrite the completed block. const chunks: StreamChunk[] = [ { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'first' } }, { type: 'block-end', index: 0, block: { type: 'text', text: 'second' } }, ] - const streaming = new BlockAssembler() - const closed = [] - for (const chunk of chunks) { - const block = streaming.push(chunk) - if (block) closed.push(block) - } - - const oneShot = new BlockAssembler() - for (const chunk of chunks) oneShot.push(chunk) - - expect(closed).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(oneShot.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) - expect(closed).toEqual(oneShot.blocks()) - }) - - it('push returns undefined for a duplicate block-end (it closed nothing)', () => { - const a = new BlockAssembler() - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'x' } })) - .toEqual({ type: 'text', text: 'x' }) - expect(a.push({ type: 'block-end', index: 0, block: { type: 'text', text: 'y' } })) - .toBeUndefined() + const assembler = new BlockAssembler() + for (const chunk of chunks) assembler.push(chunk) + expect(assembler.blocks()).toEqual([{ type: 'reasoning', text: 'first' }]) }) }) diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index f669069c44..125a810261 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -79,11 +79,12 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const err = new LlmError('boom', 'AUTH', 401) + const cause = new Error('root cause') + const err = new LlmError('boom', 'AUTH', { cause }) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.status).toBe(401) + expect(err.cause).toBe(cause) }) it('HarnessError carries a code, names itself by subclass, and chains cause', async () => { diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 94bd0cdd7e..b919c21176 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -74,7 +74,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } | { kind: 'hang' } /** Resolved plugin configuration. */ @@ -324,7 +324,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) if (signal?.aborted) throw new Error('aborted') yield chunk } - throw new LlmError(entry.message, entry.code, entry.status) + throw new LlmError(entry.message, entry.code) case 'hang': // Replay a stream that stalls until cancelled (mirrors MockAdapter): one // chunk, then wait for abort and surface it as the consumer expects. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 3b4e8c5fee..f7e85a74bf 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -175,7 +175,7 @@ describe('loadReplayScript', () => { it('uses the sidecar override when present, ignoring the JSONL', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') - const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] writeFileSync(overrideFile, JSON.stringify(override), 'utf8') expect(loadReplayScript({ file, overrideFile })).toEqual(override) }) @@ -231,12 +231,12 @@ describe('installLlmReplay (through the real waterfall)', () => { expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(second) }) - it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) @@ -245,7 +245,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) expect(seen).toEqual(partial) }) @@ -350,7 +350,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) From 91075e010ab641a3cc236881d0713f68b27e8191 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:03:44 +0800 Subject: [PATCH 016/163] refactor: hide llm adapter helpers --- docs/config-catalog.md | 4 ++-- packages/llm/llm-deepseek/README.md | 2 ++ packages/llm/llm-deepseek/src/index.ts | 5 +---- packages/llm/llm-deepseek/tests/adapter.spec.ts | 16 +++++++++++++++- .../llm/llm-deepseek/tests/serialize.spec.ts | 2 +- packages/llm/llm-deepseek/tests/sse.spec.ts | 2 +- .../llm/llm-deepseek/tests/translate.spec.ts | 3 ++- packages/llm/llm-pi-ai/README.md | 2 ++ packages/llm/llm-pi-ai/src/index.ts | 3 +-- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 9 ++++++++- packages/llm/llm-pi-ai/tests/convert.spec.ts | 2 +- 11 files changed, 36 insertions(+), 14 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9d4b8038b5..9db148cbae 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -389,7 +389,7 @@ export interface Config { } ``` -Source: [`packages/llm/llm-deepseek/src/index.ts:43`](../packages/llm/llm-deepseek/src/index.ts) +Source: [`packages/llm/llm-deepseek/src/index.ts:40`](../packages/llm/llm-deepseek/src/index.ts) ## `@deepseek-ai/dsh-llm-pi-ai` @@ -420,7 +420,7 @@ export interface Config { export type PiAiReasoning = 'off' | 'high' | 'xhigh' ``` -Source: [`packages/llm/llm-pi-ai/src/index.ts:37`](../packages/llm/llm-pi-ai/src/index.ts) +Source: [`packages/llm/llm-pi-ai/src/index.ts:36`](../packages/llm/llm-pi-ai/src/index.ts) ## `@deepseek-ai/dsh-llm-replay` diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 15fad9f881..4d9ff8fcae 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -4,6 +4,8 @@ DeepSeek chat-completions adapter for the harness LLM seam: hand-rolled `fetch` A second, independent implementation of the same seam exists in `@deepseek-ai/dsh-llm-pi-ai` (library-backed). Same Config shape — pick one per context (registering both for the same model names throws by design). +The package root exposes the Cordis plugin contract and `DeepSeekAdapter`; wire serialization, SSE parsing, and chunk translation helpers are not part of that root contract. + ## Config ```yaml diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 3a3d7bb4a1..3b8013de5c 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -23,12 +23,9 @@ import z from 'schemastery' import type {} from '@deepseek-ai/dsh-llm' import { DeepSeekAdapter } from './adapter.ts' -export { DeepSeekAdapter, httpErrorCode } from './adapter.ts' +export { DeepSeekAdapter } from './adapter.ts' export type { DeepSeekAdapterOptions } from './adapter.ts' -export { serializeMessages, serializeRequest } from './serialize.ts' export type { RequestDefaults } from './serialize.ts' -export { DONE, parseSse } from './sse.ts' -export { mapFinishReason, mapUsage, translate } from './translate.ts' export type * from './types.ts' export const name = 'llm-deepseek' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1f1aef3f05..ca9c6b7097 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { LlmError, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import { DeepSeekAdapter, httpErrorCode } from '@deepseek-ai/dsh-llm-deepseek' +import { DeepSeekAdapter } from '@deepseek-ai/dsh-llm-deepseek' +import { httpErrorCode } from '../src/adapter.ts' import { assemble } from './assemble.ts' /** One scripted behavior for the next request the mock server receives. */ @@ -234,6 +235,19 @@ describe('DeepSeekAdapter against a mock server', () => { }) describe('plugin registration and config', () => { + it('keeps wire helpers off the package root', () => { + for (const helper of [ + 'httpErrorCode', + 'serializeMessages', + 'serializeRequest', + 'DONE', + 'parseSse', + 'mapFinishReason', + 'mapUsage', + 'translate', + ]) expect(LlmDeepSeek).not.toHaveProperty(helper) + }) + it('registers the configured models and unregisters on dispose (HMR safety)', async () => { const server = await mockServer([]) const ctx = new Context() diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 3e533f8e7c..a1d7d8b4a7 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' +import { serializeMessages, serializeRequest } from '../src/serialize.ts' function request(overrides: Partial = {}): GenerateOptions { return { model: 'deepseek-v4-flash', messages: [], ...overrides } diff --git a/packages/llm/llm-deepseek/tests/sse.spec.ts b/packages/llm/llm-deepseek/tests/sse.spec.ts index 2fc297bbec..b18862e4f3 100644 --- a/packages/llm/llm-deepseek/tests/sse.spec.ts +++ b/packages/llm/llm-deepseek/tests/sse.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { LlmError } from '@deepseek-ai/dsh-llm' -import { DONE, parseSse } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE, parseSse } from '../src/sse.ts' /** Build a byte stream from string fragments (fragments = network reads). */ async function* bytes(...fragments: (string | Uint8Array)[]): AsyncGenerator { diff --git a/packages/llm/llm-deepseek/tests/translate.spec.ts b/packages/llm/llm-deepseek/tests/translate.spec.ts index d6968faed5..e62cebc4af 100644 --- a/packages/llm/llm-deepseek/tests/translate.spec.ts +++ b/packages/llm/llm-deepseek/tests/translate.spec.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from 'vitest' import { BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' -import { DONE, mapFinishReason, mapUsage, translate } from '@deepseek-ai/dsh-llm-deepseek' +import { DONE } from '../src/sse.ts' +import { mapFinishReason, mapUsage, translate } from '../src/translate.ts' async function* feed(...payloads: (string | object)[]): AsyncGenerator { for (const payload of payloads) { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index f74ccd2246..adb22c585c 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -2,6 +2,8 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](https://www.npmjs.com/package/@earendil-works/pi-ai) (the LLM library behind the pi agent). +The package root exposes the Cordis plugin contract and `PiAiAdapter`; model construction and event-conversion helpers are not part of that root contract. + ## Why a second adapter exists `@deepseek-ai/dsh-llm-deepseek` already talks to the same endpoint. This package is its **design-verification twin**: same models, same wire protocol, completely different internals — a unified LLM library with its own event vocabulary versus hand-rolled fetch/SSE. Anything the harness `StreamChunk` protocol cannot express for BOTH implementations is a core-vocabulary bug. The differences it exercised on purpose: diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 43468ba507..fd81b77b56 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -22,9 +22,8 @@ import type {} from '@deepseek-ai/dsh-llm' import { PiAiAdapter } from './adapter.ts' import type { PiAiReasoning } from './adapter.ts' -export { buildModel, PiAiAdapter } from './adapter.ts' +export { PiAiAdapter } from './adapter.ts' export type { PiAiAdapterOptions, PiAiReasoning } from './adapter.ts' -export { mapStopReason, mapUsage, toPiContext, toStreamChunks } from './convert.ts' export const name = 'llm-pi-ai' export const inject = ['llm'] diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index cefaa9f745..cc4f129b72 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -4,7 +4,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import LlmService, { CallId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' -import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' +import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' +import { buildModel } from '../src/adapter.ts' import { assemble } from './assemble.ts' /** Scripted SSE responses, one per request (OpenAI chat-completions shape). */ @@ -245,6 +246,12 @@ describe('PiAiAdapter against a mock server', () => { }) describe('option spreads and env fallbacks', () => { + it('keeps adapter conversion helpers off the package root', () => { + for (const helper of ['buildModel', 'mapStopReason', 'mapUsage', 'toPiContext', 'toStreamChunks']) { + expect(LlmPiAi).not.toHaveProperty(helper) + } + }) + it('forwards temperature, maxTokens, and signal', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index 078d2a4d3b..3dc4709603 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' -import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' +import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '../src/convert.ts' function usage(input = 0, output = 0, cacheRead = 0, cacheWrite = 0): Usage { return { From f1373cd7abc479ae89a219e6a3fad6023f66aa64 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:15:17 +0800 Subject: [PATCH 017/163] refactor: drop unused tool schema defaults --- docs/core-data-structures/tools.md | 2 -- packages/core/tools/README.md | 2 +- packages/core/tools/src/schema.ts | 10 ------- packages/core/tools/tests/tools.spec.ts | 37 ++----------------------- 4 files changed, 3 insertions(+), 48 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 36c59aea17..1d59542404 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -57,8 +57,6 @@ interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] - /** Default value. */ - default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec /** Items schema for type: 'array'. */ diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index d57aeba10b..5f06154ec4 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -77,7 +77,7 @@ ctx.tools.register(defineTool({ The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. -A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. +A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index d3a2d32e18..ad7c28760e 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -39,15 +39,6 @@ export interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] - /** - * Default value, emitted into the JSON Schema only (validation never applies - * it — see the validator note below). - * - * XXX(unused-default): no tool definition in the repo sets `default`; it rides - * into the wire schema for a model that no tool surfaces it to. Drop the field - * and its converter line unless a real tool needs a model-visible default. - */ - default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec /** Items schema for type: 'array'. */ @@ -123,7 +114,6 @@ function propToJsonSchema(prop: SchemaProp): { schema: Record; const result: Record = { type: prop.type } if (prop.description) result.description = prop.description if (prop.enum) result.enum = prop.enum - if (prop.default !== undefined) result.default = prop.default const required = prop.required === true diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index a2dff84287..2bca8e05fc 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -904,17 +904,6 @@ describe('schema DSL edge cases', () => { }) }) - it('emits default value in JSON Schema property', () => { - const spec = { - limit: { type: 'number', default: 25 }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) - expect(jsonSchema.properties['limit']).toMatchObject({ - type: 'number', - default: 25, - }) - }) - it('handles array items without nested properties (plain type array)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, @@ -926,28 +915,12 @@ describe('schema DSL edge cases', () => { }) }) - it('handles enum and default together in one property', () => { - const spec = { - level: { type: 'string', enum: ['low', 'high'], default: 'low' }, - } satisfies SchemaSpec - const jsonSchema = schemaSpecToJsonSchema(spec) - expect(jsonSchema.properties['level']).toMatchObject({ - type: 'string', - enum: ['low', 'high'], - default: 'low', - }) - }) - - it('omits description, enum, default keys when not specified', () => { + it('emits only the type when optional fields are omitted', () => { const spec = { bare: { type: 'string' }, } satisfies SchemaSpec const jsonSchema = schemaSpecToJsonSchema(spec) - const prop = jsonSchema.properties['bare'] as Record - expect(prop).toEqual({ type: 'string' }) - expect('description' in prop).toBe(false) - expect('enum' in prop).toBe(false) - expect('default' in prop).toBe(false) + expect(jsonSchema.properties['bare']).toEqual({ type: 'string' }) }) it('handles array with no items (items omitted)', () => { @@ -1137,12 +1110,6 @@ describe('validateArgs (the runtime-validation RFC, part 1)', () => { expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([]) }) - it('does not apply defaults (validation only)', () => { - const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec - // absent optional is valid, and validation does not synthesize the default - expect(validateArgs(spec, {})).toEqual([]) - }) - it('type-checks primitives', () => { const spec = { s: { type: 'string' }, From 863116daaf406d3fc6485be819bcba0fc3d275f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:23:17 +0800 Subject: [PATCH 018/163] fix: retain dynamic tool schema defaults --- docs/core-data-structures/tools.md | 2 + .../cordis/tool-cordis/tests/mount.spec.ts | 6 ++- packages/core/tools/README.md | 2 +- packages/core/tools/src/schema.ts | 6 +++ packages/core/tools/tests/tools.spec.ts | 37 ++++++++++++++++++- 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 1d59542404..36c59aea17 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -57,6 +57,8 @@ interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] + /** Default value. */ + default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec /** Items schema for type: 'array'. */ diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index fc29eeb2a0..bf2e57c13e 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -185,9 +185,13 @@ describe('cordis_mount', () => { // The registered schema is canonical JSON Schema derived from the DSL: // the required array survived, integer became number, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! - const parameters = schema.parameters as { properties: Record; required?: string[] } + const parameters = schema.parameters as { + properties: Record + required?: string[] + } expect(parameters.required).toEqual(['text']) expect(parameters.properties.count!.type).toBe('number') + expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5f06154ec4..d57aeba10b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -77,7 +77,7 @@ ctx.tools.register(defineTool({ The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. -A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. +A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index ad7c28760e..e912252b18 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -39,6 +39,11 @@ export interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] + /** + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. + */ + default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec /** Items schema for type: 'array'. */ @@ -114,6 +119,7 @@ function propToJsonSchema(prop: SchemaProp): { schema: Record; const result: Record = { type: prop.type } if (prop.description) result.description = prop.description if (prop.enum) result.enum = prop.enum + if (prop.default !== undefined) result.default = prop.default const required = prop.required === true diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 2bca8e05fc..a2dff84287 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -904,6 +904,17 @@ describe('schema DSL edge cases', () => { }) }) + it('emits default value in JSON Schema property', () => { + const spec = { + limit: { type: 'number', default: 25 }, + } satisfies SchemaSpec + const jsonSchema = schemaSpecToJsonSchema(spec) + expect(jsonSchema.properties['limit']).toMatchObject({ + type: 'number', + default: 25, + }) + }) + it('handles array items without nested properties (plain type array)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, @@ -915,12 +926,28 @@ describe('schema DSL edge cases', () => { }) }) - it('emits only the type when optional fields are omitted', () => { + it('handles enum and default together in one property', () => { + const spec = { + level: { type: 'string', enum: ['low', 'high'], default: 'low' }, + } satisfies SchemaSpec + const jsonSchema = schemaSpecToJsonSchema(spec) + expect(jsonSchema.properties['level']).toMatchObject({ + type: 'string', + enum: ['low', 'high'], + default: 'low', + }) + }) + + it('omits description, enum, default keys when not specified', () => { const spec = { bare: { type: 'string' }, } satisfies SchemaSpec const jsonSchema = schemaSpecToJsonSchema(spec) - expect(jsonSchema.properties['bare']).toEqual({ type: 'string' }) + const prop = jsonSchema.properties['bare'] as Record + expect(prop).toEqual({ type: 'string' }) + expect('description' in prop).toBe(false) + expect('enum' in prop).toBe(false) + expect('default' in prop).toBe(false) }) it('handles array with no items (items omitted)', () => { @@ -1110,6 +1137,12 @@ describe('validateArgs (the runtime-validation RFC, part 1)', () => { expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([]) }) + it('does not apply defaults (validation only)', () => { + const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec + // absent optional is valid, and validation does not synthesize the default + expect(validateArgs(spec, {})).toEqual([]) + }) + it('type-checks primitives', () => { const spec = { s: { type: 'string' }, From 9028c9b63b6c3ed1737c168ae5e1548bab064668 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:35:34 +0800 Subject: [PATCH 019/163] fix: contain ACP update predicate failures --- packages/support/acp-snapshot/src/launcher.ts | 13 +++++++++++-- packages/support/acp-snapshot/tests/harness.spec.ts | 4 ++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 635a02ca35..975b1bb852 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -107,6 +107,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const updateWaiters: { match: (update: SessionNotification['update']) => boolean resolve: (update: SessionNotification['update']) => void + reject: (reason: unknown) => void }[] = [] const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, @@ -119,7 +120,15 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const waiter = updateWaiters[index] /* v8 ignore next 1 -- index is bounded by the array length */ if (waiter === undefined) continue - if (!waiter.match(params.update)) continue + let matches: boolean + try { + matches = waiter.match(params.update) + } catch (error: unknown) { + updateWaiters.splice(index, 1) + waiter.reject(error) + continue + } + if (!matches) continue updateWaiters.splice(index, 1) waiter.resolve(params.update) } @@ -136,7 +145,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), - waitForUpdate: match => new Promise(resolve => updateWaiters.push({ match, resolve })), + waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { if (child.exitCode !== null || child.signalCode !== null) return if (signal === undefined) child.stdin.end() diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index ec1ed5cf6f..db715db90c 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -57,7 +57,11 @@ describe('runScenario', () => { await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) const nextChunk = launched.waitForUpdate(update => update.sessionUpdate === 'agent_message_chunk') + const predicateFailure = new Error('predicate failed') + const failedPredicate = launched.waitForUpdate(() => { throw predicateFailure }) + .catch((error: unknown): unknown => error) await launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) + expect(await failedPredicate).toBe(predicateFailure) expect((await nextChunk).sessionUpdate).toBe('agent_message_chunk') expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') From 7d711c44a8a6202680f2801ba16b391858715163 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:41:01 +0800 Subject: [PATCH 020/163] fix: keep remote subagent ids parent-scoped --- .../2026-06-20-unify-agent-and-session-id.md | 4 ++-- .../core/agent-loop/tests/scope-lifecycle.spec.ts | 2 +- packages/subagent/subagent-acp/README.md | 2 ++ packages/subagent/subagent-acp/src/run.ts | 15 ++++++++++----- .../subagent-acp/tests/subagent-acp.spec.ts | 10 ++++++++-- packages/subagent/subagent/src/types.ts | 2 +- 6 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index be1052830a..032eae4f7d 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -14,7 +14,7 @@ Session itself repeated the same fact as `Session.id` and `Session.header.id`. C ## Decision -An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process and ACP subagent creation use the child session id; and `Session.id` derives from `header.id`. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between the ids are gone. +An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone. The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. @@ -28,7 +28,7 @@ The config-driven path keeps `agents[].id` as a stable configuration label, not - Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. - The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend uses the child server's returned session id as its run id. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local. - The config-driven resume-or-create policy is explicit and covered across a durable restart. - A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index c68d96e525..e29e73c393 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -249,7 +249,7 @@ describe('agent scope lifecycle', () => { }) await setupStarted.promise expect(ctx.agents.get(SessionId('atomic'))).toBeUndefined() - expect(ctx.sessions.get(SessionId('atomic-s'))).toBeUndefined() + expect(ctx.sessions.get(SessionId('atomic'))).toBeUndefined() expect(order).toEqual(['setup:start']) gate.resolve(undefined) const handle = await creating diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index c949685fcc..03b1a856ed 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -6,6 +6,8 @@ The ACP provider runs each subagent in a fresh subprocess and drives it as an Ag `start(request)` performs `spawn` → ACP `initialize` → `newSession` before it fulfills. Fulfillment therefore means a remote session is ready and ownership has transferred to the caller. A spawn, initialization, new-session, or pre-publication cancellation failure rejects only after the subprocess has been reaped. +The returned run id is minted in the parent namespace. The child server's session id remains private to ACP wire calls because ACP guarantees it only within that fresh child process; using it as the parent lifecycle id could collide with another remote run or a local agent. + After publication, the provider sends the prompt and collects streamed `agent_message_chunk` text into `SubagentResult.output`. A prompt/transport failure resolves with `stopReason: 'error'`, or `aborted` when the required request signal or disposal requested cancellation. `dispose()` is idempotent. It removes the signal listener, requests ACP cancellation when possible, closes stdin, waits `disposeEofGraceMs`, escalates to SIGTERM, waits `disposeGraceMs`, and finally uses SIGKILL if necessary. Every run uses a fresh process; process pooling is not implemented. diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 1cc220204f..7d0c11cbe1 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -23,6 +23,7 @@ */ import { spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' import { Readable, Writable } from 'node:stream' import { ClientSideConnection, @@ -190,6 +191,10 @@ function toError(value: unknown): Error { */ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Promise { if (request.signal.aborted) throw new Error('subagent request was aborted before the ACP child started') + // ACP session ids are unique only within the child server. The lifecycle id + // is minted in the parent namespace so fresh processes cannot collide with + // each other or with a local agent that happens to use the same session id. + const id = SessionId(randomUUID()) // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP // response channel, stderr = INHERIT so the child's diagnostics surface on the @@ -257,7 +262,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe ), ) - let sessionId: SessionId | undefined + let sessionId: string | undefined // Resolves when a cancel is requested, so `result` can settle `aborted` even // if the child never cooperates with `session/cancel` (it ignores the notify, // or the prompt wedges). The result path races this against the ACP drive: the @@ -306,7 +311,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe clientCapabilities: {}, }) const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = SessionId(session.sessionId) + sessionId = session.sessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), spawnFailed.then((err): never => { throw err }), @@ -322,7 +327,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // guard keeps that cross-closure invariant explicit for TypeScript. /* v8 ignore next */ if (sessionId === undefined) throw new Error('ACP child published without a session id') - const runId = sessionId + const remoteSessionId = sessionId const result: Promise = (async (): Promise => { try { @@ -334,7 +339,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe // succeeds, transport/process failure rejects the in-flight prompt RPC. const prompt = async (): Promise => { // The startup phase cannot fulfill without assigning the session id. - const promptResult = await conn.prompt({ sessionId: runId, prompt: toAcpPrompt(request.prompt) }) + const promptResult = await conn.prompt({ sessionId: remoteSessionId, prompt: toAcpPrompt(request.prompt) }) return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } } return await Promise.race([ @@ -368,7 +373,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let disposal: Promise | undefined return { - id: runId, + id, result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 115d5c3e97..e5fe05384e 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -121,16 +121,22 @@ describe('buildChildEnv', () => { }) describe('dsh-subagent-acp', () => { - it('drives a child process to completion and returns its streamed output', async () => { + it('drives child processes with parent-unique run ids and returns streamed output', async () => { const ctx = await setup({ MOCK_TEXT: 'hello from acp child', MOCK_STOP: 'end_turn', MOCK_SESSION_ID: 'acp-child-session' }) const run = await ctx.subagents.start('acp', request('do X')) - expect(run.id).toBe('acp-child-session') + expect(run.id).not.toBe('acp-child-session') const result = await run.result expect(result.stopReason).toBe('completed') expect(text(result.output)).toBe('hello from acp child') const disposal = run.dispose() expect(run.dispose()).toBe(disposal) await disposal + + const nextRun = await ctx.subagents.start('acp', request('do X again')) + expect(nextRun.id).not.toBe(run.id) + expect(nextRun.id).not.toBe('acp-child-session') + await nextRun.result + await nextRun.dispose() }) it('maps a max_tokens stop reason', async () => { diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 8d360b1182..827c2db7a7 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -147,7 +147,7 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** The child agent's id (local in-process runs are already published in `ctx.agents`; remote transports need not publish locally). */ + /** Parent-scoped run id. Local runs use the published child session id; remote providers mint an id unique in the parent namespace. */ readonly id: SessionId /** * Resolves with the child's terminal {@link SubagentResult} when the run From a415d8fdb1b3de4425495b6c87707b58f5c3f65f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:43:10 +0800 Subject: [PATCH 021/163] docs: remove stale streamed-prefix rationale --- .../implemented/testing/2026-06-11-property-based-testing.md | 2 +- packages/llm/llm/src/assembler.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md index 67404ab49a..db7a9c8a79 100644 --- a/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md +++ b/docs/rfc/implemented/testing/2026-06-11-property-based-testing.md @@ -20,7 +20,7 @@ Adopt `fast-check` (a root devDependency) with one `tests/properties.spec.ts` pe ## Consequences - Generator quality is the value lever — the generators bias toward small index pools and short strings so collisions and interleavings are common. -- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index overwrote an already-flushed block, so the streamed prefix disagreed with final `blocks()`. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. +- **It already paid off:** the BlockAssembler stream found a real bug — a duplicate `block-end` at the same index rewrote a completed block. Fixed (first close wins, matching the existing straggler rule) with a dedicated regression test. - A property flake from a timeout is a finding, not something to retry away. The loop properties are deterministic by construction (settle on `agent/status`), so a hang is a real defect. - Property tests supplement, not replace, the example tests that pin specific branches for the 100%-coverage gate. diff --git a/packages/llm/llm/src/assembler.ts b/packages/llm/llm/src/assembler.ts index fb34afe0b9..1dd023d177 100644 --- a/packages/llm/llm/src/assembler.ts +++ b/packages/llm/llm/src/assembler.ts @@ -72,9 +72,8 @@ export class BlockAssembler { case 'block-end': { const partial = this.ensure(chunk.index, chunk.block.type) // First close wins: a second block-end for an already-closed index is - // a straggler (same rule as post-close deltas). Ignoring it keeps the - // streamed prefix and the final blocks() in agreement — otherwise a - // re-close could rewrite a block already flushed downstream. + // a straggler (same rule as post-close deltas). Ignoring it prevents a + // later chunk from rewriting a completed block. if (partial.block) return partial.block = chunk.block return From 60ce23d77c52b6f4c183c320d2a1b54c4aedea7f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:07:37 +0800 Subject: [PATCH 022/163] fix: surface ACP launcher spawn failures --- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 1 + packages/support/acp-snapshot/src/launcher.ts | 22 ++++++++++++++++++- .../acp-snapshot/tests/harness.spec.ts | 13 ++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 4a43d0eef9..0058e9116f 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`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). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 93bb556ba6..d04b0bc2b5 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -214,6 +214,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise }, }) const active = launched + await active.spawned const { client } = active for (const step of input.steps) { diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 975b1bb852..3508b02ec5 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -53,6 +53,8 @@ export interface AcpTestLaunchOptions { export interface LaunchedAcpTestAgent { /** The child process, exposed for process-level assertions. */ child: ChildProcessWithoutNullStreams + /** Resolve when the OS spawns the child; reject with its asynchronous spawn failure. */ + spawned: Promise /** The SDK connection backed by the child's stdio. */ client: ClientSideConnection /** Session updates in receive order. */ @@ -90,6 +92,18 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe stdio: ['pipe', 'pipe', 'pipe'], }, ) + // A spawn-level failure is an asynchronous `error` event. Observe it in the + // same tick as spawn so a missing cwd or OS rejection cannot crash the test + // runner, then make startup and shutdown surface the original error. + const childFailure = new Promise(resolve => child.once('error', resolve)) + const spawned = Promise.race([ + new Promise(resolve => child.once('spawn', resolve)), + childFailure.then((error): never => { throw error }), + ]) + // `spawned` is public and close() also awaits it, but a caller may ignore both. + // Keep that misuse from turning the already-observed child error into an + // unhandled promise rejection. + void spawned.catch(() => undefined) const stderrChunks: string[] = [] child.stderr.setEncoding('utf8') @@ -141,16 +155,22 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe return { child, + spawned, client, updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { + await spawned if (child.exitCode !== null || child.signalCode !== null) return if (signal === undefined) child.stdin.end() else child.kill(signal) - await waitForExit(child) + const failure = await Promise.race([ + waitForExit(child).then((): undefined => undefined), + childFailure, + ]) + if (failure !== undefined) throw failure }, } } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index db715db90c..69c02a407f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -40,6 +40,13 @@ async function scenario(behavior: object): Promise<{ dir: string; fixtureFile: s const boot: InputStep[] = [{ op: 'initialize' }, { op: 'newSession' }] describe('runScenario', () => { + it('surfaces an asynchronous child spawn failure through startup and close', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + }) + it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ permissionProbe: true, echoEnv: true, stderrNote: 'launcher stderr' }) const sessionsRoot = await mkdtemp(join(tmpdir(), 'acp-launcher-sessions-')) @@ -72,7 +79,11 @@ describe('runScenario', () => { // The minimal shape needs no environment or config override. const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - await minimal.close() + const childFailure = new Error('child process failed') + const exited = new Promise(resolve => minimal.child.once('exit', () => { resolve() })) + minimal.child.emit('error', childFailure) + await expect(minimal.close('SIGKILL')).rejects.toBe(childFailure) + await exited }) it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { From e9d1e1cdda70eedb49a12f9adce2afb18caa4c00 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:19:24 +0800 Subject: [PATCH 023/163] docs: update ACP ownership routing --- .../rfc/implemented/feature/2026-06-14-acp-multi-session.md | 4 ++-- .../rfc/implemented/feature/2026-06-25-ask-user-question.md | 2 +- docs/rfc/implemented/feature/2026-07-06-approval-seam.md | 6 +++--- packages/ui/acp/README.md | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md index 77fa2b4669..64961bee89 100644 --- a/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md +++ b/docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md @@ -8,11 +8,11 @@ An ACP editor can keep several conversations alive over one agent subprocess. A ## Decision -The ACP bridge stores live sessions in `Map` and keeps a `WeakMap` reverse index for agent-scoped callbacks. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently. +The ACP bridge stores live sessions in `Map`. Agent-scoped callbacks use `ownedRecord`: look up `agent.session.id` in that forward map and accept the record only when it owns the exact agent object, so a foreign same-id object cannot claim the session. A record owns its agent handle, in-flight prompt, live tool-call presentation state, pending idle config switches, session cwd, and client capability snapshot. A separate loading-id set reserves each id before asynchronous resume so two pipelined loads cannot construct duplicate agents; distinct ids may load concurrently. Every `session/event` and `agent/status` callback resolves the owning record before sending or settling anything. Each session permits one in-flight prompt independently. The prompt records a log watermark, captures its own `turn/start`, and settles only on the matching `turn/end`; a late end from a cancelled prior turn cannot resolve a newer prompt. `session/cancel` addresses one record and calls only that agent's queue-aware cancel path. -Permission ownership uses the same reverse index. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them. +Permission ownership uses the same exact-agent check against the forward map. The ACP `approval/request` answerer prompts only the editor session that owns the requesting agent and delegates foreign requests. User-interaction elicitations likewise route by agent ownership. Per-session sandbox and approval config values fold only that session's events, with pending idle switches stored on that record until the next turn anchors them. Background bash tasks carry an opaque owner token equal to the owning session id. `bash_output` and `bash_kill` compare the caller's token with the executor's task ownership before reading or killing; a predictable task id alone grants no access. Ownership is stored with the executor task, so a tool plugin reload does not erase it. diff --git a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md index 9320189de1..42e5d9a7ab 100644 --- a/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md +++ b/docs/rfc/implemented/feature/2026-06-25-ask-user-question.md @@ -22,7 +22,7 @@ Providers return `{ answers: [{ id, selected, custom? }] }`. `selected` is alway `dsh-stdio-agent`'s in-package readline module renders each question, shows each option's `description` on the next line, supports comma/space-separated numeric choices for `multi_select`, accepts free-form custom answers, and rejects pending questions on abort, provider disposal, or stdin EOF. A batched request is asked in order and resolved as one answer object. The stdio provider serializes simultaneous requests with an internal queue so only one prompt owns stdin at a time. -`dsh-acp` provides the same seam for ACP sessions. It routes an ask request from the calling `Agent` through the bridge's `agent→sessionId` reverse map and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. +`dsh-acp` provides the same seam for ACP sessions. It resolves the calling `Agent` through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, and calls ACP `unstable_createElicitation` with a session-scoped form for each question. Single-select options become a `choice` string enum; `multi_select` options become a `choice` array enum; optionless questions use a required `custom` text field. If the client returns both `choice` and non-empty `custom`, the custom answer wins. ACP `decline`/`cancel`, a missing answer, a missing session, and a client without elicitation support all become structured `UserInteractionError`s. The ACP mapping deliberately uses elicitation, not `session/request_permission`. `request_permission` is still reserved for the separate permission gate: it is a yes/no-or-policy authorization protocol around tool execution. `ask_user_question` is a general information-gathering tool with optional free-form answers, so ACP form elicitation is the closer protocol fit. The bridge's session routing is shared with the future permission gate, but the user intent is different. diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 5d82226a96..c5d61b0edb 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -65,9 +65,9 @@ The seam also owns the session-scoped approval policy — the approval knob of t #### The ACP answerer -The bridge registers the first real answerer: it resolves the owning session through its existing `WeakMap` reverse map, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. +The bridge registers the first real answerer: it resolves the owning session through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. -The answerer routes through the bridge's reverse-map ownership seam described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). +The answerer routes through the bridge's exact-agent ownership check described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees @@ -137,5 +137,5 @@ In-repo precedents this design copies or contrasts with: - The `fs/write-intent` gate (`packages/fs/fs/`) — the documented single-occupancy decision-slot waterfall semantics (first answer wins, delegate via `next()`) the answerer contract reuses. - `hook/invoked`/`hook/result` — the log-only audit-pair precedent `approval/asked`/`approval/decided` follows; [the hook-bridges RFC](2026-06-30-hook-bridges.md) ships `permissionDecision: ask`, the first producer. - [The interception-seams RFC](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary whose `ask` this seam services. -- [The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) — the `WeakMap` ownership seam the answerer routes through; [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. +- [The ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md) — the exact-agent ownership check against the forward session map that the answerer routes through; [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md) — the per-session permission-ownership blocker this implements. - The opportunistic `ctx.get()` consumption pattern (`tool-bash`'s owner-token lookup, the loop's persistence probe) — how `dsh-tools` consumes the seam without gating its fiber on it. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 31a865c068..4b9a278707 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -75,7 +75,7 @@ A `session/prompt` resolves or rejects exactly once from the canonical `session/ ## Permission prompts -The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning session through the reverse map and issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority. +The bridge registers an `approval/request` waterfall listener — the ACP answerer of the [user-approval seam](../user-approval/README.md). When `ctx.approval` routes an `ask` for an agent the bridge owns, the listener resolves the owning `SessionRecord` through `ownedRecord`, which looks up `agent.session.id` in the forward session map and requires the record to own that exact agent object. It then issues `session/request_permission` with the request's `callId` as the `toolCall` reference (the editor attaches the prompt to the already-streamed call) and the one-shot options `allow_once`/`reject_once` (`allow_always` is deferred to the approval RFC's grant-storage question). Outcomes map `allow-once → allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled → cancelled`. A request for an agent the bridge does NOT own — or one without a `callId` to attach to — delegates via `next()` so another answerer or the seam's fail-closed `unavailable` default takes it. A rejected `requestPermission` RPC (client gone mid-prompt) propagates to the ApprovalService, which contains it as `unavailable`. Whether a call asks at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment; without such policy, tools keep the executor's full authority. ## Disposal & disconnect From 140d32681ef2fc4b35dbae710044c7b7c4e693b7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:33:22 +0800 Subject: [PATCH 024/163] fix: make ACP test teardown failure-safe --- examples/acp-agent/tests/acp.e2e.ts | 14 ++++-- examples/acp-agent/tests/hooks.e2e.ts | 14 ++++-- .../sandbox-acp-agent/tests/escalation.e2e.ts | 14 ++++-- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 44 ++++++++++++------- packages/support/acp-snapshot/src/launcher.ts | 25 +++++++++-- .../acp-snapshot/tests/harness.spec.ts | 8 ++-- 7 files changed, 86 insertions(+), 35 deletions(-) diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index 4b6c675208..871006dfb4 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -30,10 +30,16 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - await spawned?.close('SIGKILL') - spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined + try { + await spawned?.close('SIGKILL') + } finally { + spawned = undefined + try { + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + } finally { + workdir = undefined + } + } }) describe('acp-agent over real stdio (no key required)', () => { diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index a553addc96..8dc169f437 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -38,10 +38,16 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - await spawned?.close('SIGKILL') - spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined + try { + await spawned?.close('SIGKILL') + } finally { + spawned = undefined + try { + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + } finally { + workdir = undefined + } + } }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { diff --git a/examples/sandbox-acp-agent/tests/escalation.e2e.ts b/examples/sandbox-acp-agent/tests/escalation.e2e.ts index a7e8787e36..0ddaa6426e 100644 --- a/examples/sandbox-acp-agent/tests/escalation.e2e.ts +++ b/examples/sandbox-acp-agent/tests/escalation.e2e.ts @@ -81,10 +81,16 @@ let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - await spawned?.close('SIGKILL') - spawned = undefined - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - workdir = undefined + try { + await spawned?.close('SIGKILL') + } finally { + spawned = undefined + try { + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + } finally { + workdir = undefined + } + } }) describe('sandbox-acp-agent keyless smoke (real cordis.yml via the Loader)', () => { diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 0058e9116f..26192f1cac 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit before resolving or propagating a child error, so callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`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). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d04b0bc2b5..3db1355b0f 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -163,7 +163,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise let launched: LaunchedAcpTestAgent | undefined let sessionId: string | undefined let sessionLogs: HarvestedLog[] = [] - try { + const outcome = await (async (): Promise => { // Seed the workspace if the scenario ships one (a file the agent reads/edits). // Copied into the temp cwd so the agent's bash tools see it; the goldens // normalize the cwd, so the seeded paths stay stable across runs. @@ -231,22 +231,36 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // Harvest EVERY persisted log (parent + any subagent children) while the // temp dirs still exist, ordered primary-first. sessionLogs = await harvestSessionLogs(sessionsRoot) - } finally { - // Failure-safe teardown: kill a still-running child and drop the temp dirs - // even if seeding/spawn/a step/harvest threw, so a flaky run never leaks a - // process or dir. `launched` is undefined only if launch itself threw. - await launched?.close('SIGKILL') - await rm(cwd, { recursive: true, force: true }) - await rm(sessionsRoot, { recursive: true, force: true }) - } + return { + rawStdout: launched.rawStdout(), + stderr: launched.stderr(), + cwd, + ...sessionId !== undefined ? { sessionId } : {}, + sessionLogs, + } + })().then( + value => ({ status: 'fulfilled', value } as const), + (error: unknown) => ({ status: 'rejected', error } as const), + ) - return { - rawStdout: launched.rawStdout(), - stderr: launched.stderr(), - cwd, - ...sessionId !== undefined ? { sessionId } : {}, - sessionLogs, + // Failure-safe teardown: wait for a still-running child, then attempt BOTH + // directory removals even when an earlier cleanup rejects. The main outcome + // wins over teardown noise so a step/harvest failure is never replaced; on a + // successful run, the first cleanup failure remains visible to the caller. + const cleanupResults: PromiseSettledResult[] = [] + const cleanup = async (action: () => Promise): Promise => { + cleanupResults.push(...await Promise.allSettled([action()])) } + /* v8 ignore next 1 -- launch itself can only throw on a defensive synchronous spawn API failure */ + await cleanup(() => launched?.close('SIGKILL') ?? Promise.resolve()) + await cleanup(() => rm(cwd, { recursive: true, force: true })) + await cleanup(() => rm(sessionsRoot, { recursive: true, force: true })) + + if (outcome.status === 'rejected') throw outcome.error + const cleanupFailure = cleanupResults.find((result): result is PromiseRejectedResult => result.status === 'rejected') + /* v8 ignore next 1 -- defensive OS cleanup failure after an otherwise successful real subprocess run */ + if (cleanupFailure !== undefined) throw cleanupFailure.reason + return outcome.value } /** Drive one input step over the client connection. */ diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 3508b02ec5..7f2ec9b0a7 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -95,7 +95,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // A spawn-level failure is an asynchronous `error` event. Observe it in the // same tick as spawn so a missing cwd or OS rejection cannot crash the test // runner, then make startup and shutdown surface the original error. - const childFailure = new Promise(resolve => child.once('error', resolve)) + // Keep observing after the first error: a fallback kill attempted during + // shutdown may itself report another process error, which must not become an + // unhandled EventEmitter error after the promise has already settled. + const childFailure = new Promise(resolve => child.on('error', resolve)) const spawned = Promise.race([ new Promise(resolve => child.once('spawn', resolve)), childFailure.then((error): never => { throw error }), @@ -163,14 +166,23 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), async close(signal?: NodeJS.Signals): Promise { await spawned - if (child.exitCode !== null || child.signalCode !== null) return + if (!isRunning(child)) return + const exited = waitForExit(child) if (signal === undefined) child.stdin.end() else child.kill(signal) const failure = await Promise.race([ - waitForExit(child).then((): undefined => undefined), + exited.then((): undefined => undefined), childFailure, ]) - if (failure !== undefined) throw failure + if (failure === undefined) return + + // An `error` after spawn is not an exit edge: in particular, a failed + // signal can leave the subprocess live. Force termination, await the + // already-observed exit edge, and only then propagate the child error so + // callers may safely remove cwd/session resources after close rejects. + child.kill('SIGKILL') + await exited + throw failure }, } } @@ -179,3 +191,8 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe function waitForExit(child: ChildProcessWithoutNullStreams): Promise { return new Promise(resolve => child.once('exit', () => { resolve() })) } + +/** Whether the child still lacks either OS termination marker. */ +function isRunning(child: ChildProcessWithoutNullStreams): boolean { + return child.exitCode === null && child.signalCode === null +} diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 69c02a407f..3c1bb0d44a 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -80,10 +80,12 @@ describe('runScenario', () => { const minimal = launchAcpTestAgent({ agent: AGENT, cwd: dir }) await minimal.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) const childFailure = new Error('child process failed') - const exited = new Promise(resolve => minimal.child.once('exit', () => { resolve() })) + let exited = false + minimal.child.once('exit', () => { exited = true }) minimal.child.emit('error', childFailure) - await expect(minimal.close('SIGKILL')).rejects.toBe(childFailure) - await exited + await expect(minimal.close('SIGTERM')).rejects.toBe(childFailure) + // close rejects only after the fallback SIGKILL has produced an exit edge. + expect(exited).toBe(true) }) it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { From 58e61ae20ba28101be0bed01505b03ec20cd6a7e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:37:58 +0800 Subject: [PATCH 025/163] fix: retain stdio target across agent replacement --- packages/ui/stdio-agent/src/stdio-chat.ts | 19 +++++++++++------ .../ui/stdio-agent/tests/stdio-chat.spec.ts | 21 +++++++++++++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index cb9ce0bc10..bfb58e3a57 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -95,13 +95,20 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // This app owns exactly one pre-created agent. Hold the live object directly: - // its per-run id is intentionally fresh, while `main` remains only the - // terminal's fixed display label. - let target: Agent | undefined = ctx.agents.list()[0] - ctx.on('agent/created', (agent) => { target ??= agent }) + // This app owns one root agent. Hold the live object directly: its per-run id + // is intentionally fresh, while `main` remains only the terminal's fixed + // display label. HMR may publish the replacement before old teardown emits + // disposed, so a target disposal reselects the surviving root from the live + // registry instead of leaving the terminal detached. Fork children carry + // parentSession lineage and must never become the terminal target. + const rootAgent = (): Agent | undefined => + ctx.agents.list().find(agent => agent.session.header.parentSession === undefined) + let target: Agent | undefined = rootAgent() + ctx.on('agent/created', (agent) => { + if (target === undefined && agent.session.header.parentSession === undefined) target = agent + }) ctx.on('agent/disposed', (agent) => { - if (target === agent) target = undefined + if (target === agent) target = rootAgent() }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 69ca5f8837..2b1df53921 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -230,6 +230,27 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[main turn 1] ') }) + it('retargets a surviving root when HMR publishes it before disposing the old root', async () => { + const { ctx, input } = await setup() + const oldRoot = makeAgent('old-root') + const child = makeAgent('child') + ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id + const replacement = makeAgent('replacement') + const disposeOld = ctx.agents.register(oldRoot) + ctx.agents.register(child) + ctx.agents.register(replacement) + + // The replacement's created edge arrived while oldRoot was still targeted. + // Once oldRoot is removed, registry order is child then replacement; the + // UI must skip the surviving child and route input to the replacement root. + disposeOld() + input.feed('after hmr') + await new Promise(resolve => setImmediate(resolve)) + + expect(child.sent).toEqual([]) + expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) + }) + it('renders tool/call and tool/result session events', async () => { const { ctx, out } = await setup() const session = {} as Session From 3bd2052d7251b9500e8afca02aaee64ca8b2ad40 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 06:52:46 +0800 Subject: [PATCH 026/163] fix: allow lineage-bearing stdio targets --- packages/ui/stdio-agent/src/stdio-chat.ts | 24 +++++++++---------- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 17 ++++++++++++- 2 files changed, 27 insertions(+), 14 deletions(-) diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index bfb58e3a57..2a4d620494 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -95,20 +95,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // This app owns one root agent. Hold the live object directly: its per-run id - // is intentionally fresh, while `main` remains only the terminal's fixed - // display label. HMR may publish the replacement before old teardown emits - // disposed, so a target disposal reselects the surviving root from the live - // registry instead of leaving the terminal detached. Fork children carry - // parentSession lineage and must never become the terminal target. - const rootAgent = (): Agent | undefined => - ctx.agents.list().find(agent => agent.session.header.parentSession === undefined) - let target: Agent | undefined = rootAgent() - ctx.on('agent/created', (agent) => { - if (target === undefined && agent.session.header.parentSession === undefined) target = agent - }) + // This app owns one configured agent. Hold the live object directly: its + // per-run id is intentionally fresh, while `main` remains only the + // terminal's fixed display label. At install the configured agent is the + // earliest registry entry (it creates any subagents later). During HMR the + // replacement is published after the old tree, so when old teardown finally + // emits disposed, the newest survivor is the replacement. Persisted + // parentSession lineage is deliberately irrelevant: a resumed child session + // can itself be this process's configured top-level agent. + let target: Agent | undefined = ctx.agents.list()[0] + ctx.on('agent/created', (agent) => { target ??= agent }) ctx.on('agent/disposed', (agent) => { - if (target === agent) target = rootAgent() + if (target === agent) target = ctx.agents.list().at(-1) }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2b1df53921..2654a05680 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -185,6 +185,9 @@ describe('createStdioChat rendering', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) const agent = makeAgent('main') + // Durable lineage does not imply runtime child ownership: the stdio app + // may explicitly resume a persisted fork as its one configured agent. + ;(agent.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(agent) // registered BEFORE the UI plugin below const { runtime, out } = makeRuntime() await ctx.plugin(Object.assign((inner: Context) => { @@ -196,6 +199,18 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[main turn 5] ') }) + it('accepts a lineage-bearing configured agent created after the UI installs', async () => { + const { ctx, input } = await setup() + const resumed = makeAgent('resumed') + ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' + ctx.emit('agent/created', resumed) + + input.feed('continue') + await new Promise(resolve => setImmediate(resolve)) + + expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) + }) + it('resets dim styling at turn/end if a turn ends mid-reasoning', async () => { const { ctx, out } = await setup() const session = makeSession('main') @@ -242,7 +257,7 @@ describe('createStdioChat rendering', () => { // The replacement's created edge arrived while oldRoot was still targeted. // Once oldRoot is removed, registry order is child then replacement; the - // UI must skip the surviving child and route input to the replacement root. + // most recently published survivor is the HMR replacement. disposeOld() input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) From e2a5a160d3763ee7faaa9b4235ae7284144f05ee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:00:10 +0800 Subject: [PATCH 027/163] fix: settle ACP update waiters on shutdown --- packages/support/acp-snapshot/src/launcher.ts | 43 ++++++++++++++----- .../acp-snapshot/tests/harness.spec.ts | 3 ++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 7f2ec9b0a7..253e67fd5a 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -114,18 +114,26 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const rawBuffers: Buffer[] = [] const passthrough = new Readable({ read() {} }) - child.stdout.on('data', (buffer: Buffer) => { - rawBuffers.push(buffer) - passthrough.push(buffer) - }) - child.stdout.on('end', () => passthrough.push(null)) - const updates: SessionNotification['update'][] = [] const updateWaiters: { match: (update: SessionNotification['update']) => boolean resolve: (update: SessionNotification['update']) => void reject: (reason: unknown) => void }[] = [] + let updateStreamFailure: Error | undefined + const closeUpdateStream = (): void => { + if (updateStreamFailure !== undefined) return + updateStreamFailure = new Error('ACP test agent update stream closed before a matching session update arrived') + for (const waiter of updateWaiters.splice(0)) waiter.reject(updateStreamFailure) + } + child.stdout.on('data', (buffer: Buffer) => { + rawBuffers.push(buffer) + passthrough.push(buffer) + }) + child.stdout.on('end', () => { + passthrough.push(null) + closeUpdateStream() + }) const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(passthrough) as ReadableStream, @@ -163,10 +171,21 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe updates, rawStdout: () => Buffer.concat(rawBuffers).toString('utf8'), stderr: () => stderrChunks.join(''), - waitForUpdate: match => new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })), + waitForUpdate(match): Promise { + if (updateStreamFailure !== undefined) return Promise.reject(updateStreamFailure) + return new Promise((resolve, reject) => updateWaiters.push({ match, resolve, reject })) + }, async close(signal?: NodeJS.Signals): Promise { - await spawned - if (!isRunning(child)) return + try { + await spawned + } catch (error: unknown) { + closeUpdateStream() + throw error + } + if (!isRunning(child)) { + closeUpdateStream() + return + } const exited = waitForExit(child) if (signal === undefined) child.stdin.end() else child.kill(signal) @@ -174,7 +193,10 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe exited.then((): undefined => undefined), childFailure, ]) - if (failure === undefined) return + if (failure === undefined) { + closeUpdateStream() + return + } // An `error` after spawn is not an exit edge: in particular, a failed // signal can leave the subprocess live. Force termination, await the @@ -182,6 +204,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // callers may safely remove cwd/session resources after close rejects. child.kill('SIGKILL') await exited + closeUpdateStream() throw failure }, } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 3c1bb0d44a..192d36dc6c 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -73,7 +73,10 @@ describe('runScenario', () => { expect(launched.updates.some(update => update.sessionUpdate === 'agent_message_chunk')).toBe(true) expect(launched.rawStdout()).toContain('permission:{\\"outcome\\":\\"cancelled\\"}') expect(launched.stderr()).toContain('launcher stderr') + const unmatched = expect(launched.waitForUpdate(() => false)).rejects.toThrow(/update stream closed/) await launched.close() + await unmatched + await expect(launched.waitForUpdate(() => true)).rejects.toThrow(/update stream closed/) await launched.close('SIGKILL') // The minimal shape needs no environment or config override. From c2979322c8b938b1c2e91920740688788867e6d7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:13:42 +0800 Subject: [PATCH 028/163] fix: keep stdio targeting on runtime roots --- docs/cordis-catalog/services.md | 5 ++-- packages/core/agent-loop/src/index.ts | 2 +- .../agent-loop/tests/scope-lifecycle.spec.ts | 18 +++++++++++++ packages/core/agent/README.md | 3 ++- packages/core/agent/src/index.ts | 22 ++++++++++++++-- packages/core/agent/tests/agent.spec.ts | 24 +++++++++++++++--- packages/ui/stdio-agent/src/stdio-chat.ts | 20 +++++++-------- .../ui/stdio-agent/tests/readline.spec.ts | 4 +-- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 25 ++++++++++++------- 9 files changed, 92 insertions(+), 31 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 4d162a55f5..1366fcf6f6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -30,15 +30,16 @@ setFactory(factory: AgentFactory): () => void async create(options: CreateAgentOptions): Promise async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void -enter(agent: Agent): () => void +enter(agent: Agent, owner: Agent | undefined): () => void announce(agent: Agent): void get(id: SessionId): Agent | undefined list(): Agent[] +roots(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:199`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33276c01f8..dbe503ff6a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -229,7 +229,7 @@ class AgentCreationTransaction { this.publishing = true try { this.detachSession = agent.ctx.sessions.enter(session) - this.detachAgent = this.loopCtx.agents.enter(agent) + this.detachAgent = this.loopCtx.agents.enter(agent, this.ownerAgent) agent.ctx.sessions.announce(session) this.assertActive() diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index e29e73c393..610e077a67 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -149,6 +149,24 @@ describe('agent scope lifecycle', () => { await ctx.agents.get(SessionId('a1'))?.whenIdle() }) + it('records agents created through an agent context as non-root runtime children', async () => { + const ctx = await harness() + const root = await ctx.agents.create({ + sessionId: SessionId('runtime-root'), + agentOptions: { model: 'mock' }, + }) + const child = await root.agent.ctx.agents.create({ + sessionId: SessionId('runtime-child'), + agentOptions: { model: 'mock' }, + }) + + expect(ctx.agents.list()).toEqual([root.agent, child.agent]) + expect(ctx.agents.roots()).toEqual([root.agent]) + + await child.dispose() + await root.dispose() + }) + it('scoped registrations live in the agent world and die with the agent', async () => { const ctx = await harness() const handle = await ctx.agents.create({ sessionId: SessionId('s1'), agentOptions: { model: 'mock' } }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 4eddb050d5..643c15002b 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -11,9 +11,10 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `enter(agent): () => void` performs the authoritative ID collision check and inserts without announcing; `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- Advanced ordered lifecycle: `enter(agent, owner): () => void` performs the authoritative ID collision check and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: SessionId): Agent | undefined` - `ctx.agents.list(): Agent[]` +- `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root. #### Factory seam (creation) diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 1306e8004d..b25d1e3cac 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -178,6 +178,8 @@ const NO_FACTORY_MESSAGE = 'no agent factory registered (load an agent-loop plug interface AgentEntry { readonly id: SessionId readonly agent: Agent + /** Runtime creator-agent ownership; independent of durable session lineage. */ + readonly owner: Agent | undefined readonly carrier: Scoped announced: boolean announcing: boolean @@ -303,7 +305,7 @@ export class AgentRegistry extends Service { */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { - yield this.enter(agent) + yield this.enter(agent, this.ctx.agent) this.announce(agent) }.bind(this), 'agents.register()') // eslint-disable-next-line @typescript-eslint/no-misused-promises -- synchronous cleanup; direct return preserves disposer identity @@ -317,12 +319,15 @@ export class AgentRegistry extends Service { * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits * `agent/disposed` with listener failures contained. When called from a * synchronous `agent/created` listener, removal and disposal wait until * that creation dispatch unwinds. */ - enter(agent: Agent): () => void { + enter(agent: Agent, owner: Agent | undefined): () => void { const id = agent.id const carrier = scopeTarget(agent, agent) // This is the authoritative collision boundary. Concurrent create/resume @@ -331,6 +336,7 @@ export class AgentRegistry extends Service { const entry: AgentEntry = { id, agent, + owner, carrier, announced: false, announcing: false, @@ -438,6 +444,18 @@ export class AgentRegistry extends Service { list(): Agent[] { return [...this.store.values()].map(entry => entry.agent) } + + /** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ + roots(): Agent[] { + return [...this.store.values()] + .filter(entry => entry.owner === undefined) + .map(entry => entry.agent) + } } export default AgentRegistry diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index cba833b212..5563d01cad 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -42,6 +42,7 @@ describe('AgentRegistry', () => { const dispose = ctx.agents.register(agent) expect(ctx.agents.get(agent.id)).toBe(agent) expect(ctx.agents.list()).toEqual([agent]) + expect(ctx.agents.roots()).toEqual([agent]) expect(() => ctx.agents.register(stubAgent('a1'))).toThrow(/already registered/) dispose() @@ -49,6 +50,23 @@ describe('AgentRegistry', () => { expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) + it('tracks runtime creator ownership separately from registry order', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const root = stubAgent('root') + const child = stubAgent('child') + const detachRoot = ctx.agents.enter(root, undefined) + ctx.agents.announce(root) + const detachChild = ctx.agents.enter(child, root) + ctx.agents.announce(child) + + expect(ctx.agents.list()).toEqual([root, child]) + expect(ctx.agents.roots()).toEqual([root]) + + detachChild() + detachRoot() + }) + it('rolls an entry back and pairs a partially delivered creation when a listener throws', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -94,7 +112,7 @@ describe('AgentRegistry', () => { ctx.on('agent/disposed', agent => void lifecycle.push(`disposed:${agent.id}`)) const first = stubAgent('split') - const detachFirst = ctx.agents.enter(first) + const detachFirst = ctx.agents.enter(first, undefined) expect(lifecycle).toEqual([]) ctx.agents.announce(first) expect(() => { ctx.agents.announce(first) }).toThrow(/already announced/) @@ -102,7 +120,7 @@ describe('AgentRegistry', () => { detachFirst() const replacement = stubAgent('split') - const detachReplacement = ctx.agents.enter(replacement) + const detachReplacement = ctx.agents.enter(replacement, undefined) detachFirst() expect(ctx.agents.get(replacement.id)).toBe(replacement) expect(() => { ctx.agents.announce(first) }).toThrow(/not live/) @@ -122,7 +140,7 @@ describe('AgentRegistry', () => { }) ctx.on('agent/created', () => void order.push(`second:${ctx.agents.get(agent.id) === agent}`)) ctx.on('agent/disposed', () => void order.push('disposed')) - const detach = ctx.agents.enter(agent) + const detach = ctx.agents.enter(agent, undefined) ctx.agents.announce(agent) expect(order).toEqual(['first:true', 'after-detach:true', 'second:true', 'disposed']) expect(ctx.agents.get(agent.id)).toBeUndefined() diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 2a4d620494..a295274786 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -95,18 +95,16 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // This app owns one configured agent. Hold the live object directly: its - // per-run id is intentionally fresh, while `main` remains only the - // terminal's fixed display label. At install the configured agent is the - // earliest registry entry (it creates any subagents later). During HMR the - // replacement is published after the old tree, so when old teardown finally - // emits disposed, the newest survivor is the replacement. Persisted - // parentSession lineage is deliberately irrelevant: a resumed child session - // can itself be this process's configured top-level agent. - let target: Agent | undefined = ctx.agents.list()[0] - ctx.on('agent/created', (agent) => { target ??= agent }) + // This app owns one configured top-level agent. Hold the live object + // directly: its per-run id is intentionally fresh, while `main` remains only + // the terminal's fixed display label. Runtime creator ownership distinguishes + // that root from its subagents even if a child is registered after an HMR + // replacement. Persisted parentSession lineage is deliberately irrelevant: + // a resumed child session can itself be this process's configured root. + let target: Agent | undefined = ctx.agents.roots()[0] + ctx.on('agent/created', () => { target ??= ctx.agents.roots()[0] }) ctx.on('agent/disposed', (agent) => { - if (target === agent) target = ctx.agents.list().at(-1) + if (target === agent) target = ctx.agents.roots().at(-1) }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts index 6116aa356f..fa82db4d96 100644 --- a/packages/ui/stdio-agent/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -16,9 +16,9 @@ function fakeContext(): Context { return { on: vi.fn(() => vi.fn()), effect: vi.fn((callback: () => () => void) => callback()), - // The UI seeds its target object from the registry at install; this suite only + // The UI seeds its root target from the registry at install; this suite only // exercises readline terminal-mode selection, so an empty roster suffices. - agents: { list: vi.fn(() => []) }, + agents: { roots: vi.fn(() => []) }, userInteraction: { registerProvider: vi.fn(() => vi.fn()) }, } as unknown as Context } diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2654a05680..2600fdb24f 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -154,8 +154,7 @@ describe('createStdioChat rendering', () => { it('renders turn/start and turn/end markers from the session feed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - // agent/created supplies the app-owned target object. - ctx.emit('agent/created', agent) + ctx.agents.register(agent) const session = agent.session ctx.emit('session/event', session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 3, trigger: { kind: 'message' } }, @@ -203,7 +202,7 @@ describe('createStdioChat rendering', () => { const { ctx, input } = await setup() const resumed = makeAgent('resumed') ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' - ctx.emit('agent/created', resumed) + ctx.agents.register(resumed) input.feed('continue') await new Promise(resolve => setImmediate(resolve)) @@ -224,8 +223,8 @@ describe('createStdioChat rendering', () => { it('drops the target object on agent/disposed', async () => { const { ctx, out } = await setup() const agent = makeAgent('main') - ctx.emit('agent/created', agent) - ctx.emit('agent/disposed', agent) + const dispose = ctx.agents.register(agent) + dispose() // After disposal the event belongs to a non-target session, so its durable // identity is rendered directly. ctx.emit('session/event', agent.session, { @@ -237,7 +236,7 @@ describe('createStdioChat rendering', () => { it('keeps the target when a different agent is disposed', async () => { const { ctx, out } = await setup() const target = makeAgent('target') - ctx.emit('agent/created', target) + ctx.agents.register(target) ctx.emit('agent/disposed', makeAgent('other')) ctx.emit('session/event', target.session, { type: 'turn/start', seq: 1, time: 0, data: { turn: 1, trigger: { kind: 'message' } }, @@ -251,19 +250,27 @@ describe('createStdioChat rendering', () => { const child = makeAgent('child') ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id const replacement = makeAgent('replacement') + const lateChild = makeAgent('late-child') const disposeOld = ctx.agents.register(oldRoot) - ctx.agents.register(child) + const disposeChild = ctx.agents.enter(child, oldRoot) + ctx.agents.announce(child) ctx.agents.register(replacement) + const disposeLateChild = ctx.agents.enter(lateChild, replacement) + ctx.agents.announce(lateChild) // The replacement's created edge arrived while oldRoot was still targeted. - // Once oldRoot is removed, registry order is child then replacement; the - // most recently published survivor is the HMR replacement. + // A replacement-owned child then arrived even later. Once oldRoot is + // removed, runtime ownership still identifies replacement as the only + // surviving root instead of selecting either newer child by insertion order. disposeOld() input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) expect(child.sent).toEqual([]) + expect(lateChild.sent).toEqual([]) expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) + disposeLateChild() + disposeChild() }) it('renders tool/call and tool/result session events', async () => { From 0b492e6e62be53abb8560d16f806a4078f56c651 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:22:22 +0800 Subject: [PATCH 029/163] fix: drain ACP test launcher streams --- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/src/launcher.ts | 17 +++++++++++++-- .../tests/fixtures/fake-acp-agent.ts | 21 +++++++++++++++++++ .../acp-snapshot/tests/harness.spec.ts | 21 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 26192f1cac..b95615abea 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -4,7 +4,7 @@ The ACP snapshot suite kit: the shared machinery behind the keyless snapshot tie Four layers, importable separately: -- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit before resolving or propagating a child error, so callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. +- **`launchAcpTestAgent` (launcher)** — boots an unbuilt ACP agent from a temp cwd, pins tsx to the repo tsconfig, connects the SDK client over a raw-byte stdout tee, collects session updates and stderr, surfaces asynchronous spawn failures through its startup lifecycle, fails closed on unhandled permission requests, and owns graceful or signalled shutdown. Shutdown waits for process exit, inherited stdio closure, and ACP parser exhaustion before resolving or propagating a child error, so captures are complete and callers can remove owned paths after either outcome. Snapshot and ordinary e2e suites share this process boundary; a test supplies only agent paths, cwd, environment overrides, and any permission policy. - **`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). Each scenario directory's `session.jsonl` plus contiguous `session..jsonl` siblings are the ordered primary/child inventory; the scenario table does not duplicate their count. Must be called at vitest collection time. diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 253e67fd5a..815753ae5f 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent { stderr(): string /** Resolve when a future session update matches the predicate. */ waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise - /** Gracefully close stdin, or send a signal, and wait for process exit. */ + /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, and ACP parser drain. */ close(signal?: NodeJS.Signals): Promise } @@ -132,7 +132,6 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe }) child.stdout.on('end', () => { passthrough.push(null) - closeUpdateStream() }) const stream = ndJsonStream( Writable.toWeb(child.stdin) as WritableStream, @@ -163,6 +162,17 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' } })), }) const client = new ClientSideConnection(makeClient, stream) + // `exit` only reports the parent process's status. Descendants may retain + // inherited stdout/stderr handles and buffered ACP frames may still be + // crossing the SDK parser. Node's `close` follows stdio closure; the SDK's + // `closed` follows parser exhaustion. Capture both eagerly so a caller that + // invokes close after process exit still joins the complete drain boundary. + const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) + const drained = Promise.all([stdioClosed, client.closed]).then(() => undefined) + // A caller may await a pending update without calling close(). Make natural + // stream exhaustion terminal for those waiters too, but only after the + // parser has dispatched every buffered frame. + void client.closed.then(closeUpdateStream) return { child, @@ -183,6 +193,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe throw error } if (!isRunning(child)) { + await drained closeUpdateStream() return } @@ -194,6 +205,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe childFailure, ]) if (failure === undefined) { + await drained closeUpdateStream() return } @@ -204,6 +216,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // callers may safely remove cwd/session resources after close rejects. child.kill('SIGKILL') await exited + await drained closeUpdateStream() throw failure }, diff --git a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts index 861c9d2b04..9b0760213c 100644 --- a/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts +++ b/packages/support/acp-snapshot/tests/fixtures/fake-acp-agent.ts @@ -18,6 +18,7 @@ import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { readdirSync } from 'node:fs' +import { spawn } from 'node:child_process' import { dirname, join } from 'node:path' import { randomUUID } from 'node:crypto' import { createInterface } from 'node:readline' @@ -50,6 +51,8 @@ interface Behavior { echoWorkspace?: boolean /** Write a line to stderr on boot (spec-side stderr-capture assertions). */ stderrNote?: string + /** Let a short-lived descendant retain stdio and emit one final ACP update plus stderr line after this parent exits. */ + lateInheritedOutput?: boolean /** Session logs to persist on stdin EOF. */ logs?: ScriptedLog[] /** Leave a stray FILE directly under the sessions root (harvest must skip it). */ @@ -256,6 +259,24 @@ function flushLogsAndExit(): void { writeFileSync(join(sessionsRoot, 'bucket-noise', 'notes.txt'), 'not a session log\n') } if (behavior.deleteSessionsRoot === true) rmSync(sessionsRoot, { recursive: true, force: true }) + if (behavior.lateInheritedOutput === true) { + const frame = JSON.stringify({ + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'late inherited stdout' }, + }, + }, + }) + const code = [ + `setTimeout(() => process.stdout.write(${JSON.stringify(`${frame}\n`)}), 50)`, + `setTimeout(() => process.stderr.write(${JSON.stringify('late inherited stderr\n')}), 75)`, + ].join(';') + spawn(process.execPath, ['-e', code], { stdio: ['ignore', 1, 2] }).unref() + } process.exit(0) } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 192d36dc6c..051db6a0ba 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -91,6 +91,27 @@ describe('runScenario', () => { expect(exited).toBe(true) }) + it('waits for inherited stdio and buffered ACP parsing after the parent exits', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ lateInheritedOutput: true }) + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await launched.client.newSession({ cwd: dir, mcpServers: [] }) + const lateUpdate = launched.waitForUpdate(update => + update.sessionUpdate === 'agent_message_chunk' + && update.content.type === 'text' + && update.content.text === 'late inherited stdout') + + await launched.close() + + await expect(lateUpdate).resolves.toMatchObject({ sessionUpdate: 'agent_message_chunk' }) + expect(launched.rawStdout()).toContain('late inherited stdout') + expect(launched.stderr()).toContain('late inherited stderr') + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From 99b5f04c9bb5ff2ac78d9cc30ce22d65a181c097 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:26:46 +0800 Subject: [PATCH 030/163] fix: use unified identity for bash ownership --- docs/core-data-structures/bash.md | 2 +- ...-18-agent-lifecycle-and-ownership-seams.md | 4 +- .../architecture/2026-06-20-branded-ids.md | 4 +- packages/bash/bash/src/types.ts | 2 +- packages/bash/tool-bash/README.md | 4 +- packages/bash/tool-bash/src/index.ts | 22 ++++----- packages/bash/tool-bash/tests/tools.spec.ts | 46 +++++++++---------- .../cordis/tool-cordis/src/api-catalog.ts | 3 +- 8 files changed, 40 insertions(+), 47 deletions(-) diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index df6c5ab16b..0cc82e5165 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -37,7 +37,7 @@ interface BashExecRequest { env?: Record | undefined /** * Opaque OWNER token for a background task — the consumer's isolation key - * (the tool layer passes the owning agent's `session.header.id`). The + * (the tool layer passes the owning agent's shared `id`). The * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf}; * the executor itself NEVER interprets it (no access policy lives in the * seam — that is the consumer's job). Absent for foreground runs and for an diff --git a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md index d64329262e..d75ec379fc 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md +++ b/docs/rfc/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md @@ -22,7 +22,7 @@ A new `cancel()` verb on the `Agent` interface — the single public stop primit ### 3. Bash owner token in the seam -Background-task ownership moved from a `tool-bash` plugin-local `Map` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.session.header.id` as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.session.header.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) +Background-task ownership moved from a `tool-bash` plugin-local `Map` into the executor. `BashExecRequest` gains an optional `owner?: string`; the resolved `BashExecSpec` carries it as required-but-nullable `owner: string | undefined` (a forgotten owner is a visible `undefined`, never a silently-absent property). The executor stores the token on its task and exposes it via a new `BashExecutor.ownerOf(id): string | undefined` seam (NOT on the public `BashTask` — one read path, no redundant API). `tool-bash` deletes its `Map` entirely: it stamps `exec.agent?.id` (the shared registry/session id) as the owner at `start`, and `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token with `!== undefined` semantics (an empty-string token is still a real owner). The completion notice finds the live agent by scanning `ctx.get('agents')?.list()` for `agent.id === ownerToken` (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, where the `ctx.agents` proxy would throw). Because ownership now lives on the task in the executor (disposed with the `dsh-bash` fiber), it SURVIVES a `tool-bash` HMR reload — closing the old `XXX(tool-bash-owner-hmr)` gap. (The `onTaskDone` listener is still effect-scoped to `tool-bash`'s `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) ## Verification @@ -35,7 +35,7 @@ These invariants hold and are pinned by tests: ## Session owner tokens are unique among live agents -The bash owner-token comparison relies on `session.header.id` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. +The bash owner-token comparison relies on the shared `Agent.id`/`SessionId` being unique among live agents. Concurrent same-ID operations may both prepare privately, but publication enters the session and agent in order; `SessionStore.enter()` rejects a duplicate live session id, and every losing transaction rolls its private state back. A programmatic caller therefore cannot publish two live agents with one session token. The access *policy* (token comparison) stays in `tool-bash` (the consumer); the bash seam stores only an opaque `owner` string and never interprets it — the correct interface/implementation/consumer split. ## Alternatives considered diff --git a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md index 4fe7f58168..fd5c5bf953 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md +++ b/docs/rfc/implemented/architecture/2026-06-20-branded-ids.md @@ -8,7 +8,7 @@ The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared age **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-task id is a plain `string`: `BashTask.id: string` (`packages/bash/bash/src/types.ts`), carried as `string` through the whole executor seam (`BashExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/bash/bash/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateTaskId`, `assertTaskAccess`, the `task_id` schema arg in `packages/bash/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/bash/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash task id and a session id are trivially swappable at a call site and the compiler says nothing. This is the headline case the user asked about, and it is a model-facing id (the model passes `task_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. -The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's `session.header.id` (`callerToken = (exec) => exec.agent?.session.header.id` in `packages/bash/tool-bash/src/index.ts`) — i.e. a `SessionId` wearing a `string` disguise. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the same `session.header.id`-as-owner alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). +The bash **owner token** is the related sub-case: `BashExecRequest.owner?: string` and `BashExecSpec.owner: string | undefined` (`packages/bash/bash/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/bash/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). **Gap 2 — brand erosion at the seams of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), `ToolPresenter`'s call-id map, ACP's session-id records and loading set, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. @@ -18,7 +18,7 @@ A type-only change. Brands are zero-cost casts; nothing about runtime behavior, - **Brand the bash task id.** Add `BashTaskId = Branded<'BashTaskId'>` plus its same-named factory in `packages/bash/bash/src/types.ts` (the package that *owns* the id), importing `Branded` from `@deepseek-ai/dsh-brand` exactly as `SessionId` does. The brand primitive lives in the dependency-free `dsh-brand` utility package precisely so `dsh-bash` can brand its ids by depending on it alone — it never pulls in `dsh-llm` (or `dsh-session`) just to reach `Branded`. Thread it through `BashTask.id`, the `BashExecutor` seam methods (`get`/`ownerOf`/`readOutput`/`kill`), the generation site in `dsh-bash-local` (brand the counter output once, at creation), and the `dsh-tool-bash` validate/access surface (`validateTaskId` returns a `BashTaskId`; `task_id` is branded at the tool boundary where the model's string arrives). -- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's `session.header.id` (a `SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) +- **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/bash/bash/src/types.ts`; type `BashExecRequest.owner` / `BashExecSpec.owner` / `BashExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash seam never imports `dsh-session`. (Rationale in the next section.) - **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the diff and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. diff --git a/packages/bash/bash/src/types.ts b/packages/bash/bash/src/types.ts index 39dbc162c6..c4c48645e1 100644 --- a/packages/bash/bash/src/types.ts +++ b/packages/bash/bash/src/types.ts @@ -117,7 +117,7 @@ export interface BashExecRequest { env?: Record | undefined /** * Opaque OWNER token for a background task — the consumer's isolation key - * (the tool layer passes the owning agent's `session.header.id`). The + * (the tool layer passes the owning agent's shared `id`). The * executor stores it on the task and exposes it via {@link BashExecutor.ownerOf}; * the executor itself NEVER interprets it (no access policy lives in the * seam — that is the consumer's job). Absent for foreground runs and for an diff --git a/packages/bash/tool-bash/README.md b/packages/bash/tool-bash/README.md index 800de0132c..c4ea498e07 100644 --- a/packages/bash/tool-bash/README.md +++ b/packages/bash/tool-bash/README.md @@ -34,7 +34,7 @@ Result text: stdout, then a `[stderr]` section, then status markers — `[sandbo ### Task ownership (cross-session isolation) -The owning agent's session token (`session.header.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token (`session.header.id`) with `!== undefined` semantics and reject a task owned by a *different* session with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) +The owning agent's shared registry/session id (`agent.id`) is stamped onto the task at spawn — passed to the executor via `resolve({ …, owner })` and stored ON THE TASK inside the executor (the `dsh-bash` `ownerOf(id)` seam), **not** in a plugin-local map. `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's shared id with `!== undefined` semantics and reject a task owned by a *different* session with `task belongs to another session` (a task started with no agent — a non-loop caller — has no owner token and is open to anyone; a call with no `exec.agent` cannot access an owned task). Task ids are global and predictable, so under multi-session ACP this token check is the fence that stops one session's agent from reading or killing another session's background task. Because ownership lives on the task in the executor (disposed with the `dsh-bash` fiber), it **survives an independent `tool-bash` HMR reload** — closing the old plugin-local-map gap where a reload orphaned pre-reload tasks. (The `onTaskDone` listener is still effect-scoped to this plugin's `apply`, so a completion landing during the reload gap still drops its one notice — the pre-existing reload-gap drop — but the ownership fence itself is HMR-proof.) ## UI presentation @@ -42,7 +42,7 @@ These tools own how their calls render in a UI (an editor's tool-call card) via ## Background completion notices -When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The owning agent is found by its session token: the listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for an agent whose `session.header.id` matches (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. +When a background task finishes, a short notice is injected into the owning agent's session (`agent.inject()`, source `{kind: 'plugin', plugin: 'tool-bash'}`). The listener reads `ctx.bash.ownerOf(task.id)` and scans `ctx.get('agents')?.list()` for that shared agent/session id (read via `ctx.get` — `onTaskDone` runs on the bash fiber, a foreign fiber, so the `ctx.agents` proxy would throw). If no live agent carries that token — e.g. the owning session disconnected and its agent was disposed while the task ran on — the notice is dropped cleanly. Injection is **durable context for the next model request, not a wake-up** — an idle agent stays idle until something sends a message. That's why the tool descriptions tell the model to poll with `bash_output`. ## The tool builds its request from named args only diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 31e6512ae0..fe07b80989 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -12,7 +12,7 @@ * `bash_output`. * * Task ownership: a background task's OWNER is an opaque token — the owning - * agent's `session.header.id` — passed to the executor at spawn + * agent's shared `id` — passed to the executor at spawn * (`resolve({ …, owner })`) and stored ON THE TASK inside the executor * (`@deepseek-ai/dsh-bash`'s `ownerOf(id)` seam), NOT in a plugin-local map. * `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to the caller's token @@ -414,16 +414,12 @@ export function apply(ctx: Context): void { }) /** - * The caller's owner TOKEN — the owning agent's `session.header.id`, or - * `undefined` for a non-agent caller. Read `session.header.id` (NOT - * `session.id`): every other subsystem keys off the header id (the ACP bridge, - * both persistence backends), and the sibling `resolveWorkdir` already reads - * `session.header.cwd`, so using `session.id` here would be the asymmetry smell - * the conventions flag. The two are equal in production, but the header is the - * canonical identity. + * The caller's owner TOKEN — the owning agent's shared registry/session id, + * or `undefined` for a non-agent caller. Agent and Session deliberately have + * one live identity; workdir remains separate session metadata. */ const callerToken = (exec: { agent?: Agent }): OwnerToken | undefined => - exec.agent ? OwnerToken(exec.agent.session.header.id) : undefined + exec.agent ? OwnerToken(exec.agent.id) : undefined /** * Authorize a `bash_output`/`bash_kill` call against the task's stored owner @@ -443,18 +439,16 @@ export function apply(ctx: Context): void { } // Background completion → inject a notice into the owning agent's session. - // Find the live agent by its session id token via the agent registry, read + // Find the live agent by its shared registry/session token, read // opportunistically with `ctx.get('agents')` (NOT `ctx.agents`/static inject): // this listener runs from `task.done.then` on the bash fiber — a foreign // fiber — where the `ctx.agents` property proxy would throw through the // traceable shadow; `ctx.get(name)` is the topology-independent lookup. No - // registry mounted (`undefined`) → drop the notice. Match on - // `agent.session.header.id`, NOT the registry key: a config agent's id differs - // from its session id, and the owner token IS the session id. + // registry mounted (`undefined`) → drop the notice. ctx.bash.onTaskDone((task) => { const ownerToken = ctx.bash.ownerOf(task.id) if (ownerToken === undefined) return - const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.session.header.id) === ownerToken) + const agent = ctx.get('agents')?.list().find(a => OwnerToken(a.id) === ownerToken) if (!agent) return try { agent.inject( diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index c68c445f0a..639dd8631a 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -47,22 +47,16 @@ async function setup() { } /** - * Build a fake {@link Agent} whose session token is `sessionId`, REGISTER it in - * `ctx.agents` (the completion-notice path finds the owning agent by scanning - * the registry for a matching `session.header.id`), and return it. The returned + * Build a fake {@link Agent} with the shared registry/session `sessionId`, + * REGISTER it in `ctx.agents`, and return it. The returned * agent is also passed to `execute` as `exec.agent` so it owns the spawned task. * The registration disposer is tracked so {@link unregisterFakeAgents} can drop * it (simulating the owning session disconnecting before a task completes). */ const fakeAgentDisposers = new Map Promise | void)[]>() function registerFakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void): Agent { - // The registry KEY (agent.id) is deliberately DIFFERENT from the session - // token (session.header.id), which is also the agent's durable id. The - // owner token IS the session id, so the notice path must find the agent by - // `session.header.id`, NOT the registry key. Using distinct values here makes - // the test fail if a regression matched on the wrong field (a same-value fake - // would pass either way — the "hits the line but not the scenario" trap). - const agent = { id: `agent-${sessionId}`, inject, session: { header: { version: 0, id: sessionId, createdAt: 0 } } } as unknown as Agent + const id = SessionId(sessionId) + const agent = { id, inject, session: new Session(id) } as unknown as Agent const dispose = ctx.agents.register(agent) const list = fakeAgentDisposers.get(ctx) ?? [] list.push(dispose) @@ -418,9 +412,9 @@ describe('background tools', () => { it('injects a completion notice into the owning agent (found via the registry by session token)', async () => { const ctx = await setup() const inject = vi.fn() - // The notice path looks the agent up in ctx.agents by its session token, so + // The notice path looks the agent up in ctx.agents by its shared id, so // the agent must be REGISTERED (not merely passed to execute). Mount a - // registry and register a fake whose session.header.id IS the owner token. + // registry and register a fake whose agent/session id IS the owner token. const agent = registerFakeAgent(ctx, 'bg', inject) const started = await ctx.tools.execute({ @@ -517,13 +511,14 @@ describe('background task ownership (cross-session isolation)', () => { function callAs(ctx: Context, agent: import('@deepseek-ai/dsh-agent').Agent | undefined, name: string, args: unknown) { return ctx.tools.execute({ callId: CallId(`own-${++callCounter}`), name, arguments: args, ...agent ? { agent } : {} }) } - // Ownership is by TOKEN (session.header.id), NOT agent object identity — so - // each agent needs a DISTINCT session id, else every fake yields the same + // Ownership is by the shared agent/session TOKEN, NOT agent object identity — + // so each agent needs a DISTINCT id, else every fake yields the same // token and the isolation tests pass for the wrong reason (all tasks owned by - // the same token). The impl reads `session.header.id`, so the fakes MUST carry - // it. - const fakeAgent = (sessionId: string) => - ({ inject: () => undefined, session: { header: { version: 0, id: sessionId, createdAt: 0 } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + // the same token). + const fakeAgent = (sessionId: string) => { + const id = SessionId(sessionId) + return { id, inject: () => undefined, session: new Session(id) } as unknown as import('@deepseek-ai/dsh-agent').Agent + } it('rejects bash_output/bash_kill for a task owned by a DIFFERENT session token', async () => { const ctx = await setup() @@ -548,7 +543,7 @@ describe('background task ownership (cross-session isolation)', () => { }) it('a DIFFERENT Agent object with the SAME session token may access the task (ownership is by token, not object identity)', async () => { - // Ownership fences by session.header.id, NOT Agent object identity. Two + // Ownership fences by the shared id, NOT Agent object identity. Two // distinct Agent objects sharing one session token (e.g. an agent re-created // on the same session) are the SAME owner. const ctx = await setup() @@ -638,8 +633,10 @@ describe('session-cwd routing (per-session workdir)', () => { return ctx.tools.execute({ callId: CallId(`cwd-${++callCounter}`), name: 'bash', arguments: args, ...agent ? { agent } : {} }) } // An agent whose session header carries a cwd (what session/new records). - const agentInCwd = (cwd: string) => - ({ inject: () => undefined, session: { header: { version: 0, id: 'c', createdAt: 0, cwd } } }) as unknown as import('@deepseek-ai/dsh-agent').Agent + const agentInCwd = (cwd: string) => { + const id = SessionId('c') + return { id, inject: () => undefined, session: { header: { version: 0, id, createdAt: 0, cwd } } } as unknown as import('@deepseek-ai/dsh-agent').Agent + } it('defaults bash to the agent\'s session cwd (not the server launch dir)', async () => { const ctx = await setup() @@ -1188,10 +1185,11 @@ describe('sandbox escalation (sandbox_permissions / justification)', () => { * enforces the enclosure. */ function escalationAgent(events: Array<{ type: string; data: Record }>): Agent { + const id = SessionId('sess-esc') return { - id: 'agent-esc', + id, session: { - header: { version: 0, id: 'sess-esc', createdAt: 0 }, + header: { version: 0, id, createdAt: 0 }, events: [{ type: 'turn/start' }], append: (type: string, data: Record) => { events.push({ type, data }) }, }, @@ -1406,7 +1404,7 @@ describe('per-session sandbox mode (the bash/sandbox-mode fold)', () => { session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) const injected: string[] = [] const agent = { - id, + id: SessionId(id), session, inject: (content: { type: string; text: string }[]) => { injected.push(content[0]?.text ?? '') }, } as unknown as Agent diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5721bd97d9..ff27f51067 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -69,10 +69,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => void', - 'enter(agent: Agent): () => void', + 'enter(agent: Agent, owner: Agent | undefined): () => void', 'announce(agent: Agent): void', 'get(id: SessionId): Agent | undefined', 'list(): Agent[]', + 'roots(): Agent[]', ], }, { From 03f91a547183919422aeb1d699af58d678a8984d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:29:40 +0800 Subject: [PATCH 031/163] fix: retain local subagent completion identity --- .../cordis/tool-cordis/src/api-catalog.ts | 3 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 34 +++++++++++-------- packages/ui/jsonrpc/tests/server.spec.ts | 23 ++++++++++++- 4 files changed, 45 insertions(+), 17 deletions(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 5721bd97d9..ff27f51067 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -69,10 +69,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'async create(options: CreateAgentOptions): Promise', 'async resume(options: ResumeAgentOptions): Promise', 'register(agent: Agent): () => void', - 'enter(agent: Agent): () => void', + 'enter(agent: Agent, owner: Agent | undefined): () => void', 'announce(agent: Agent): void', 'get(id: SessionId): Agent | undefined', 'list(): Agent[]', + 'roots(): Agent[]', ], }, { diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 2324227ff7..735cfead0a 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches only parent lineage because the child may be disposed before `subagent/end`. Runs from remote providers are not reported through this local-session notification pair because they create no local `session/created`/`subagent.started` edge. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`); a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage because the child may be disposed before `subagent/end`, and the provider contract does not require lineage. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 3c803a6c8c..bf21f7fc2d 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -58,6 +58,11 @@ interface SessionRecord { activePrompt: boolean } +/** Runtime-local agent identity plus optional durable fork lineage. */ +interface LocalAgentRecord { + parentSessionId?: SessionId +} + /** * The SDK server over a booted harness context. Constructing it subscribes to * the context's `session/event`, `session/created`, `agent/created`, and @@ -71,7 +76,7 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly subagentParents = new Map() + private readonly localAgents = new Map() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -95,23 +100,24 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache parent lineage on creation: by the time `subagent/end` fires the - // child agent may already be disposed and gone from the registry. The child - // session id needs no cache because it is the shared agent/session id. + // Cache runtime-local identity and optional lineage on creation: by the + // time `subagent/end` fires the child agent may already be disposed and + // gone from the registry. Parent lineage is not required by the provider + // contract, so an empty record remains a load-bearing locality marker. this.disposers.push(ctx.on('agent/created', (agent) => { const parentSessionId = agent.session.header.parentSession - if (parentSessionId !== undefined) this.subagentParents.set(agent.id, parentSessionId) + this.localAgents.set(agent.id, parentSessionId === undefined ? {} : { parentSessionId }) })) this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { const agent = this.ctx.agents.get(info.id) - const cachedParentSessionId = this.subagentParents.get(info.id) - this.subagentParents.delete(info.id) - // This protocol reports LOCAL child sessions, paired with the - // session/created-driven subagent.started notification above. A remote - // provider may use a real remote SessionId for its run, but that session - // does not exist in this harness and therefore has no paired start event. - if (cachedParentSessionId === undefined && agent === undefined) return - const parentSessionId = cachedParentSessionId ?? agent?.session.header.parentSession + const cachedLocalAgent = this.localAgents.get(info.id) + this.localAgents.delete(info.id) + // This protocol reports LOCAL child sessions. A lineage-bearing child + // has the session/created-driven start notification above; a parentless + // local provider still gets its terminal notification. A remote provider + // has neither a cached creation nor a live local agent and is ignored. + if (cachedLocalAgent === undefined && agent === undefined) return + const parentSessionId = cachedLocalAgent?.parentSessionId ?? agent?.session.header.parentSession this.transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), @@ -189,7 +195,7 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.subagentParents.clear() + this.localAgents.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index e7f591b88f..163aee4c0c 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -272,15 +272,26 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) + const parentlessHandle = await ctx.agents.create({ + sessionId: SessionId('parentless-child-session'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) // The backend may dispose the child before publishing its run outcome; - // only the cached parent lineage should be needed at this point. + // cached locality must survive with or without optional parent lineage. await handle.dispose() + await parentlessHandle.dispose() await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'spawn', + id: SessionId('parentless-child-session'), + stopReason: 'error', + }) expect(transport.notifications).toContainEqual({ method: 'subagent.finished', @@ -294,6 +305,16 @@ describe('HarnessSdkServer', () => { lastAssistantMessage: [{ type: 'text', text: 'child done' }], }, }) + expect(transport.notifications).toContainEqual({ + method: 'subagent.finished', + params: { + provider: 'spawn', + agentId: 'parentless-child-session', + childSessionId: 'parentless-child-session', + status: 'error', + stopReason: 'error', + }, + }) await parentHandle.dispose() await server.shutdown() From f02005c832b450d65d372c935c612c7d83eec54d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:39:28 +0800 Subject: [PATCH 032/163] fix: enforce unified agent startup invariants --- packages/core/agent/README.md | 2 +- packages/core/agent/src/index.ts | 3 +++ packages/core/agent/tests/agent.spec.ts | 12 ++++++++- .../hooks/hooks-claude/tests/coverage.spec.ts | 8 +++--- packages/subagent/subagent-acp/src/run.ts | 11 ++++---- .../subagent-acp/tests/mock-acp-server.ts | 3 +++ .../subagent-acp/tests/subagent-acp.spec.ts | 26 +++++++++++++++++++ 7 files changed, 55 insertions(+), 10 deletions(-) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 643c15002b..a45603f191 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -11,7 +11,7 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh-scope`, key = the agent) — register tools/sections/variables/listeners through it for that agent alone, all unwound on disposal. `agentEvents(ctx, agent)` is the fused dispatcher for ordinary agent-subject operations (carrier + injected subject in one move); its notification mode invokes every listener and contains both synchronous throws and returned-promise rejections. The registry lifecycle pair reuses one stable routing carrier. `assembleContextFor(agent)` builds the per-agent assembly context (`agent` + `scope` together). `CreateAgentOptions.setup(agentCtx)` and `ResumeAgentOptions.setup(agentCtx)` compose a fresh or resumed agent's scoped world while both objects remain unpublished. Setup is trusted, composition-only same-process code: drive the agent only after creation resolves. - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. -- Advanced ordered lifecycle: `enter(agent, owner): () => void` performs the authoritative ID collision check and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. +- Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: SessionId): Agent | undefined` - `ctx.agents.list(): Agent[]` - `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index b25d1e3cac..abafa38e45 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -329,6 +329,9 @@ export class AgentRegistry extends Service { */ enter(agent: Agent, owner: Agent | undefined): () => void { const id = agent.id + if (id !== agent.session.id) { + throw new Error(`agent id "${id}" does not match session id "${agent.session.id}"`) + } const carrier = scopeTarget(agent, agent) // This is the authoritative collision boundary. Concurrent create/resume // operations may both prepare, but only one exact entry can publish. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5563d01cad..d2866cacb3 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -11,7 +11,7 @@ function stubAgent(rawId: string): Agent { return { id, options: {}, - session: new Session(SessionId(`${id}-session`)), + session: new Session(id), status: 'idle', ctx: new Context(), send() {}, @@ -50,6 +50,16 @@ describe('AgentRegistry', () => { expect(lifecycle).toEqual(['created:a1', 'disposed:a1']) }) + it('rejects an agent whose registry and session identities differ', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const agent = { ...stubAgent('agent-id'), session: new Session(SessionId('session-id')) } + + expect(() => ctx.agents.enter(agent, undefined)) + .toThrow('agent id "agent-id" does not match session id "session-id"') + expect(ctx.agents.list()).toEqual([]) + }) + it('tracks runtime creator ownership separately from registry order', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 16c48aa149..0b41fd0d10 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { Context } from 'cordis' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineTool } from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' @@ -209,7 +209,8 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const ctx = await harness(path, new MockAdapter([])) // Register a fake child agent under the id the event carries. const injected: string[] = [] - const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { header: { id: 'child-x' } } } as unknown as Parameters[0] + const childId = SessionId('child-x') + const child = { id: childId, inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: new Session(childId) } as unknown as Parameters[0] ctx.agents.register(child) ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') }) await waitFor(() => injected.includes('child guidance')) @@ -225,7 +226,8 @@ describe('hooks-claude coverage — Stop continuation + subagent inject/catch', const path = hooks(d, { SubagentStart: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) const warn = vi.fn(); ctx.logger.warn = warn as never - const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { header: { id: 'child-y' } } } as unknown as Parameters[0] + const childId = SessionId('child-y') + const child = { id: childId, inject: () => { throw new Error('inject boom') }, session: new Session(childId) } as unknown as Parameters[0] ctx.agents.register(child) ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 7d0c11cbe1..8ef49f0f7d 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -311,7 +311,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe clientCapabilities: {}, }) const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) - sessionId = session.sessionId + const returnedSessionId: unknown = Reflect.get(session, 'sessionId') + if (typeof returnedSessionId !== 'string') throw new Error('ACP child published without a session id') + sessionId = returnedSessionId if (flags.cancelled) throw new Error('subagent cancelled before the ACP session started') })(), spawnFailed.then((err): never => { throw err }), @@ -323,10 +325,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe if (flags.cancelled) throw new Error('subagent request was aborted before the ACP child started') throw toError(error) } - // The startup race can fulfill only after newSession assigned the id; this - // guard keeps that cross-closure invariant explicit for TypeScript. - /* v8 ignore next */ - if (sessionId === undefined) throw new Error('ACP child published without a session id') + // The startup transaction validates the returned id before it can fulfill. + // This assertion carries that cross-closure invariant into TypeScript. + if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id') const remoteSessionId = sessionId const result: Promise = (async (): Promise => { diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts index 5145941526..f56404834b 100644 --- a/packages/subagent/subagent-acp/tests/mock-acp-server.ts +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -19,6 +19,8 @@ * handler is in flight (it has streamed its chunk). A test * polls for this file to cancel on a CONDITION rather than * an arbitrary timeout (subprocess cold-start is variable). + * - `MOCK_MISSING_SESSION_ID` — if `1`, return a malformed empty `session/new` + * response to exercise startup rollback. * - `MOCK_FLUSH_ON_EOF` — if set, on stdin EOF the agent takes an async beat * (MOCK_FLUSH_DELAY_MS, default 150) simulating the real * acp-agent's EOF-driven quiesce+flush, then touches this @@ -99,6 +101,7 @@ function makeAgent(conn: AgentSideConnection): Agent { writeFileSync(NEWSESSION_GATE.ready, 'at-newSession') while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10)) } + if (process.env.MOCK_MISSING_SESSION_ID === '1') return {} as NewSessionResponse return { sessionId: process.env.MOCK_SESSION_ID ?? randomUUID() } }, authenticate(_params: AuthenticateRequest): Promise { diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index e5fe05384e..daa037ca20 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -195,6 +195,32 @@ describe('dsh-subagent-acp', () => { } }) + it('reaps a child whose session/new response omits the session id', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-malformed-session-')) + const flushed = join(tmp, 'flushed') + try { + await expect(startAcpRun(request(), { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { + MOCK_MISSING_SESSION_ID: '1', + MOCK_FLUSH_ON_EOF: flushed, + MOCK_FLUSH_DELAY_MS: '20', + TSX_TSCONFIG_PATH: repoTsconfig, + }, + disposeEofGraceMs: 1000, + disposeGraceMs: 100, + })).rejects.toThrow('ACP child published without a session id') + // Startup rejects only after its private child reaches quiescence. The + // marker proves rollback closed stdin and allowed the child's EOF flush. + expect(existsSync(flushed)).toBe(true) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('dispose escalates SIGTERM → SIGKILL for a child that traps SIGTERM (bounded quiescence)', async () => { // The child traps SIGTERM and keeps its event loop alive, so a graceful // term alone would hang dispose forever. With a short grace, dispose must From a9c4fe2c4ba9f92a85117d60ad01ce1ee5930c69 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:43:01 +0800 Subject: [PATCH 033/163] test: exclude validated ACP assertion --- packages/subagent/subagent-acp/src/run.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 8ef49f0f7d..e135beb210 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -327,6 +327,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe } // The startup transaction validates the returned id before it can fulfill. // This assertion carries that cross-closure invariant into TypeScript. + /* v8 ignore next */ if (sessionId === undefined) throw new Error('unreachable: ACP startup fulfilled without a session id') const remoteSessionId = sessionId From 47812b45db85a9824530e800b12de1c82fdd6972 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 07:57:45 +0800 Subject: [PATCH 034/163] test: re-record Codex hook snapshot --- .../hook-codex-posttool-block/session.jsonl | 340 ++++++------------ .../stdout.golden.jsonl | 153 ++------ 2 files changed, 142 insertions(+), 351 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index ae105f6679..94bbbc5a08 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -1,222 +1,118 @@ -{"type":"session","version":0,"id":"7a5183c0-ec3a-46a8-a382-475eaa0c205b","createdAt":1783352220743,"cwd":"/tmp/acp-snap-cwd-vGnYqn"} -{"type":"turn/start","seq":0,"time":1783352220747,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":1783352220748,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":1783352220749,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783352220750,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"assistant/chunk","seq":4,"time":1783352221451,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":5,"time":1783352221451,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":6,"time":1783352221651,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":7,"time":1783352221685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} -{"type":"assistant/chunk","seq":8,"time":1783352221685,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":9,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":10,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} -{"type":"assistant/chunk","seq":11,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":12,"time":1783352221686,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} -{"type":"assistant/chunk","seq":13,"time":1783352221709,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":14,"time":1783352221710,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} -{"type":"assistant/chunk","seq":15,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} -{"type":"assistant/chunk","seq":16,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} -{"type":"assistant/chunk","seq":17,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} -{"type":"assistant/chunk","seq":18,"time":1783352221711,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":19,"time":1783352221738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" using"}}} -{"type":"assistant/chunk","seq":20,"time":1783352221738,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":21,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} -{"type":"assistant/chunk","seq":22,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":23,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} -{"type":"assistant/chunk","seq":24,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":25,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":26,"time":1783352221768,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":27,"time":1783352221794,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":28,"time":1783352221795,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":29,"time":1783352221795,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":30,"time":1783352221884,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":31,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":""}}} -{"type":"assistant/chunk","seq":32,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"{"}}} -{"type":"assistant/chunk","seq":33,"time":1783352221885,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":34,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"command"}}} -{"type":"assistant/chunk","seq":35,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":36,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":37,"time":1783352221915,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":38,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"echo"}}} -{"type":"assistant/chunk","seq":39,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":40,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":41,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":42,"time":1783352221945,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":43,"time":1783352222000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":", "}}} -{"type":"assistant/chunk","seq":44,"time":1783352222000,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":45,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"description"}}} -{"type":"assistant/chunk","seq":46,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":47,"time":1783352222001,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":": "}}} -{"type":"assistant/chunk","seq":48,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":49,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"Print"}}} -{"type":"assistant/chunk","seq":50,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" HE"}}} -{"type":"assistant/chunk","seq":51,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"LL"}}} -{"type":"assistant/chunk","seq":52,"time":1783352222031,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"O"}}} -{"type":"assistant/chunk","seq":53,"time":1783352222058,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" to"}}} -{"type":"assistant/chunk","seq":54,"time":1783352222059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":" stdout"}}} -{"type":"assistant/chunk","seq":55,"time":1783352222059,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"\""}}} -{"type":"assistant/chunk","seq":56,"time":1783352222088,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","argumentsDelta":"}"}}} -{"type":"assistant/chunk","seq":57,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run the command \"echo HELLO\" using the bash tool and report the result verbatim."}}}} -{"type":"assistant/chunk","seq":58,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2878,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":25}}}} -{"type":"assistant/chunk","seq":60,"time":1783352222121,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1783352222124,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the command \"echo HELLO\" using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":2878,"outputTokens":92,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1783352222124,"data":{"turn":1,"step":1,"callId":"call_00_wNJIQDMLdssZp45zIXvz2684","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} -{"type":"hook/invoked","seq":63,"time":1783352222138,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":64,"time":1783352222148,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":9.571565000000192}} -{"type":"tool/result","seq":65,"time":1783352222148,"data":{"turn":1,"step":1,"callId":"call_00_wNJIQDMLdssZp45zIXvz2684","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1783352222149,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":67,"time":1783352222149,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":68,"time":1783352223151,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"assistant/chunk","seq":69,"time":1783352223151,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} -{"type":"assistant/chunk","seq":70,"time":1783352223301,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":71,"time":1783352223315,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} -{"type":"assistant/chunk","seq":72,"time":1783352223316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":73,"time":1783352223316,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":74,"time":1783352223343,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":75,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":76,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} -{"type":"assistant/chunk","seq":77,"time":1783352223344,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":78,"time":1783352223372,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} -{"type":"assistant/chunk","seq":79,"time":1783352223406,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":80,"time":1783352223407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instruction"}}} -{"type":"assistant/chunk","seq":81,"time":1783352223407,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":82,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} -{"type":"assistant/chunk","seq":83,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"sum"}}} -{"type":"assistant/chunk","seq":84,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"mar"}}} -{"type":"assistant/chunk","seq":85,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ize"}}} -{"type":"assistant/chunk","seq":86,"time":1783352223430,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":87,"time":1783352223458,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} -{"type":"assistant/chunk","seq":88,"time":1783352223487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" This"}}} -{"type":"assistant/chunk","seq":89,"time":1783352223487,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" means"}}} -{"type":"assistant/chunk","seq":90,"time":1783352223488,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":91,"time":1783352223519,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cannot"}}} -{"type":"assistant/chunk","seq":92,"time":1783352223520,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" show"}}} -{"type":"assistant/chunk","seq":93,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":94,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" raw"}}} -{"type":"assistant/chunk","seq":95,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":96,"time":1783352223548,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":97,"time":1783352223576,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":98,"time":1783352223576,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} -{"type":"assistant/chunk","seq":99,"time":1783352223605,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":100,"time":1783352223640,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":101,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" requested"}}} -{"type":"assistant/chunk","seq":102,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":103,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} -{"type":"assistant/chunk","seq":104,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":105,"time":1783352223641,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" try"}}} -{"type":"assistant/chunk","seq":106,"time":1783352223663,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":107,"time":1783352223664,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" comply"}}} -{"type":"assistant/chunk","seq":108,"time":1783352223691,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" but"}}} -{"type":"assistant/chunk","seq":109,"time":1783352223692,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":110,"time":1783352223721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} -{"type":"assistant/chunk","seq":111,"time":1783352223721,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" happened"}}} -{"type":"assistant/chunk","seq":112,"time":1783352223749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":".\n\n"}}} -{"type":"assistant/chunk","seq":113,"time":1783352223749,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"Actually"}}} -{"type":"assistant/chunk","seq":114,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} -{"type":"assistant/chunk","seq":115,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":116,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} -{"type":"assistant/chunk","seq":117,"time":1783352223750,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} -{"type":"assistant/chunk","seq":118,"time":1783352223778,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} -{"type":"assistant/chunk","seq":119,"time":1783352223779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} -{"type":"assistant/chunk","seq":120,"time":1783352223779,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":121,"time":1783352223813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} -{"type":"assistant/chunk","seq":122,"time":1783352223813,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":123,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":124,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":125,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":126,"time":1783352223837,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":127,"time":1783352223865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} -{"type":"assistant/chunk","seq":128,"time":1783352223866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} -{"type":"assistant/chunk","seq":129,"time":1783352223866,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} -{"type":"assistant/chunk","seq":130,"time":1783352223896,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":131,"time":1783352223897,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} -{"type":"assistant/chunk","seq":132,"time":1783352223923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} -{"type":"assistant/chunk","seq":133,"time":1783352223923,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} -{"type":"assistant/chunk","seq":134,"time":1783352223952,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":135,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} -{"type":"assistant/chunk","seq":136,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"tool"}}} -{"type":"assistant/chunk","seq":137,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} -{"type":"assistant/chunk","seq":138,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} -{"type":"assistant/chunk","seq":139,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} -{"type":"assistant/chunk","seq":140,"time":1783352223953,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} -{"type":"assistant/chunk","seq":141,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} -{"type":"assistant/chunk","seq":142,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} -{"type":"assistant/chunk","seq":143,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} -{"type":"assistant/chunk","seq":144,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" summarize"}}} -{"type":"assistant/chunk","seq":145,"time":1783352223981,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" instead"}}} -{"type":"assistant/chunk","seq":146,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} -{"type":"assistant/chunk","seq":147,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"So"}}} -{"type":"assistant/chunk","seq":148,"time":1783352224017,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} -{"type":"assistant/chunk","seq":149,"time":1783352224018,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} -{"type":"assistant/chunk","seq":150,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" report"}}} -{"type":"assistant/chunk","seq":151,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} -{"type":"assistant/chunk","seq":152,"time":1783352224039,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} -{"type":"assistant/chunk","seq":153,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} -{"type":"assistant/chunk","seq":154,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} -{"type":"assistant/chunk","seq":155,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":156,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"The"}}} -{"type":"assistant/chunk","seq":157,"time":1783352224068,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":158,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":159,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" I"}}} -{"type":"assistant/chunk","seq":160,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" got"}}} -{"type":"assistant/chunk","seq":161,"time":1783352224105,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" back"}}} -{"type":"assistant/chunk","seq":162,"time":1783352224129,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":163,"time":1783352224159,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":\n\n"}}} -{"type":"assistant/chunk","seq":164,"time":1783352224159,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":">"}}} -{"type":"assistant/chunk","seq":165,"time":1783352224186,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":166,"time":1783352224186,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} -{"type":"assistant/chunk","seq":167,"time":1783352224187,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":168,"time":1783352224215,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} -{"type":"assistant/chunk","seq":169,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} -{"type":"assistant/chunk","seq":170,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} -{"type":"assistant/chunk","seq":171,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} -{"type":"assistant/chunk","seq":172,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":173,"time":1783352224216,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} -{"type":"assistant/chunk","seq":174,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} -{"type":"assistant/chunk","seq":175,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":176,"time":1783352224244,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`\n\n"}}} -{"type":"assistant/chunk","seq":177,"time":1783352224245,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"This"}}} -{"type":"assistant/chunk","seq":178,"time":1783352224245,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" is"}}} -{"type":"assistant/chunk","seq":179,"time":1783352224297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":180,"time":1783352224297,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" verb"}}} -{"type":"assistant/chunk","seq":181,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"atim"}}} -{"type":"assistant/chunk","seq":182,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" result"}}} -{"type":"assistant/chunk","seq":183,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" from"}}} -{"type":"assistant/chunk","seq":184,"time":1783352224305,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":185,"time":1783352224331,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" tool"}}} -{"type":"assistant/chunk","seq":186,"time":1783352224360,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" —"}}} -{"type":"assistant/chunk","seq":187,"time":1783352224361,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} -{"type":"assistant/chunk","seq":188,"time":1783352224361,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" was"}}} -{"type":"assistant/chunk","seq":189,"time":1783352224392,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" not"}}} -{"type":"assistant/chunk","seq":190,"time":1783352224418,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":191,"time":1783352224446,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" expected"}}} -{"type":"assistant/chunk","seq":192,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" `"}}} -{"type":"assistant/chunk","seq":193,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":194,"time":1783352224476,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"HE"}}} -{"type":"assistant/chunk","seq":195,"time":1783352224505,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"LL"}}} -{"type":"assistant/chunk","seq":196,"time":1783352224505,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"O"}}} -{"type":"assistant/chunk","seq":197,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\""}}} -{"type":"assistant/chunk","seq":198,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"`"}}} -{"type":"assistant/chunk","seq":199,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} -{"type":"assistant/chunk","seq":200,"time":1783352224506,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" because"}}} -{"type":"assistant/chunk","seq":201,"time":1783352224533,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" the"}}} -{"type":"assistant/chunk","seq":202,"time":1783352224534,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} -{"type":"assistant/chunk","seq":203,"time":1783352224534,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} -{"type":"assistant/chunk","seq":204,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} -{"type":"assistant/chunk","seq":205,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" blocked"}}} -{"type":"assistant/chunk","seq":206,"time":1783352224563,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" it"}}} -{"type":"assistant/chunk","seq":207,"time":1783352224592,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" and"}}} -{"type":"assistant/chunk","seq":208,"time":1783352224621,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instructed"}}} -{"type":"assistant/chunk","seq":209,"time":1783352224621,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" me"}}} -{"type":"assistant/chunk","seq":210,"time":1783352224653,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" to"}}} -{"type":"assistant/chunk","seq":211,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} -{"type":"assistant/chunk","seq":212,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} -{"type":"assistant/chunk","seq":213,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"."}}} -{"type":"assistant/chunk","seq":214,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output was rejected by the codex policy with the instruction to \"summarize instead\". This means I cannot show the raw output verbatim as the user requested. Let me try to comply but report what happened.\n\nActually, the user asked me to report the tool result verbatim. The tool result I got back is:\n\n\"tool output rejected by codex policy: summarize instead\"\n\nSo I should report that verbatim."}}}} -{"type":"assistant/chunk","seq":215,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"The tool result I got back is:\n\n> `tool output rejected by codex policy: summarize instead`\n\nThis is the verbatim result from the tool — it was not the expected `\"HELLO\"` output because the codex policy blocked it and instructed me to summarize instead."}}}} -{"type":"assistant/chunk","seq":216,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":176,"outputTokens":145,"cacheReadTokens":2816,"reasoningTokens":86}}}} -{"type":"assistant/chunk","seq":217,"time":1783352224654,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":218,"time":1783352224655,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output was rejected by the codex policy with the instruction to \"summarize instead\". This means I cannot show the raw output verbatim as the user requested. Let me try to comply but report what happened.\n\nActually, the user asked me to report the tool result verbatim. The tool result I got back is:\n\n\"tool output rejected by codex policy: summarize instead\"\n\nSo I should report that verbatim."},{"type":"text","text":"The tool result I got back is:\n\n> `tool output rejected by codex policy: summarize instead`\n\nThis is the verbatim result from the tool — it was not the expected `\"HELLO\"` output because the codex policy blocked it and instructed me to summarize instead."}],"usage":{"inputTokens":176,"outputTokens":145,"cacheReadTokens":2816,"reasoningTokens":86}},"sourceEventSeqs":[68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217],"surfaceOp":"append"} -{"type":"step/end","seq":219,"time":1783352224655,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":220,"time":1783352224655,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"01aa6a36-e9c2-42ba-934b-30bec80a1658","createdAt":1783986962232,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-x67BsP"} +{"type":"turn/start","seq":0,"time":1783986962235,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783986962235,"data":{"content":[{"type":"text","text":"Call the bash tool exactly once to run: echo HELLO. Whatever tool result comes back, quote it verbatim and stop without calling another tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783986962240,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783986962240,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783986962953,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783986962953,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":11,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":13,"time":1783986963134,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":15,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1783986963135,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" `"}}} +{"type":"assistant/chunk","seq":17,"time":1783986963160,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"echo"}}} +{"type":"assistant/chunk","seq":18,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" HE"}}} +{"type":"assistant/chunk","seq":19,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"LL"}}} +{"type":"assistant/chunk","seq":20,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"O"}}} +{"type":"assistant/chunk","seq":21,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"`,"}}} +{"type":"assistant/chunk","seq":22,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":23,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":24,"time":1783986963213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" result"}}} +{"type":"assistant/chunk","seq":26,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":27,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":28,"time":1783986963221,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1783986963252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":30,"time":1783986963252,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":31,"time":1783986963314,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":1783986963315,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":33,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":34,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":35,"time":1783986963345,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":36,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":38,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783986963369,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":40,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":41,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":42,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":43,"time":1783986963397,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":44,"time":1783986963428,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":45,"time":1783986963429,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":46,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":47,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":48,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":49,"time":1783986963457,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783986963489,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":51,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":52,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" HE"}}} +{"type":"assistant/chunk","seq":53,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"LL"}}} +{"type":"assistant/chunk","seq":54,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"O"}}} +{"type":"assistant/chunk","seq":55,"time":1783986963514,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":56,"time":1783986963544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":" stdout"}}} +{"type":"assistant/chunk","seq":57,"time":1783986963544,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":58,"time":1783986963658,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":59,"time":1783986963659,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."}}}} +{"type":"assistant/chunk","seq":60,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}}}} +{"type":"assistant/chunk","seq":61,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":62,"time":1783986963660,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":63,"time":1783986963663,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to call the bash tool once with `echo HELLO`, then quote the result verbatim and stop."},{"type":"tool-call","id":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}],"usage":{"inputTokens":3256,"outputTokens":94,"cacheReadTokens":0,"reasoningTokens":26}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62],"surfaceOp":"append"} +{"type":"tool/call","seq":64,"time":1783986963664,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO to stdout\"}"}} +{"type":"hook/invoked","seq":65,"time":1783986963673,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} +{"type":"hook/result","seq":66,"time":1783986963677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":4.42941699999983}} +{"type":"tool/result","seq":67,"time":1783986963678,"data":{"turn":1,"step":1,"callId":"call_00_1rmSWHhVchVg7PDTmegT0421","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[64],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":1783986963678,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":69,"time":1783986963679,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":70,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":71,"time":1783986964555,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":72,"time":1783986964809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":73,"time":1783986964835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":74,"time":1783986964835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":75,"time":1783986964836,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" rejected"}}} +{"type":"assistant/chunk","seq":76,"time":1783986964864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" by"}}} +{"type":"assistant/chunk","seq":77,"time":1783986964865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" code"}}} +{"type":"assistant/chunk","seq":78,"time":1783986964865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"x"}}} +{"type":"assistant/chunk","seq":79,"time":1783986964893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" policy"}}} +{"type":"assistant/chunk","seq":80,"time":1783986964899,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":81,"time":1783986964900,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":82,"time":1783986964924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":83,"time":1783986964924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" quote"}}} +{"type":"assistant/chunk","seq":84,"time":1783986964955,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":85,"time":1783986964985,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":86,"time":1783986965013,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" got"}}} +{"type":"assistant/chunk","seq":87,"time":1783986965014,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" back"}}} +{"type":"assistant/chunk","seq":88,"time":1783986965045,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":89,"time":1783986965132,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":90,"time":1783986965132,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```\n"}}} +{"type":"assistant/chunk","seq":91,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"<"}}} +{"type":"assistant/chunk","seq":92,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":93,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_result"}}} +{"type":"assistant/chunk","seq":94,"time":1783986965133,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":">"}}} +{"type":"assistant/chunk","seq":95,"time":1783986965233,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"tool"}}} +{"type":"assistant/chunk","seq":96,"time":1783986965234,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" output"}}} +{"type":"assistant/chunk","seq":97,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" rejected"}}} +{"type":"assistant/chunk","seq":98,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" by"}}} +{"type":"assistant/chunk","seq":99,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" code"}}} +{"type":"assistant/chunk","seq":100,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"x"}}} +{"type":"assistant/chunk","seq":101,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" policy"}}} +{"type":"assistant/chunk","seq":102,"time":1783986965235,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":":"}}} +{"type":"assistant/chunk","seq":103,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" summarize"}}} +{"type":"assistant/chunk","seq":104,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":" instead"}}} +{"type":"assistant/chunk","seq":105,"time":1783986965236,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"\n"}}} +{"type":"assistant/chunk","seq":109,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"```"}}} +{"type":"assistant/chunk","seq":110,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."}}}} +{"type":"assistant/chunk","seq":111,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}}}} +{"type":"assistant/chunk","seq":112,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":113,"time":1783986965237,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":114,"time":1783986965238,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by codex policy. Let me quote what I got back."},{"type":"text","text":"```\ntool output rejected by codex policy: summarize instead\n```"}],"usage":{"inputTokens":44,"outputTokens":39,"cacheReadTokens":3328,"reasoningTokens":18}},"sourceEventSeqs":[70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113],"surfaceOp":"append"} +{"type":"step/end","seq":115,"time":1783986965238,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":116,"time":1783986965238,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index fddecc08c6..1c99b178a3 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -5,124 +5,52 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" run"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" call"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"echo"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" HE"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"LL"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" using"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" bash"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"`,"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" then"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" stop"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_wNJIQDMLdssZp45zIXvz2684","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Print HELLO to stdout"}}]}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_wNJIQDMLdssZp45zIXvz2684","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","title":"echo HELLO","kind":"execute","status":"in_progress","rawInput":"echo HELLO","content":[{"type":"content","content":{"type":"text","text":"Echo HELLO to stdout"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_1rmSWHhVchVg7PDTmegT0421","status":"failed","content":[{"type":"content","content":{"type":"text","text":"```console\ntool output rejected by codex policy: summarize instead\n```"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" was"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" with"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instruction"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" \""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"sum"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"mar"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" means"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cannot"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" show"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" raw"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" as"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" requested"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" Let"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" try"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" comply"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" but"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" quote"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" what"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" happened"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Actually"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":","}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" got"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" rejected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" by"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"x"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" summarize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"\"\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"So"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" should"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" report"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" that"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"atim"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"The"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" I"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" got"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" back"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":\n\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"<"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"_result"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":">"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"tool"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" rejected"}}}} @@ -133,42 +61,9 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summarize"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`\n\n"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"This"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" verb"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"atim"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" result"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" from"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" tool"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" —"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" was"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" not"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" expected"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" `"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"HE"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"LL"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"O"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\""}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"`"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" output"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" because"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" the"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" code"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"x"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" policy"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" blocked"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" and"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instructed"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" me"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" to"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" summarize"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" instead"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"```"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} From 8ada835396a021a7dce8f72d6b025dcd3a264c15 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:03:33 +0800 Subject: [PATCH 035/163] docs: remove links to private subagent helpers --- packages/subagent/subagent-subprocess/src/index.ts | 12 +++++------- packages/subagent/tool-subagent/src/index.ts | 10 +++++----- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index 2ee2745985..bd792e7f23 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -2,11 +2,10 @@ * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn * an external agent as a child process and must keep the parent deployment's * credentials out of it, tear it down to quiescence, and isolate it from the - * host user's on-disk CLI state. The pieces: the credential env scrub - * ({@link SENSITIVE_ENV_PATTERN} / {@link buildChildEnv}), the spawn-failure - * capture ({@link spawnFailure}), the child-exit waits ({@link waitForExit} / - * {@link exitsWithin}), the stdin-EOF → SIGTERM → SIGKILL dispose ladder - * ({@link disposeChildProcess}), and the per-run isolated config dir + * host user's on-disk CLI state. The pieces: credential-shaped env scrubbing + * ({@link buildChildEnv}), spawn-failure capture ({@link spawnFailure}), + * bounded child-exit waits inside the stdin-EOF → SIGTERM → SIGKILL dispose + * ladder ({@link disposeChildProcess}), and the per-run isolated config dir * ({@link createIsolatedConfigDir}). * * This package owns no provider and registers nothing; it is a pure library @@ -37,8 +36,7 @@ const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i /** * The ambient env minus credential-shaped vars, plus the caller's explicit * env. `PATH`, `HOME`, `TMPDIR`, locale, and proxy vars survive the scrub, so - * a child CLI runs normally; only {@link SENSITIVE_ENV_PATTERN}-shaped names - * are dropped. + * a child CLI runs normally; only credential-shaped names are dropped. * @param extra - explicit vars layered on top AFTER the scrub, so a * credential-shaped name supplied deliberately still reaches the child. * @returns the environment to spawn the child with. diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 426753dbb9..ff54bc109a 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -12,11 +12,11 @@ * sees only `{ description, prompt }`. * * The tool DESCRIPTION is derived from the bound provider's conversation-history - * descriptor ({@link providerWording}): a fresh-conversation provider (spawn, - * ACP) gets the standalone-prompt wording, while a seeded-conversation provider - * (fork) tells the model the child already sees the conversation's completed - * turns. This descriptor says nothing about Cordis scope, services, tools, or - * authority. The tool MIRRORS the + * descriptor ({@link SubagentProvider.inheritsParentContext}): a + * fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording, + * while a seeded-conversation provider (fork) tells the model the child already + * sees the conversation's completed turns. This descriptor says nothing about + * Cordis scope, services, tools, or authority. The tool MIRRORS the * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers * when the provider is (or becomes) available and unregisters when the * provider goes away — so no load-order requirement exists and an HMR reload From cb80277e60c17ac5238dc7ee740ae315ee3521ff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:11:13 +0800 Subject: [PATCH 036/163] fix: retain JSON-RPC subagent locality per run --- docs/event-producer-consumer.md | 4 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 45 +++++++++++---- packages/ui/jsonrpc/tests/server.spec.ts | 73 ++++++++++++++++++++---- 4 files changed, 98 insertions(+), 26 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 80389608cc..04aff464a9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | @@ -34,7 +34,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:91`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:67`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:73`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:49`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | - | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:59`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:173`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 735cfead0a..95d7d7c7d7 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage because the child may be disposed before `subagent/end`, and the provider contract does not require lineage. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage for the agent lifetime and snapshots it per run, because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index bf21f7fc2d..12bf82de3c 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -17,7 +17,7 @@ import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { AgentHandle } from '@deepseek-ai/dsh-agent' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' -import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' @@ -65,10 +65,11 @@ interface LocalAgentRecord { /** * The SDK server over a booted harness context. Constructing it subscribes to - * the context's `session/event`, `session/created`, `agent/created`, and - * `subagent/end` events and forwards them to the host as notifications; the - * subscriptions live until {@link shutdown}. One instance serves one transport - * peer for the process lifetime — there is no re-`initialize`. + * session, agent, and subagent lifecycle events, forwarding durable session + * events and SDK-facing completion notifications while retaining local-run + * identity across child disposal. The subscriptions live until + * {@link shutdown}. One instance serves one transport peer for the process + * lifetime — there is no re-`initialize`. */ export class HarnessSdkServer { private cwd = process.cwd() @@ -77,6 +78,7 @@ export class HarnessSdkServer { private readonly sessions = new Map() private readonly sessionCreations = new Map>() private readonly localAgents = new Map() + private readonly localRuns = new Map() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -100,18 +102,38 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache runtime-local identity and optional lineage on creation: by the - // time `subagent/end` fires the child agent may already be disposed and - // gone from the registry. Parent lineage is not required by the provider - // contract, so an empty record remains a load-bearing locality marker. + // Cache runtime-local identity and optional lineage for each agent lifetime. + // Parent lineage is not required by the provider contract, so an empty + // record remains a load-bearing locality marker. this.disposers.push(ctx.on('agent/created', (agent) => { const parentSessionId = agent.session.header.parentSession this.localAgents.set(agent.id, parentSessionId === undefined ? {} : { parentSessionId }) })) - this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { + this.disposers.push(ctx.on('agent/disposed', (agent) => { + this.localAgents.delete(agent.id) + })) + // Snapshot locality per run. A provider may settle one run, continue the + // same live child in another run, and dispose that child before the later + // result settles. Consuming an agent-lifetime marker at the first end would + // lose the later notification; this queue pairs each start with one end. + this.disposers.push(ctx.on('subagent/start', (info: SubagentRunInfo) => { const agent = this.ctx.agents.get(info.id) const cachedLocalAgent = this.localAgents.get(info.id) - this.localAgents.delete(info.id) + const localAgent = cachedLocalAgent ?? (agent === undefined + ? undefined + : agent.session.header.parentSession === undefined + ? {} + : { parentSessionId: agent.session.header.parentSession }) + if (localAgent === undefined) return + const runs = this.localRuns.get(info.id) ?? [] + runs.push(localAgent) + this.localRuns.set(info.id, runs) + })) + this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { + const agent = this.ctx.agents.get(info.id) + const runs = this.localRuns.get(info.id) + const cachedLocalAgent = runs?.shift() + if (runs?.length === 0) this.localRuns.delete(info.id) // This protocol reports LOCAL child sessions. A lineage-bearing child // has the session/created-driven start notification above; a parentless // local provider still gets its terminal notification. A remote provider @@ -196,6 +218,7 @@ export class HarnessSdkServer { const records = [...this.sessions.values()] this.sessions.clear() this.localAgents.clear() + this.localRuns.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 163aee4c0c..d56f3000f6 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -11,7 +11,7 @@ import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import SubagentService, { type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' +import SubagentService, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import { HarnessSdkServer, type JsonRpcTransportPeer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -67,7 +67,13 @@ async function makeHarness(storageDir: string) { } /** Drive the owning service so test lifecycle events carry the real parent scope. */ -async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndInfo): Promise { +async function settleSubagent( + ctx: Context, + parent: Agent, + info: SubagentRunEndInfo, + beforeSettle?: () => Promise, +): Promise { + const result = Promise.withResolvers() const disposeProvider = ctx.subagents.registerProvider({ name: info.provider, capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -75,9 +81,7 @@ async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndI async start() { return { id: info.id, - result: info.lastAssistantMessage === undefined - ? Promise.reject(new Error('synthetic infrastructure failure')) - : Promise.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }), + result: result.promise, dispose: () => Promise.resolve(), } }, @@ -88,6 +92,12 @@ async function settleSubagent(ctx: Context, parent: Agent, info: SubagentRunEndI prompt: [], signal: new AbortController().signal, }) + await beforeSettle?.() + if (info.lastAssistantMessage === undefined) { + result.reject(new Error('synthetic infrastructure failure')) + } else { + result.resolve({ output: info.lastAssistantMessage, stopReason: info.stopReason }) + } await run.result.then(() => undefined, () => undefined) await run.dispose() } finally { @@ -277,21 +287,17 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - // The backend may dispose the child before publishing its run outcome; - // cached locality must survive with or without optional parent lineage. - await handle.dispose() - await parentlessHandle.dispose() await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], - }) + }, () => handle.dispose()) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('parentless-child-session'), stopReason: 'error', - }) + }, () => parentlessHandle.dispose()) expect(transport.notifications).toContainEqual({ method: 'subagent.finished', @@ -324,6 +330,49 @@ describe('HarnessSdkServer', () => { } }) + it('retains locality across continuation runs on one live child', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('continuation-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const childHandle = await ctx.agents.create({ + sessionId: SessionId('continuation-child'), + meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') }, + agentOptions: { model: 'deepseek' }, + }) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'continuation', + id: SessionId('continuation-child'), + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'first' }], + }) + await settleSubagent(ctx, parentHandle.agent, { + provider: 'continuation', + id: SessionId('continuation-child'), + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'second' }], + }, () => childHandle.dispose()) + + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'continuation-child', + )).toHaveLength(2) + + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('falls back to live lineage and ignores runs without a local child session', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) @@ -585,6 +634,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(4) + expect(on).toHaveBeenCalledTimes(6) }) }) From d898d1faf0739e4e4f2edd5b6f7a156f54ed2d25 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:25:52 +0800 Subject: [PATCH 037/163] fix: avoid FIFO subagent lineage attribution --- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 49 ++++++--- packages/ui/jsonrpc/tests/server.spec.ts | 123 ++++++++++++++++++++++- 3 files changed, 158 insertions(+), 16 deletions(-) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 95d7d7c7d7..5ff7eb7f8e 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage for the agent lifetime and snapshots it per run, because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage for the agent lifetime and counts pending runs per provider/id, because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. Settlement order is not assumed: if concurrent ID reuse makes parent lineage ambiguous, the completion remains local but omits the optional `parentSessionId` rather than attributing the wrong parent. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 12bf82de3c..ce0ac9c75a 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -63,6 +63,13 @@ interface LocalAgentRecord { parentSessionId?: SessionId } +/** Pending local runs that share one provider/id correlation key. */ +interface PendingLocalRuns { + count: number + parentSessionId?: SessionId + parentAmbiguous: boolean +} + /** * The SDK server over a booted harness context. Constructing it subscribes to * session, agent, and subagent lifecycle events, forwarding durable session @@ -78,7 +85,7 @@ export class HarnessSdkServer { private readonly sessions = new Map() private readonly sessionCreations = new Map>() private readonly localAgents = new Map() - private readonly localRuns = new Map() + private readonly localRuns = new Map>() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -112,10 +119,12 @@ export class HarnessSdkServer { this.disposers.push(ctx.on('agent/disposed', (agent) => { this.localAgents.delete(agent.id) })) - // Snapshot locality per run. A provider may settle one run, continue the - // same live child in another run, and dispose that child before the later - // result settles. Consuming an agent-lifetime marker at the first end would - // lose the later notification; this queue pairs each start with one end. + // Snapshot locality per provider/id run key. A provider may settle one run, + // continue the same live child in another run, and dispose that child before + // the later result settles. Counts preserve every completion without + // assuming settlement order. If id reuse produces disagreeing lineage, the + // optional parent is omitted until that pending group drains rather than + // attributed to the wrong completion. this.disposers.push(ctx.on('subagent/start', (info: SubagentRunInfo) => { const agent = this.ctx.agents.get(info.id) const cachedLocalAgent = this.localAgents.get(info.id) @@ -125,21 +134,35 @@ export class HarnessSdkServer { ? {} : { parentSessionId: agent.session.header.parentSession }) if (localAgent === undefined) return - const runs = this.localRuns.get(info.id) ?? [] - runs.push(localAgent) - this.localRuns.set(info.id, runs) + const providerRuns = this.localRuns.get(info.provider) ?? new Map() + const pending = providerRuns.get(info.id) + if (pending === undefined) { + providerRuns.set(info.id, localAgent.parentSessionId === undefined + ? { count: 1, parentAmbiguous: false } + : { count: 1, parentSessionId: localAgent.parentSessionId, parentAmbiguous: false }) + } else { + pending.count += 1 + if (pending.parentSessionId !== localAgent.parentSessionId) pending.parentAmbiguous = true + } + this.localRuns.set(info.provider, providerRuns) })) this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { const agent = this.ctx.agents.get(info.id) - const runs = this.localRuns.get(info.id) - const cachedLocalAgent = runs?.shift() - if (runs?.length === 0) this.localRuns.delete(info.id) + const providerRuns = this.localRuns.get(info.provider) + const pending = providerRuns?.get(info.id) + if (pending !== undefined) { + pending.count -= 1 + if (pending.count === 0) providerRuns?.delete(info.id) + if (providerRuns?.size === 0) this.localRuns.delete(info.provider) + } // This protocol reports LOCAL child sessions. A lineage-bearing child // has the session/created-driven start notification above; a parentless // local provider still gets its terminal notification. A remote provider // has neither a cached creation nor a live local agent and is ignored. - if (cachedLocalAgent === undefined && agent === undefined) return - const parentSessionId = cachedLocalAgent?.parentSessionId ?? agent?.session.header.parentSession + if (pending === undefined && agent === undefined) return + const parentSessionId = pending === undefined + ? agent?.session.header.parentSession + : pending.parentAmbiguous ? undefined : pending.parentSessionId this.transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index d56f3000f6..48b8c4b1be 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -373,6 +373,100 @@ describe('HarnessSdkServer', () => { } }) + it('omits ambiguous lineage when one local id is reused and runs settle out of order', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const oldParent = await ctx.agents.create({ + sessionId: SessionId('old-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const oldChild = await ctx.agents.create({ + sessionId: SessionId('reused-child'), + meta: { cwd: storageDir, parentSession: SessionId('old-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const first = Promise.withResolvers() + const sameLifetime = Promise.withResolvers() + const replacement = Promise.withResolvers() + const results = [first.promise, sameLifetime.promise, replacement.promise] + let starts = 0 + const disposeProvider = ctx.subagents.registerProvider({ + name: 'reused', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + const result = results[starts] + starts += 1 + if (result === undefined) throw new Error('unexpected fourth reused-id run') + return Promise.resolve({ id: SessionId('reused-child'), result, dispose: () => Promise.resolve() }) + }, + }) + + const firstRun = await ctx.subagents.start('reused', { + parent: oldParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + const sameLifetimeRun = await ctx.subagents.start('reused', { + parent: oldParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + sameLifetime.resolve({ output: [{ type: 'text', text: 'same lifetime' }], stopReason: 'completed' }) + await sameLifetimeRun.result + await oldChild.dispose() + const newParent = await ctx.agents.create({ + sessionId: SessionId('new-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const newChild = await ctx.agents.create({ + sessionId: SessionId('reused-child'), + meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const secondRun = await ctx.subagents.start('reused', { + parent: newParent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + replacement.resolve({ output: [{ type: 'text', text: 'new lifetime' }], stopReason: 'completed' }) + await secondRun.result + first.resolve({ output: [{ type: 'text', text: 'old lifetime' }], stopReason: 'completed' }) + await firstRun.result + await Promise.resolve() + + const finished = transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'reused-child', + ) + expect(finished.map(notification => notification.params?.lastAssistantMessage)).toEqual([ + [{ type: 'text', text: 'same lifetime' }], + [{ type: 'text', text: 'new lifetime' }], + [{ type: 'text', text: 'old lifetime' }], + ]) + expect(finished[0]?.params?.parentSessionId).toBe('old-parent') + expect(finished.slice(1).every(notification => !Object.hasOwn(notification.params ?? {}, 'parentSessionId'))).toBe(true) + + await firstRun.dispose() + await sameLifetimeRun.dispose() + await secondRun.dispose() + disposeProvider() + await newChild.dispose() + await oldParent.dispose() + await newParent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('falls back to live lineage and ignores runs without a local child session', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) @@ -395,13 +489,38 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) + const missedStartResult = Promise.withResolvers() + const disposeMissedStartProvider = ctx.subagents.registerProvider({ + name: 'fork', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: true, + start: () => Promise.resolve({ + id: SessionId('fallback-child-session'), + result: missedStartResult.promise, + dispose: () => Promise.resolve(), + }), + }) + // Start before the server subscribes, so the terminal fallback must use + // the still-live registry entry rather than a cached start record. + const missedStartRun = await ctx.subagents.start('fork', { + parent: parentHandle.agent, + prompt: [], + signal: new AbortController().signal, + }) const transport = new FakeTransport() const server = new HarnessSdkServer(ctx, transport) + missedStartResult.resolve({ output: [], stopReason: 'max-tokens' }) + await missedStartRun.result + await Promise.resolve() + await missedStartRun.dispose() + disposeMissedStartProvider() + // The server also missed this agent's creation, but observes the start; + // recover its lineage from the still-live registry entry. await settleSubagent(ctx, parentHandle.agent, { - provider: 'fork', + provider: 'fork-live-fallback', id: SessionId('fallback-child-session'), - stopReason: 'max-tokens', + stopReason: 'completed', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { From 45e3997efcecf48c57e171a7d77401b1c914ea32 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:41:31 +0800 Subject: [PATCH 038/163] fix: correlate subagent completion by parent scope --- docs/event-producer-consumer.md | 4 +- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 101 ++++++++--------------- packages/ui/jsonrpc/tests/server.spec.ts | 13 ++- 4 files changed, 47 insertions(+), 73 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 04aff464a9..bc1c0d38d0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,8 +7,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:426`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:444`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 5ff7eb7f8e..ffbd7a1288 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server caches runtime-local identity plus optional parent lineage for the agent lifetime and counts pending runs per provider/id, because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. Settlement order is not assumed: if concurrent ID reuse makes parent lineage ambiguous, the completion remains local but omits the optional `parentSessionId` rather than attributing the wrong parent. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server counts local starts by provider/id and the exact delegating-parent carrier, because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index ce0ac9c75a..352563f7f2 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -15,8 +15,10 @@ import type { Context } from 'cordis' import { resolve } from 'node:path' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' +import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' @@ -58,21 +60,14 @@ interface SessionRecord { activePrompt: boolean } -/** Runtime-local agent identity plus optional durable fork lineage. */ -interface LocalAgentRecord { - parentSessionId?: SessionId -} - -/** Pending local runs that share one provider/id correlation key. */ -interface PendingLocalRuns { - count: number - parentSessionId?: SessionId - parentAmbiguous: boolean +/** Recover the delegating parent carried by every service-owned subagent lifecycle event. */ +function subagentParentOf(carrier: Scoped): Agent { + return carrierKeyOf(carrier) as Agent } /** * The SDK server over a booted harness context. Constructing it subscribes to - * session, agent, and subagent lifecycle events, forwarding durable session + * session and subagent lifecycle events, forwarding durable session * events and SDK-facing completion notifications while retaining local-run * identity across child disposal. The subscriptions live until * {@link shutdown}. One instance serves one transport peer for the process @@ -84,8 +79,7 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly localAgents = new Map() - private readonly localRuns = new Map>() + private readonly localRuns = new Map>>() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -109,64 +103,40 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // Cache runtime-local identity and optional lineage for each agent lifetime. - // Parent lineage is not required by the provider contract, so an empty - // record remains a load-bearing locality marker. - this.disposers.push(ctx.on('agent/created', (agent) => { - const parentSessionId = agent.session.header.parentSession - this.localAgents.set(agent.id, parentSessionId === undefined ? {} : { parentSessionId }) + // In-process providers publish the child before start. Count those starts by + // the exact delegating-parent carrier so later completions remain local after + // child disposal and reused ids need no settlement-order assumption. + const localRuns = this.localRuns + this.disposers.push(ctx.on('subagent/start', function (this: Scoped, info: SubagentRunInfo) { + if (ctx.agents.get(info.id) === undefined) return + const parent = subagentParentOf(this) + const providerRuns = localRuns.get(info.provider) ?? new Map>() + const parentRuns = providerRuns.get(info.id) ?? new Map() + parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1) + providerRuns.set(info.id, parentRuns) + localRuns.set(info.provider, providerRuns) })) - this.disposers.push(ctx.on('agent/disposed', (agent) => { - this.localAgents.delete(agent.id) - })) - // Snapshot locality per provider/id run key. A provider may settle one run, - // continue the same live child in another run, and dispose that child before - // the later result settles. Counts preserve every completion without - // assuming settlement order. If id reuse produces disagreeing lineage, the - // optional parent is omitted until that pending group drains rather than - // attributed to the wrong completion. - this.disposers.push(ctx.on('subagent/start', (info: SubagentRunInfo) => { - const agent = this.ctx.agents.get(info.id) - const cachedLocalAgent = this.localAgents.get(info.id) - const localAgent = cachedLocalAgent ?? (agent === undefined - ? undefined - : agent.session.header.parentSession === undefined - ? {} - : { parentSessionId: agent.session.header.parentSession }) - if (localAgent === undefined) return - const providerRuns = this.localRuns.get(info.provider) ?? new Map() - const pending = providerRuns.get(info.id) - if (pending === undefined) { - providerRuns.set(info.id, localAgent.parentSessionId === undefined - ? { count: 1, parentAmbiguous: false } - : { count: 1, parentSessionId: localAgent.parentSessionId, parentAmbiguous: false }) - } else { - pending.count += 1 - if (pending.parentSessionId !== localAgent.parentSessionId) pending.parentAmbiguous = true - } - this.localRuns.set(info.provider, providerRuns) - })) - this.disposers.push(ctx.on('subagent/end', (info: SubagentRunEndInfo) => { - const agent = this.ctx.agents.get(info.id) - const providerRuns = this.localRuns.get(info.provider) - const pending = providerRuns?.get(info.id) - if (pending !== undefined) { - pending.count -= 1 - if (pending.count === 0) providerRuns?.delete(info.id) - if (providerRuns?.size === 0) this.localRuns.delete(info.provider) + this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { + const agent = ctx.agents.get(info.id) + const parent = subagentParentOf(this) + const providerRuns = localRuns.get(info.provider) + const parentRuns = providerRuns?.get(info.id) + const pendingCount = parentRuns?.get(parent) + if (pendingCount !== undefined) { + if (pendingCount === 1) parentRuns?.delete(parent) + else parentRuns?.set(parent, pendingCount - 1) + if (parentRuns?.size === 0) providerRuns?.delete(info.id) + if (providerRuns?.size === 0) localRuns.delete(info.provider) } // This protocol reports LOCAL child sessions. A lineage-bearing child // has the session/created-driven start notification above; a parentless // local provider still gets its terminal notification. A remote provider - // has neither a cached creation nor a live local agent and is ignored. - if (pending === undefined && agent === undefined) return - const parentSessionId = pending === undefined - ? agent?.session.header.parentSession - : pending.parentAmbiguous ? undefined : pending.parentSessionId - this.transport.notify('subagent.finished', { + // has neither a pending local start nor a live local agent and is ignored. + if (pendingCount === undefined && agent === undefined) return + transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), - ...(parentSessionId === undefined ? {} : { parentSessionId: String(parentSessionId) }), + parentSessionId: String(parent.session.id), childSessionId: String(info.id), status: info.stopReason === 'completed' ? 'ok' : 'error', stopReason: info.stopReason, @@ -240,7 +210,6 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.localAgents.clear() this.localRuns.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 48b8c4b1be..427a190c36 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -316,6 +316,7 @@ describe('HarnessSdkServer', () => { params: { provider: 'spawn', agentId: 'parentless-child-session', + parentSessionId: 'main', childSessionId: 'parentless-child-session', status: 'error', stopReason: 'error', @@ -373,7 +374,7 @@ describe('HarnessSdkServer', () => { } }) - it('omits ambiguous lineage when one local id is reused and runs settle out of order', async () => { + it('correlates reused local ids by parent scope when runs settle out of order', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-reuse-')) const ctx = await makeHarness(storageDir) try { @@ -450,8 +451,11 @@ describe('HarnessSdkServer', () => { [{ type: 'text', text: 'new lifetime' }], [{ type: 'text', text: 'old lifetime' }], ]) - expect(finished[0]?.params?.parentSessionId).toBe('old-parent') - expect(finished.slice(1).every(notification => !Object.hasOwn(notification.params ?? {}, 'parentSessionId'))).toBe(true) + expect(finished.map(notification => notification.params?.parentSessionId)).toEqual([ + 'old-parent', + 'new-parent', + 'old-parent', + ]) await firstRun.dispose() await sameLifetimeRun.dispose() @@ -551,6 +555,7 @@ describe('HarnessSdkServer', () => { params: { provider: 'fork', agentId: 'failed-child-session', + parentSessionId: 'fallback-parent', childSessionId: 'failed-child-session', status: 'error', stopReason: 'error', @@ -753,6 +758,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(6) + expect(on).toHaveBeenCalledTimes(4) }) }) From d33a819d15964fac1212f7b716f50106c8bb0ed1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:55:38 +0800 Subject: [PATCH 039/163] fix: share scope carrier across built JSON-RPC --- AGENTS.md | 2 +- docs/testing.md | 2 +- packages/ui/jsonrpc/package.json | 2 + .../jsonrpc/tests/built-scope-carrier.e2e.ts | 121 ++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/run-gates.ts | 1 + 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 4dfbedb97b..1494d3fcf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/docs/testing.md b/docs/testing.md index 23cca651fe..a1a841296a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index be19ab7217..bf4fe39118 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts new file mode 100644 index 0000000000..159600e49c --- /dev/null +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -0,0 +1,121 @@ +/** + * Built-artifact guard for the scope carrier shared by `dsh-subagent` and + * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must + * externalize `dsh-scope`; source-mode tests cannot expose an accidentally + * inlined second registry. This test runs the real `lib/index.js` bundles in a + * plain Node subprocess, disposes the child before settlement, and requires the + * SDK completion notification to retain the delegating parent. + */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) + +const builtRuntimeProbe = String.raw` +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const load = (path) => import(pathToFileURL(resolve(path)).href); +const [ + { Context }, + agentCore, + { default: SubagentService }, + { default: SessionPersistenceJsonl }, + { HarnessSdkServer }, + { SessionId }, +] = await Promise.all([ + load("vendor/cordis/lib/index.js"), + load("packages/core/agent-core/lib/index.js"), + load("packages/subagent/subagent/lib/index.js"), + load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), + load("packages/ui/jsonrpc/lib/index.js"), + load("packages/core/session/lib/index.js"), +]); + +const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); +const ctx = new Context(); +try { + await ctx.plugin(agentCore); + await ctx.plugin(SubagentService); + await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); + await new Promise((ready) => setTimeout(ready, 50)); + + const notifications = []; + const server = new HarnessSdkServer(ctx, { + request() { return Promise.reject(new Error("unexpected host request")); }, + notify(method, params) { notifications.push({ method, params }); }, + }); + const parent = await ctx.agents.create({ + sessionId: SessionId("built-parent"), + meta: { cwd: storageRoot }, + agentOptions: { model: "test" }, + }); + const child = await ctx.agents.create({ + sessionId: SessionId("built-child"), + meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, + agentOptions: { model: "test" }, + }); + const result = Promise.withResolvers(); + const unregister = ctx.subagents.registerProvider({ + name: "built-local", + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + return Promise.resolve({ + id: child.agent.id, + result: result.promise, + dispose() { return Promise.resolve(); }, + }); + }, + }); + const run = await ctx.subagents.start("built-local", { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }); + await child.dispose(); + result.resolve({ output: [], stopReason: "completed" }); + await run.result; + await Promise.resolve(); + + console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); + await run.dispose(); + unregister(); + await parent.dispose(); + await server.shutdown(); +} finally { + await ctx.fiber.dispose(); + await rm(storageRoot, { recursive: true, force: true }); +} +` + +describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { + it('preserves parent-scoped completion after child disposal', async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { + cwd: repoRoot, + timeout: 15_000, + }) + + expect(stderr).not.toContain('listener threw') + expect(JSON.parse(stdout) as unknown).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'built-local', + agentId: 'built-child', + parentSessionId: 'built-parent', + childSessionId: 'built-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [], + }, + }]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6214564eae..dec8c3024f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1288,6 +1288,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 131501abb0..e1c41149ca 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -332,6 +332,7 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', + 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From 39c6c1120abdcc0414325bbdd1624917b0cb8624 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:56:21 +0800 Subject: [PATCH 040/163] Revert "fix: share scope carrier across built JSON-RPC" This reverts commit 835fd3ca4f46e0c6464098e3d0864dc56f31398d. --- AGENTS.md | 2 +- docs/testing.md | 2 +- packages/ui/jsonrpc/package.json | 2 - .../jsonrpc/tests/built-scope-carrier.e2e.ts | 121 ------------------ pnpm-lock.yaml | 3 - scripts/run-gates.ts | 1 - 6 files changed, 2 insertions(+), 129 deletions(-) delete mode 100644 packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 1494d3fcf8..4dfbedb97b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/docs/testing.md b/docs/testing.md index a1a841296a..23cca651fe 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index bf4fe39118..be19ab7217 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,7 +28,6 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -39,7 +38,6 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts deleted file mode 100644 index 159600e49c..0000000000 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * Built-artifact guard for the scope carrier shared by `dsh-subagent` and - * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must - * externalize `dsh-scope`; source-mode tests cannot expose an accidentally - * inlined second registry. This test runs the real `lib/index.js` bundles in a - * plain Node subprocess, disposes the child before settlement, and requires the - * SDK completion notification to retain the delegating parent. - */ - -import { execFile } from 'node:child_process' -import { existsSync } from 'node:fs' -import { fileURLToPath } from 'node:url' -import { promisify } from 'node:util' -import { describe, expect, it } from 'vitest' - -const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) -const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) -const execFileAsync = promisify(execFile) - -const builtRuntimeProbe = String.raw` -import { mkdtemp, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -const load = (path) => import(pathToFileURL(resolve(path)).href); -const [ - { Context }, - agentCore, - { default: SubagentService }, - { default: SessionPersistenceJsonl }, - { HarnessSdkServer }, - { SessionId }, -] = await Promise.all([ - load("vendor/cordis/lib/index.js"), - load("packages/core/agent-core/lib/index.js"), - load("packages/subagent/subagent/lib/index.js"), - load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), - load("packages/ui/jsonrpc/lib/index.js"), - load("packages/core/session/lib/index.js"), -]); - -const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); -const ctx = new Context(); -try { - await ctx.plugin(agentCore); - await ctx.plugin(SubagentService); - await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); - await new Promise((ready) => setTimeout(ready, 50)); - - const notifications = []; - const server = new HarnessSdkServer(ctx, { - request() { return Promise.reject(new Error("unexpected host request")); }, - notify(method, params) { notifications.push({ method, params }); }, - }); - const parent = await ctx.agents.create({ - sessionId: SessionId("built-parent"), - meta: { cwd: storageRoot }, - agentOptions: { model: "test" }, - }); - const child = await ctx.agents.create({ - sessionId: SessionId("built-child"), - meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, - agentOptions: { model: "test" }, - }); - const result = Promise.withResolvers(); - const unregister = ctx.subagents.registerProvider({ - name: "built-local", - capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, - inheritsParentContext: false, - start() { - return Promise.resolve({ - id: child.agent.id, - result: result.promise, - dispose() { return Promise.resolve(); }, - }); - }, - }); - const run = await ctx.subagents.start("built-local", { - parent: parent.agent, - prompt: [], - signal: new AbortController().signal, - }); - await child.dispose(); - result.resolve({ output: [], stopReason: "completed" }); - await run.result; - await Promise.resolve(); - - console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); - await run.dispose(); - unregister(); - await parent.dispose(); - await server.shutdown(); -} finally { - await ctx.fiber.dispose(); - await rm(storageRoot, { recursive: true, force: true }); -} -` - -describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { - it('preserves parent-scoped completion after child disposal', async () => { - const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { - cwd: repoRoot, - timeout: 15_000, - }) - - expect(stderr).not.toContain('listener threw') - expect(JSON.parse(stdout) as unknown).toEqual([{ - method: 'subagent.finished', - params: { - provider: 'built-local', - agentId: 'built-child', - parentSessionId: 'built-parent', - childSessionId: 'built-child', - status: 'ok', - stopReason: 'completed', - lastAssistantMessage: [], - }, - }]) - }) -}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dec8c3024f..6214564eae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1288,9 +1288,6 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek - '@deepseek-ai/dsh-scope': - specifier: workspace:^ - version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index e1c41149ca..131501abb0 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -332,7 +332,6 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', - 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From c6cb9f4210d9303c197ac985fca7a4dc3b11cb88 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:55:38 +0800 Subject: [PATCH 041/163] fix: share scope carrier across built JSON-RPC --- AGENTS.md | 2 +- docs/testing.md | 2 +- packages/ui/jsonrpc/package.json | 2 + .../jsonrpc/tests/built-scope-carrier.e2e.ts | 121 ++++++++++++++++++ pnpm-lock.yaml | 3 + scripts/run-gates.ts | 1 + 6 files changed, 129 insertions(+), 2 deletions(-) create mode 100644 packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts diff --git a/AGENTS.md b/AGENTS.md index 4dfbedb97b..1494d3fcf8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ printf '%s\n' "$out" | grep -q '\[tool call\] echo({"text":"ci smoke"})' printf '%s\n' "$out" | grep -q '\[tool result\] ECHO: CI SMOKE' test -n "$(find .sessions -path '.sessions/cwd-*/main-session-*.jsonl' -type f -print -quit)" rm -rf .sessions -pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +pnpm exec vitest run --config vitest.e2e.config.ts packages/ui/stdio-agent/tests/built-bin.e2e.ts packages/ui/acp-agent/tests/built-bin.e2e.ts packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts ``` `test:coverage`, not `test`, is the gating run ([why](docs/testing.md)); a sign-off counts only for commands actually run. diff --git a/docs/testing.md b/docs/testing.md index 23cca651fe..a1a841296a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -25,7 +25,7 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword - A plugin shipped via `cordis.yml` needs at least one test through the REAL Loader path: hand-built `ctx.plugin({...})` mounts bypass `unwrapExports` and cannot catch a broken export shape ([postmortem 0001](postmortem/0001-acp-default-export-drops-inject.md); export-shape rules in [packages/AGENTS.md](../packages/AGENTS.md)). - A guard only guards if the regression actually fails it. For a plugin without `inject` (bundle/composition plugins), a Loader smoke stays green under a broken export shape — add an explicit `expect('default' in mod).toBe(false)` plus an `unwrapExports` round-trip assertion, and prove it: introduce the regression, watch red, revert. -- "Real entry path" means the published artifact: the package `bin` points at built `lib/bin.js` under plain `node`, which tsx masks (settle races, module resolution, a swallowed load failure exiting 0). The same applies to any non-index runtime entry the built package resolves at run time (the worker-thread runtime's sibling `lib/worker.cjs`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. +- "Real entry path" means the published artifact: a package `bin` runs built `lib/bin.js` under plain `node`, exposing failures tsx masks (settle races, module resolution, swallowed load failures). The same applies to non-index runtime entries (the worker-thread sibling `lib/worker.cjs`) and singleton modules shared across bundles (`packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts`). Keep the built-artifact smokes green (`packages/ui/*/tests/built-bin.e2e.ts`, `packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts`), and assert a genuinely-missing config exits non-zero. - An e2e that spawns an example from a temp cwd sets `TSX_TSCONFIG_PATH` to the repo-root tsconfig, or it silently falls back to stale built `lib/` ([examples/AGENTS.md](../examples/AGENTS.md)). ## When a snapshot test is required diff --git a/packages/ui/jsonrpc/package.json b/packages/ui/jsonrpc/package.json index be19ab7217..bf4fe39118 100644 --- a/packages/ui/jsonrpc/package.json +++ b/packages/ui/jsonrpc/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", + "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-subagent": "^0.0.1", "cordis": "^4.0.0-rc.6" @@ -38,6 +39,7 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts new file mode 100644 index 0000000000..159600e49c --- /dev/null +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -0,0 +1,121 @@ +/** + * Built-artifact guard for the scope carrier shared by `dsh-subagent` and + * `dsh-jsonrpc`. The carrier registry is module-local, so both bundles must + * externalize `dsh-scope`; source-mode tests cannot expose an accidentally + * inlined second registry. This test runs the real `lib/index.js` bundles in a + * plain Node subprocess, disposes the child before settlement, and requires the + * SDK completion notification to retain the delegating parent. + */ + +import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { promisify } from 'node:util' +import { describe, expect, it } from 'vitest' + +const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) +const jsonrpcBundle = fileURLToPath(new URL('../lib/index.js', import.meta.url)) +const execFileAsync = promisify(execFile) + +const builtRuntimeProbe = String.raw` +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const load = (path) => import(pathToFileURL(resolve(path)).href); +const [ + { Context }, + agentCore, + { default: SubagentService }, + { default: SessionPersistenceJsonl }, + { HarnessSdkServer }, + { SessionId }, +] = await Promise.all([ + load("vendor/cordis/lib/index.js"), + load("packages/core/agent-core/lib/index.js"), + load("packages/subagent/subagent/lib/index.js"), + load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), + load("packages/ui/jsonrpc/lib/index.js"), + load("packages/core/session/lib/index.js"), +]); + +const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); +const ctx = new Context(); +try { + await ctx.plugin(agentCore); + await ctx.plugin(SubagentService); + await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); + await new Promise((ready) => setTimeout(ready, 50)); + + const notifications = []; + const server = new HarnessSdkServer(ctx, { + request() { return Promise.reject(new Error("unexpected host request")); }, + notify(method, params) { notifications.push({ method, params }); }, + }); + const parent = await ctx.agents.create({ + sessionId: SessionId("built-parent"), + meta: { cwd: storageRoot }, + agentOptions: { model: "test" }, + }); + const child = await ctx.agents.create({ + sessionId: SessionId("built-child"), + meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, + agentOptions: { model: "test" }, + }); + const result = Promise.withResolvers(); + const unregister = ctx.subagents.registerProvider({ + name: "built-local", + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start() { + return Promise.resolve({ + id: child.agent.id, + result: result.promise, + dispose() { return Promise.resolve(); }, + }); + }, + }); + const run = await ctx.subagents.start("built-local", { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }); + await child.dispose(); + result.resolve({ output: [], stopReason: "completed" }); + await run.result; + await Promise.resolve(); + + console.log(JSON.stringify(notifications.filter(({ method }) => method === "subagent.finished"))); + await run.dispose(); + unregister(); + await parent.dispose(); + await server.shutdown(); +} finally { + await ctx.fiber.dispose(); + await rm(storageRoot, { recursive: true, force: true }); +} +` + +describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', () => { + it('preserves parent-scoped completion after child disposal', async () => { + const { stdout, stderr } = await execFileAsync(process.execPath, ['--input-type=module', '-e', builtRuntimeProbe], { + cwd: repoRoot, + timeout: 15_000, + }) + + expect(stderr).not.toContain('listener threw') + expect(JSON.parse(stdout) as unknown).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'built-local', + agentId: 'built-child', + parentSessionId: 'built-parent', + childSessionId: 'built-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [], + }, + }]) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6214564eae..dec8c3024f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1288,6 +1288,9 @@ importers: '@deepseek-ai/dsh-llm-deepseek': specifier: workspace:^ version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-scope': + specifier: workspace:^ + version: link:../../core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 131501abb0..e1c41149ca 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -332,6 +332,7 @@ function builtBinSmokeGate(): Gate { 'vitest.e2e.config.ts', 'packages/ui/stdio-agent/tests/built-bin.e2e.ts', 'packages/ui/acp-agent/tests/built-bin.e2e.ts', + 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', // The worker-entry packages' built bundles: the only automated proof // that lib/index.js resolves its sibling lib/worker.cjs under plain node // (the e2e lane runs unbuilt, so these files self-skip there). From 151d6387e03b9f41b553e7f079588f76d7317516 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:57:46 +0800 Subject: [PATCH 042/163] docs: refresh module dependency graph --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ac71b11cc..864f39359f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -293,6 +293,7 @@ flowchart TD pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent pkg_workflow_workerthread --> pkg_agent @@ -389,7 +390,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | From 82c0d1fb7cc291196bc3fd5733dfec867d84dac7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 08:59:33 +0800 Subject: [PATCH 043/163] test: register JSON-RPC artifact smoke --- knip.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 825980f205..fe2366e3fa 100644 --- a/knip.json +++ b/knip.json @@ -77,13 +77,17 @@ "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, - "packages/ui/stdio-agent": { + "packages/ui/jsonrpc": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, "packages/ui/jsonrpc-agent": { "project": ["src/**/*.ts"] }, + "packages/ui/stdio-agent": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/subagent/subagent-spawn": { "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] From e784e4dce5f5178b5e22a3d3376599144d8bea1b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:12:32 +0800 Subject: [PATCH 044/163] fix: await ACP client callbacks during shutdown --- packages/support/acp-snapshot/src/launcher.ts | 59 ++++++++++++------- .../acp-snapshot/tests/harness.spec.ts | 36 +++++++++++ 2 files changed, 75 insertions(+), 20 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 815753ae5f..30702a5888 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent { stderr(): string /** Resolve when a future session update matches the predicate. */ waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise - /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, and ACP parser drain. */ + /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, ACP parsing, and client callbacks. */ close(signal?: NodeJS.Signals): Promise } @@ -137,29 +137,41 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe Writable.toWeb(child.stdin) as WritableStream, Readable.toWeb(passthrough) as ReadableStream, ) + const inFlightClientCallbacks = new Set>() + const trackClientCallback = (callback: () => T | PromiseLike): Promise => { + const pending = Promise.resolve().then(callback) + inFlightClientCallbacks.add(pending) + void pending.then( + () => { inFlightClientCallbacks.delete(pending) }, + () => { inFlightClientCallbacks.delete(pending) }, + ) + return pending + } + const requestPermission = options.requestPermission + ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' as const } })) const makeClient = (_agent: AcpAgent): Client => ({ sessionUpdate(params: SessionNotification): Promise { - updates.push(params.update) - for (let index = updateWaiters.length - 1; index >= 0; index--) { - const waiter = updateWaiters[index] - /* v8 ignore next 1 -- index is bounded by the array length */ - if (waiter === undefined) continue - let matches: boolean - try { - matches = waiter.match(params.update) - } catch (error: unknown) { + return trackClientCallback(() => { + updates.push(params.update) + for (let index = updateWaiters.length - 1; index >= 0; index--) { + const waiter = updateWaiters[index] + /* v8 ignore next 1 -- index is bounded by the array length */ + if (waiter === undefined) continue + let matches: boolean + try { + matches = waiter.match(params.update) + } catch (error: unknown) { + updateWaiters.splice(index, 1) + waiter.reject(error) + continue + } + if (!matches) continue updateWaiters.splice(index, 1) - waiter.reject(error) - continue + waiter.resolve(params.update) } - if (!matches) continue - updateWaiters.splice(index, 1) - waiter.resolve(params.update) - } - return Promise.resolve() + }) }, - requestPermission: options.requestPermission - ?? (() => Promise.resolve({ outcome: { outcome: 'cancelled' } })), + requestPermission: params => trackClientCallback(() => requestPermission(params)), }) const client = new ClientSideConnection(makeClient, stream) // `exit` only reports the parent process's status. Descendants may retain @@ -168,7 +180,14 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.all([stdioClosed, client.closed]).then(() => undefined) + const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + // The ACP SDK's readable loop dispatches client callbacks without awaiting + // them. Once `closed` settles no new callbacks can start, but callbacks + // already in flight still belong to this launch's teardown boundary. + while (inFlightClientCallbacks.size > 0) { + await Promise.allSettled([...inFlightClientCallbacks]) + } + }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the // parser has dispatched every buffered frame. diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 051db6a0ba..902f48d2cf 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -1,4 +1,5 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -112,6 +113,41 @@ describe('runScenario', () => { expect(launched.stderr()).toContain('late inherited stderr') }) + it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => { + const { dir, fixtureFile } = await scenario({ permissionProbe: true }) + let releasePermission: (() => void) | undefined + const permissionReleased = new Promise((resolve) => { releasePermission = resolve }) + let markPermissionStarted: (() => void) | undefined + const permissionStarted = new Promise((resolve) => { markPermissionStarted = resolve }) + let permissionFinished = false + const launched = launchAcpTestAgent({ + agent: AGENT, + cwd: dir, + env: { DSH_SNAPSHOT_FILE: fixtureFile }, + async requestPermission() { + markPermissionStarted?.() + await permissionReleased + permissionFinished = true + return { outcome: { outcome: 'cancelled' } } + }, + }) + await launched.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await launched.client.newSession({ cwd: dir, mcpServers: [] }) + void launched.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }).catch(() => undefined) + await permissionStarted + + const childClosed = once(launched.child, 'close') + let closeSettled = false + const closing = launched.close('SIGKILL').then(() => { closeSettled = true }) + await childClosed + await launched.client.closed + expect(closeSettled).toBe(false) + + releasePermission?.() + await closing + expect(permissionFinished).toBe(true) + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From d4c96deac3802164c430335309f936f3e5ab4f1d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:19:02 +0800 Subject: [PATCH 045/163] refactor: share ACP callback cleanup --- packages/support/acp-snapshot/src/launcher.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 30702a5888..f1271986c0 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -141,10 +141,8 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe const trackClientCallback = (callback: () => T | PromiseLike): Promise => { const pending = Promise.resolve().then(callback) inFlightClientCallbacks.add(pending) - void pending.then( - () => { inFlightClientCallbacks.delete(pending) }, - () => { inFlightClientCallbacks.delete(pending) }, - ) + const untrack = (): void => { inFlightClientCallbacks.delete(pending) } + void pending.then(untrack, untrack) return pending } const requestPermission = options.requestPermission From 2dea4afac0d49976700139a766d40734e3204f4e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:23:02 +0800 Subject: [PATCH 046/163] fix: bind stdio to its configured agent --- packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/src/index.ts | 5 ++- packages/ui/stdio-agent/src/stdio-chat.ts | 30 +++++++++++----- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 35 ++++++++++++++----- 4 files changed, 53 insertions(+), 19 deletions(-) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 82da64cd81..d7d30ee49d 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id. Resumed sessions register under the exact `resumeSessionId` and keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id; the UI binds to that fresh-id namespace, or to the exact `resumeSessionId` for a resumed run, and never selects unrelated registry roots. Resumed sessions keep the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index f01c48a615..2eca0cceb3 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -124,5 +124,8 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.' }) + ctx.plugin(uiStdio, { + welcome: config.welcome ?? 'ready.', + ...config.resumeSessionId !== undefined ? { resumeSessionId: config.resumeSessionId } : {}, + }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index a295274786..76347c91e4 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -36,10 +36,13 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string + /** Exact persisted session id the app configured for resume; absent selects the app's fresh `main-session-*` identity. */ + resumeSessionId?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), + resumeSessionId: z.string(), }) /** @@ -95,16 +98,25 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // This app owns one configured top-level agent. Hold the live object - // directly: its per-run id is intentionally fresh, while `main` remains only - // the terminal's fixed display label. Runtime creator ownership distinguishes - // that root from its subagents even if a child is registered after an HMR - // replacement. Persisted parentSession lineage is deliberately irrelevant: - // a resumed child session can itself be this process's configured root. - let target: Agent | undefined = ctx.agents.roots()[0] - ctx.on('agent/created', () => { target ??= ctx.agents.roots()[0] }) + // Bind only to this app's configured top-level agent. Fresh runs own the + // `main-session-*` namespace; resumed runs own the exact persisted id. The + // registry's runtime-root relation excludes subagents without confusing it + // with durable parentSession lineage. Keeping the matching candidates also + // covers HMR's publish-new-before-dispose-old ordering without ever falling + // through to an unrelated root owned by another app or test fixture. + const matchesConfiguredIdentity = (agent: Agent): boolean => config.resumeSessionId === undefined + ? agent.id.startsWith('main-session-') + : agent.id === config.resumeSessionId + const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity)) + let target: Agent | undefined = [...configuredRoots].at(-1) + ctx.on('agent/created', (agent) => { + if (!matchesConfiguredIdentity(agent) || !ctx.agents.roots().includes(agent)) return + configuredRoots.add(agent) + target ??= agent + }) ctx.on('agent/disposed', (agent) => { - if (target === agent) target = ctx.agents.roots().at(-1) + configuredRoots.delete(agent) + if (target === agent) target = [...configuredRoots].at(-1) }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2600fdb24f..668068a72d 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -74,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there' } +const CONFIG: Config = { welcome: 'hi there', resumeSessionId: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -199,7 +199,9 @@ describe('createStdioChat rendering', () => { }) it('accepts a lineage-bearing configured agent created after the UI installs', async () => { - const { ctx, input } = await setup() + const { ctx, input } = await setup({ welcome: 'hi there', resumeSessionId: 'resumed' }) + const unrelated = makeAgent('unrelated') + ctx.agents.register(unrelated) const resumed = makeAgent('resumed') ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(resumed) @@ -207,6 +209,7 @@ describe('createStdioChat rendering', () => { input.feed('continue') await new Promise(resolve => setImmediate(resolve)) + expect(unrelated.sent).toEqual([]) expect(resumed.sent).toEqual([[{ type: 'text', text: 'continue' }]]) }) @@ -235,7 +238,7 @@ describe('createStdioChat rendering', () => { it('keeps the target when a different agent is disposed', async () => { const { ctx, out } = await setup() - const target = makeAgent('target') + const target = makeAgent('main') ctx.agents.register(target) ctx.emit('agent/disposed', makeAgent('other')) ctx.emit('session/event', target.session, { @@ -245,11 +248,11 @@ describe('createStdioChat rendering', () => { }) it('retargets a surviving root when HMR publishes it before disposing the old root', async () => { - const { ctx, input } = await setup() - const oldRoot = makeAgent('old-root') + const { ctx, input } = await setup({ welcome: 'hi there' }) + const oldRoot = makeAgent('main-session-old') const child = makeAgent('child') ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id - const replacement = makeAgent('replacement') + const replacement = makeAgent('main-session-replacement') const lateChild = makeAgent('late-child') const disposeOld = ctx.agents.register(oldRoot) const disposeChild = ctx.agents.enter(child, oldRoot) @@ -273,6 +276,22 @@ describe('createStdioChat rendering', () => { disposeChild() }) + it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { + const { ctx, input } = await setup() + const unrelated = makeAgent('unrelated') + ctx.agents.register(unrelated) + const configured = makeAgent('main') + const disposeConfigured = ctx.agents.register(configured) + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + + disposeConfigured() + input.feed('must not leak') + await new Promise(resolve => setImmediate(resolve)) + + expect(unrelated.sent).toEqual([]) + expect(error).toHaveBeenCalledWith('ui-stdio: main agent is not running') + }) + it('renders tool/call and tool/result session events', async () => { const { ctx, out } = await setup() const session = {} as Session @@ -720,8 +739,8 @@ describe('createStdioChat input', () => { expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running') }) - it('drives the app-owned agent without a duplicate id config', async () => { - const { ctx, input } = await setup({ welcome: 'w' }) + it('drives the exact app-configured resumed session', async () => { + const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: 'worker' }) const agent = makeAgent('worker') ctx.agents.register(agent) input.feed('hi') From 1bb9995128f75474eeec9933e2bab6fc69a8b60e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:28:41 +0800 Subject: [PATCH 047/163] docs: state subagent carrier invariant --- packages/ui/jsonrpc/src/server.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 352563f7f2..fe605a8536 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -62,6 +62,7 @@ interface SessionRecord { /** Recover the delegating parent carried by every service-owned subagent lifecycle event. */ function subagentParentOf(carrier: Scoped): Agent { + // SubagentService emits this lifecycle pair only through scopeTarget(this, parent). return carrierKeyOf(carrier) as Agent } From ba8a5c89ed4390b4b1679b736f2c44d79bdbaab9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:29:02 +0800 Subject: [PATCH 048/163] docs: name the public agent injection seam --- packages/bash/tool-bash/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index fe07b80989..cda683c5b4 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -457,7 +457,7 @@ export function apply(ctx: Context): void { ) } catch (error: unknown) { // The ONE expected failure: the agent was disposed between task - // completion and this injection (ReactLoopAgent.inject throws + // completion and this injection (Agent.inject throws // `agent "" is disposed`). That race is benign — drop the notice. // Anything else is a real bug and must surface, not be swallowed. if (error instanceof Error && error.message.includes('is disposed')) return From ccd810f84d74ce452d858b553eb0cbdb282d715c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:36:22 +0800 Subject: [PATCH 049/163] fix: normalize empty stdio resume identity --- packages/ui/stdio-agent/src/stdio-chat.ts | 5 +++-- packages/ui/stdio-agent/tests/stdio-chat.spec.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 76347c91e4..17a11168db 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -104,9 +104,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt // with durable parentSession lineage. Keeping the matching candidates also // covers HMR's publish-new-before-dispose-old ordering without ever falling // through to an unrelated root owned by another app or test fixture. - const matchesConfiguredIdentity = (agent: Agent): boolean => config.resumeSessionId === undefined + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const matchesConfiguredIdentity = (agent: Agent): boolean => resumeSessionId === undefined ? agent.id.startsWith('main-session-') - : agent.id === config.resumeSessionId + : agent.id === resumeSessionId const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity)) let target: Agent | undefined = [...configuredRoots].at(-1) ctx.on('agent/created', (agent) => { diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 668068a72d..2ebc51e14e 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -747,6 +747,15 @@ describe('createStdioChat input', () => { await new Promise(r => setImmediate(r)) expect(agent.sent).toHaveLength(1) }) + + it('treats an empty resume session id as a fresh configured identity', async () => { + const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: '' }) + const agent = makeAgent('main-session-fresh') + ctx.agents.register(agent) + input.feed('hi') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toHaveLength(1) + }) }) describe('createStdioChat EOF exit', () => { From 6b59b6050e379de30bb8f53bbc38172f8ffe896a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:43:12 +0800 Subject: [PATCH 050/163] fix: preserve ACP scenario cleanup failures --- packages/support/acp-snapshot/src/harness.ts | 20 +++++++--- .../acp-snapshot/tests/harness.spec.ts | 38 ++++++++++++++++++- 2 files changed, 51 insertions(+), 7 deletions(-) diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index 3db1355b0f..22b22deacd 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -244,9 +244,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise ) // Failure-safe teardown: wait for a still-running child, then attempt BOTH - // directory removals even when an earlier cleanup rejects. The main outcome - // wins over teardown noise so a step/harvest failure is never replaced; on a - // successful run, the first cleanup failure remains visible to the caller. + // directory removals even when an earlier cleanup rejects. Report every + // teardown failure alongside a scenario failure so neither orthogonal + // outcome hides the other. const cleanupResults: PromiseSettledResult[] = [] const cleanup = async (action: () => Promise): Promise => { cleanupResults.push(...await Promise.allSettled([action()])) @@ -256,10 +256,18 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise await cleanup(() => rm(cwd, { recursive: true, force: true })) await cleanup(() => rm(sessionsRoot, { recursive: true, force: true })) + const cleanupFailures = cleanupResults + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (cleanupFailures.length > 0) { + throw new AggregateError( + outcome.status === 'rejected' ? [outcome.error, ...cleanupFailures] : cleanupFailures, + outcome.status === 'rejected' + ? 'snapshot scenario and cleanup failed' + : 'snapshot cleanup failed', + ) + } if (outcome.status === 'rejected') throw outcome.error - const cleanupFailure = cleanupResults.find((result): result is PromiseRejectedResult => result.status === 'rejected') - /* v8 ignore next 1 -- defensive OS cleanup failure after an otherwise successful real subprocess run */ - if (cleanupFailure !== undefined) throw cleanupFailure.reason return outcome.value } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 902f48d2cf..de764cfd8a 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -3,11 +3,29 @@ import { once } from 'node:events' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, describe, expect, it } from 'vitest' +import { afterAll, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { runScenario, type AgentUnderTest, type InputStep } from '../src/harness.ts' import { launchAcpTestAgent } from '../src/launcher.ts' +const fsControl = vi.hoisted(() => ({ cleanupFailure: undefined as Error | undefined })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async rm(...args: Parameters): Promise { + if (String(args[0]).includes('acp-snap-cwd-') && fsControl.cleanupFailure !== undefined) { + const failure = fsControl.cleanupFailure + fsControl.cleanupFailure = undefined + await actual.rm(...args) + throw failure + } + await actual.rm(...args) + }, + } +}) + /** * Unit tests for the subprocess harness, driven through the REAL spawn path * (tsx loader, temp cwd, env plumbing) against the scripted fake ACP bin in @@ -238,6 +256,24 @@ describe('runScenario', () => { )).rejects.toThrow(/expected the prompt to fail/) }) + it('reports scenario and cleanup failures together', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({ prompt: 'respond' }) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: [...boot, { op: 'promptExpectError', text: 'fine' }] }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + const failures = (failure as AggregateError).errors as unknown[] + expect(failures).toHaveLength(2) + expect(failures[0]).toBeInstanceOf(Error) + expect((failures[0] as Error).message).toMatch(/expected the prompt to fail/) + expect(failures[1]).toBe(cleanupFailure) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario( From 2333ab19e3b03bc84cf050b16e897b830b2dfdf2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:46:05 +0800 Subject: [PATCH 051/163] fix: verify JSON-RPC child ownership --- docs/cordis-catalog/services.md | 1 + packages/core/agent/README.md | 1 + packages/core/agent/src/index.ts | 12 ++++++ packages/core/agent/tests/agent.spec.ts | 4 ++ packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 11 +++-- packages/ui/jsonrpc/tests/server.spec.ts | 51 ++++++++++++++++++++---- 7 files changed, 68 insertions(+), 14 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1366fcf6f6..6a40f88a23 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -33,6 +33,7 @@ register(agent: Agent): () => void enter(agent: Agent, owner: Agent | undefined): () => void announce(agent: Agent): void get(id: SessionId): Agent | undefined +isOwnedBy(id: SessionId, owner: Agent): boolean list(): Agent[] roots(): Agent[] ``` diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index a45603f191..941f46baf0 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -13,6 +13,7 @@ The scoped-registration surface: `Agent.ctx` is the agent's scope context (`dsh- - `ctx.agents.register(agent: Agent): () => void` — record an **already-constructed** agent. Disposed with the calling fiber. - Advanced ordered lifecycle: `enter(agent, owner): () => void` enforces `agent.id === agent.session.id`, performs the authoritative ID collision check, and inserts without announcing; `owner` explicitly records the live creator-agent relation (or `undefined` for a root), independently of durable session lineage. `announce(agent)` emits `agent/created` exactly once. A detach requested synchronously by a creation listener is deferred until that dispatch unwinds, and every detach checks the captured entry object, so a stale capability cannot delete a later same-ID replacement. The async factory uses this split; ordinary plugins use `register()`. - `ctx.agents.get(id: SessionId): Agent | undefined` +- `ctx.agents.isOwnedBy(id: SessionId, owner: Agent): boolean` — whether the exact live entry was created through that parent agent's scoped context; runtime ownership is independent of durable session lineage. - `ctx.agents.list(): Agent[]` - `ctx.agents.roots(): Agent[]` — live agents created without an owning agent context; a resumed lineage-bearing session can still be a runtime root. diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index abafa38e45..8a9ed68ab6 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -440,6 +440,18 @@ export class AgentRegistry extends Service { return this.store.get(id)?.agent } + /** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ + isOwnedBy(id: SessionId, owner: Agent): boolean { + return this.store.get(id)?.owner === owner + } + /** * All live agents, in registration order. * @returns a fresh array; mutating it does not affect the registry. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index d2866cacb3..5d79d1a91f 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -72,8 +72,12 @@ describe('AgentRegistry', () => { expect(ctx.agents.list()).toEqual([root, child]) expect(ctx.agents.roots()).toEqual([root]) + expect(ctx.agents.isOwnedBy(child.id, root)).toBe(true) + expect(ctx.agents.isOwnedBy(root.id, root)).toBe(false) + expect(ctx.agents.isOwnedBy(SessionId('missing'), root)).toBe(false) detachChild() + expect(ctx.agents.isOwnedBy(child.id, root)).toBe(false) detachRoot() }) diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index ffbd7a1288..df612ac263 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server counts local starts by provider/id and the exact delegating-parent carrier, because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported because they create no local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server verifies that the live child is owned by the exact delegating parent, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index fe605a8536..d78f24b5ab 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -109,8 +109,8 @@ export class HarnessSdkServer { // child disposal and reused ids need no settlement-order assumption. const localRuns = this.localRuns this.disposers.push(ctx.on('subagent/start', function (this: Scoped, info: SubagentRunInfo) { - if (ctx.agents.get(info.id) === undefined) return const parent = subagentParentOf(this) + if (!ctx.agents.isOwnedBy(info.id, parent)) return const providerRuns = localRuns.get(info.provider) ?? new Map>() const parentRuns = providerRuns.get(info.id) ?? new Map() parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1) @@ -118,7 +118,6 @@ export class HarnessSdkServer { localRuns.set(info.provider, providerRuns) })) this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { - const agent = ctx.agents.get(info.id) const parent = subagentParentOf(this) const providerRuns = localRuns.get(info.provider) const parentRuns = providerRuns?.get(info.id) @@ -130,10 +129,10 @@ export class HarnessSdkServer { if (providerRuns?.size === 0) localRuns.delete(info.provider) } // This protocol reports LOCAL child sessions. A lineage-bearing child - // has the session/created-driven start notification above; a parentless - // local provider still gets its terminal notification. A remote provider - // has neither a pending local start nor a live local agent and is ignored. - if (pendingCount === undefined && agent === undefined) return + // has the session/created-driven start notification above. A remote run + // has neither a cached owned start nor a live child owned by this exact + // parent; an unrelated local agent with the same id never makes it local. + if (pendingCount === undefined && !ctx.agents.isOwnedBy(info.id, parent)) return transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 427a190c36..c97bd1a6e1 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -277,12 +277,12 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const handle = await ctx.agents.create({ + const handle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) - const parentlessHandle = await ctx.agents.create({ + const parentlessHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('parentless-child-session'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, @@ -331,6 +331,43 @@ describe('HarnessSdkServer', () => { } }) + it('ignores a remote run id that collides with an unrelated local agent', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parentHandle = await ctx.agents.create({ + sessionId: SessionId('collision-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const unrelatedHandle = await ctx.agents.create({ + sessionId: SessionId('remote-run-id'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + + await settleSubagent(ctx, parentHandle.agent, { + provider: 'remote', + id: SessionId('remote-run-id'), + stopReason: 'completed', + lastAssistantMessage: [], + }, () => unrelatedHandle.dispose()) + + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.agentId === 'remote-run-id', + )).toBe(false) + + await parentHandle.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + it('retains locality across continuation runs on one live child', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-continuation-')) const ctx = await makeHarness(storageDir) @@ -342,7 +379,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const childHandle = await ctx.agents.create({ + const childHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('continuation-child'), meta: { cwd: storageDir, parentSession: SessionId('continuation-parent') }, agentOptions: { model: 'deepseek' }, @@ -385,7 +422,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const oldChild = await ctx.agents.create({ + const oldChild = await oldParent.agent.ctx.agents.create({ sessionId: SessionId('reused-child'), meta: { cwd: storageDir, parentSession: SessionId('old-parent') }, agentOptions: { model: 'deepseek' }, @@ -425,7 +462,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const newChild = await ctx.agents.create({ + const newChild = await newParent.agent.ctx.agents.create({ sessionId: SessionId('reused-child'), meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, agentOptions: { model: 'deepseek' }, @@ -483,12 +520,12 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - handle = await ctx.agents.create({ + handle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('fallback-child-session'), meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { model: 'deepseek' }, }) - failedHandle = await ctx.agents.create({ + failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, From 6b782b018927139ea07e5907488b0b948d90c813 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:54:34 +0800 Subject: [PATCH 052/163] fix: bind stdio to its exact fresh identity --- docs/config-catalog.md | 4 +- .../2026-06-20-unify-agent-and-session-id.md | 2 +- packages/core/agent-loop/README.md | 5 ++- packages/core/agent-loop/src/index.ts | 11 +++-- .../tests/config-session-id.spec.ts | 30 +++++++++++++ packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/src/index.ts | 7 ++- packages/ui/stdio-agent/src/stdio-chat.ts | 32 +++++--------- .../ui/stdio-agent/tests/stdio-agent.spec.ts | 13 ++++++ .../ui/stdio-agent/tests/stdio-chat.spec.ts | 44 +++++-------------- 10 files changed, 88 insertions(+), 62 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 94d613bc4e..66791edb0a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -119,6 +119,8 @@ export interface Config { agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string + /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -645,7 +647,7 @@ export interface Config { Depends on: [`agentCore`](../packages/core/agent-core/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/ui/stdio-agent/src/index.ts:64`](../packages/ui/stdio-agent/src/index.ts) +Source: [`packages/ui/stdio-agent/src/index.ts:65`](../packages/ui/stdio-agent/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index 032eae4f7d..404ba83636 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -16,7 +16,7 @@ Session itself repeated the same fact as `Session.id` and `Session.header.id`. C An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone. -The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; `resumeSessionId` instead supplies the exact combined identity to load and register. Logs may use the stable label while all live and durable lookups use the one `SessionId`. +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start normally mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; a coupled app may pre-mint and pass the exact fresh `sessionId`, while `resumeSessionId` supplies the exact combined identity to load and register. The two exact-id inputs are mutually exclusive. Stdio uses this narrow escape hatch so its config-created agent and UI share one opaque identity instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`. `agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index f40577bb95..246c9cdc27 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and mints `${label}-session-` before calling this boundary; `resumeSessionId` instead loads and registers the exact persisted id. This keeps fresh restarts collision-free without retaining a second live routing identity. +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary; an app may instead supply an exact fresh `sessionId` when another coupled component must bind to it. `resumeSessionId` loads and registers the exact persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): @@ -33,6 +33,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo interface Config { agents: Array<{ id: string // required stable label; prefixes fresh combined ids + sessionId?: string // optional exact identity for a fresh session model?: string resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -40,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` applies only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` and optional `sessionId` apply only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index dbe503ff6a..0041b984d1 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -326,6 +326,8 @@ export interface Config { agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string + /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */ + sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string /** Persisted session to resume instead of creating a fresh session. */ @@ -341,6 +343,7 @@ export class AgentLoop extends Service implements AgentFactory { static Config = z.object({ agents: z.array(z.object({ id: z.string().required(), + sessionId: z.string(), model: z.string(), cwd: z.string(), resumeSessionId: z.string(), @@ -360,12 +363,14 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('model', context => context.agent?.options.model) ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) - for (const { id, cwd, resumeSessionId, ...options } of config.agents) { + for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { if (resumeSessionId === undefined || resumeSessionId === '') { - const sessionId = SessionId(`${id}-session-${randomUUID()}`) - this.create(sessionId, options, cwd === undefined ? {} : { cwd }) + this.create(sessionId ?? SessionId(`${id}-session-${randomUUID()}`), options, cwd === undefined ? {} : { cwd }) continue } + if (sessionId !== undefined) { + throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`) + } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 134ea68c32..543335e5fd 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -24,7 +24,37 @@ function waitForIdle(ctx: Context, agent: ReactLoopAgent): Promise { }) } +async function makeCoreContext(): Promise { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + return ctx +} + describe('config-driven session id', () => { + it('accepts one exact fresh id and rejects it alongside a resume id', async () => { + const exact = await makeCoreContext() + await exact.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact'), model: 'mock' }], + }) + expect(exact.agents.get(SessionId('stdio-exact'))?.session.id).toBe('stdio-exact') + await exact.fiber.dispose() + + const conflicting = await makeCoreContext() + await expect(conflicting.plugin(AgentLoop, { + agents: [{ + id: 'main', + sessionId: SessionId('fresh'), + resumeSessionId: SessionId('persisted'), + model: 'mock', + }], + })).rejects.toThrow('sessionId and resumeSessionId are mutually exclusive') + await conflicting.fiber.dispose() + }) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index d7d30ee49d..b509e46781 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The UI's `main` text is a display label, not a second routing id; the UI binds to that fresh-id namespace, or to the exact `resumeSessionId` for a resumed run, and never selects unrelated registry roots. Resumed sessions keep the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; the UI's `main` text is only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 2eca0cceb3..03f3d49555 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -39,6 +39,7 @@ */ import type { Context } from 'cordis' +import { randomUUID } from 'node:crypto' import ConsoleExporter from '@cordisjs/plugin-logger-console' import z from 'schemastery' import { SessionId } from '@deepseek-ai/dsh-session' @@ -108,6 +109,8 @@ export const Config: z = z.object({ * a leaf concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { + const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId + const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) ctx.plugin(ConsoleExporter) ctx.plugin(agentCore, { ...config.persona !== undefined ? { persona: config.persona } : {}, @@ -117,7 +120,7 @@ export function apply(ctx: Context, config: Config): void { id: 'main', model: config.model, cwd: process.cwd(), - ...config.resumeSessionId !== undefined ? { resumeSessionId: SessionId(config.resumeSessionId) } : {}, + ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, }], ...config.skills !== undefined ? { skills: config.skills } : {}, }) @@ -126,6 +129,6 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(toolAskUser) ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', - ...config.resumeSessionId !== undefined ? { resumeSessionId: config.resumeSessionId } : {}, + sessionId, }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 17a11168db..93c8f9b4f1 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -36,13 +36,13 @@ export const inject = ['agents', 'userInteraction'] export interface Config { /** Banner printed once on start, before the first `> ` prompt. */ welcome?: string - /** Exact persisted session id the app configured for resume; absent selects the app's fresh `main-session-*` identity. */ - resumeSessionId?: string + /** Exact shared agent/session identity this app instance created or resumed. */ + sessionId?: string } export const Config: z = z.object({ welcome: z.string().default('ready.'), - resumeSessionId: z.string(), + sessionId: z.string(), }) /** @@ -98,26 +98,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const welcome = config.welcome ?? 'ready.' const { input, output, exit } = runtime - // Bind only to this app's configured top-level agent. Fresh runs own the - // `main-session-*` namespace; resumed runs own the exact persisted id. The - // registry's runtime-root relation excludes subagents without confusing it - // with durable parentSession lineage. Keeping the matching candidates also - // covers HMR's publish-new-before-dispose-old ordering without ever falling - // through to an unrelated root owned by another app or test fixture. - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const matchesConfiguredIdentity = (agent: Agent): boolean => resumeSessionId === undefined - ? agent.id.startsWith('main-session-') - : agent.id === resumeSessionId - const configuredRoots = new Set(ctx.agents.roots().filter(matchesConfiguredIdentity)) - let target: Agent | undefined = [...configuredRoots].at(-1) + // Bind only to the exact identity this app passed to its config-created + // agent. Session ids are opaque: neither a prefix nor registry order can + // identify ownership. The root check rejects a child that somehow preempts + // the configured id; later recreation under the same id supports loop HMR. + const matchesConfiguredIdentity = (agent: Agent): boolean => + agent.id === config.sessionId && ctx.agents.roots().includes(agent) + let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === config.sessionId) ctx.on('agent/created', (agent) => { - if (!matchesConfiguredIdentity(agent) || !ctx.agents.roots().includes(agent)) return - configuredRoots.add(agent) - target ??= agent + if (matchesConfiguredIdentity(agent)) target = agent }) ctx.on('agent/disposed', (agent) => { - configuredRoots.delete(agent) - if (target === agent) target = [...configuredRoots].at(-1) + if (target === agent) target = undefined }) // Transcript rendering off the durable `session/event` feed — the assistant diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 5c5c0d2667..9a7bf10bad 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -93,6 +93,19 @@ describe('dsh-stdio-agent app', () => { await ctx.fiber.dispose() }) + it('normalizes an empty resume id to a fresh exact app identity', async () => { + const ctx = await mount({ + model: 'mock', + resumeSessionId: '', + persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', + skills: await isolatedSkillsConfig(), + }) + const agent = ctx.get('agents')?.list()[0] + expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) + expect(agent?.id).toBe(agent?.session.id) + await ctx.fiber.dispose() + }) + it('defaults persistenceRoot and welcome when omitted', async () => { // Direct apply (NOT via ctx.plugin, which validates+defaults the config // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 2ebc51e14e..f1ca914525 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -74,7 +74,7 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { return { type: 'assistant/chunk', seq: 0, time: 0, data: { turn: 1, step: 0, chunk } } } -const CONFIG: Config = { welcome: 'hi there', resumeSessionId: 'main' } +const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() @@ -199,7 +199,7 @@ describe('createStdioChat rendering', () => { }) it('accepts a lineage-bearing configured agent created after the UI installs', async () => { - const { ctx, input } = await setup({ welcome: 'hi there', resumeSessionId: 'resumed' }) + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) const unrelated = makeAgent('unrelated') ctx.agents.register(unrelated) const resumed = makeAgent('resumed') @@ -247,33 +247,21 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[main turn 1] ') }) - it('retargets a surviving root when HMR publishes it before disposing the old root', async () => { - const { ctx, input } = await setup({ welcome: 'hi there' }) - const oldRoot = makeAgent('main-session-old') - const child = makeAgent('child') - ;(child.session.header as { parentSession?: string }).parentSession = oldRoot.id - const replacement = makeAgent('main-session-replacement') - const lateChild = makeAgent('late-child') + it('retargets only the exact identity after loop HMR recreation', async () => { + const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'main-session-fixed' }) + const oldRoot = makeAgent('main-session-fixed') + const prefixCollision = makeAgent('main-session-unrelated') const disposeOld = ctx.agents.register(oldRoot) - const disposeChild = ctx.agents.enter(child, oldRoot) - ctx.agents.announce(child) - ctx.agents.register(replacement) - const disposeLateChild = ctx.agents.enter(lateChild, replacement) - ctx.agents.announce(lateChild) - - // The replacement's created edge arrived while oldRoot was still targeted. - // A replacement-owned child then arrived even later. Once oldRoot is - // removed, runtime ownership still identifies replacement as the only - // surviving root instead of selecting either newer child by insertion order. + ctx.agents.register(prefixCollision) disposeOld() + const replacement = makeAgent('main-session-fixed') + ctx.agents.register(replacement) + input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) - expect(child.sent).toEqual([]) - expect(lateChild.sent).toEqual([]) + expect(prefixCollision.sent).toEqual([]) expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) - disposeLateChild() - disposeChild() }) it('does not retarget stdin to an unrelated root after the configured agent is disposed', async () => { @@ -740,7 +728,7 @@ describe('createStdioChat input', () => { }) it('drives the exact app-configured resumed session', async () => { - const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: 'worker' }) + const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) const agent = makeAgent('worker') ctx.agents.register(agent) input.feed('hi') @@ -748,14 +736,6 @@ describe('createStdioChat input', () => { expect(agent.sent).toHaveLength(1) }) - it('treats an empty resume session id as a fresh configured identity', async () => { - const { ctx, input } = await setup({ welcome: 'w', resumeSessionId: '' }) - const agent = makeAgent('main-session-fresh') - ctx.agents.register(agent) - input.feed('hi') - await new Promise(r => setImmediate(r)) - expect(agent.sent).toHaveLength(1) - }) }) describe('createStdioChat EOF exit', () => { From a8c0e8a03c35b1303d7780b8215e482809c32d6f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:55:15 +0800 Subject: [PATCH 053/163] test: cover successful ACP cleanup failure --- .../support/acp-snapshot/tests/harness.spec.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index de764cfd8a..23b4192a4d 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -274,6 +274,21 @@ describe('runScenario', () => { expect(failures[1]).toBe(cleanupFailure) }) + it('reports cleanup failure after an otherwise successful scenario', { timeout: 20_000 }, async () => { + const { fixtureFile } = await scenario({}) + const cleanupFailure = new Error('cleanup failed') + fsControl.cleanupFailure = cleanupFailure + + const failure = await runScenario( + { steps: boot }, + { agent: AGENT, mode: 'replay', fixtureFile }, + ).catch((error: unknown): unknown => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).message).toBe('snapshot cleanup failed') + expect((failure as AggregateError).errors as unknown[]).toEqual([cleanupFailure]) + }) + it('newSessionExpectError swallows the rejection, with and without extra dirs', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ rejectExtraDirs: true }) const result = await runScenario( From 2dca8e2151ef3745f88507fe2aac17cabd630eac Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:58:35 +0800 Subject: [PATCH 054/163] docs: refresh agent loop catalog location --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 1366fcf6f6..6b456ef942 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:337`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` From 00462f80a6ff59604e44b71446bb9b2427a212c8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:01:36 +0800 Subject: [PATCH 055/163] docs: expose agent ownership in Cordis catalog --- packages/cordis/tool-cordis/src/api-catalog.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ff27f51067..ede7935f8e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -72,6 +72,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'enter(agent: Agent, owner: Agent | undefined): () => void', 'announce(agent: Agent): void', 'get(id: SessionId): Agent | undefined', + 'isOwnedBy(id: SessionId, owner: Agent): boolean', 'list(): Agent[]', 'roots(): Agent[]', ], From db3e97fc17a0f96fda7a2ecee94af86e7fad2102 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:05:19 +0800 Subject: [PATCH 056/163] test: create built probe child in parent scope --- packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts index 159600e49c..bc6642263e 100644 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -58,7 +58,7 @@ try { meta: { cwd: storageRoot }, agentOptions: { model: "test" }, }); - const child = await ctx.agents.create({ + const child = await parent.agent.ctx.agents.create({ sessionId: SessionId("built-child"), meta: { cwd: storageRoot, parentSession: SessionId("built-parent") }, agentOptions: { model: "test" }, From 3e2ba3f5574b43dc5af5d03e146769be241f96a3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:13:05 +0800 Subject: [PATCH 057/163] fix: stop ACP teardown when fallback kill fails --- packages/support/acp-snapshot/src/launcher.ts | 27 ++++++++- .../acp-snapshot/tests/harness.spec.ts | 59 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index f1271986c0..8d25a6f56c 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -65,7 +65,7 @@ export interface LaunchedAcpTestAgent { stderr(): string /** Resolve when a future session update matches the predicate. */ waitForUpdate(match: (update: SessionNotification['update']) => boolean): Promise - /** Gracefully close stdin, or send a signal, then wait for process exit, inherited stdio closure, ACP parsing, and client callbacks. */ + /** Close the process and drain its streams and callbacks; rejects promptly if fallback termination is refused. */ close(signal?: NodeJS.Signals): Promise } @@ -231,8 +231,29 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // signal can leave the subprocess live. Force termination, await the // already-observed exit edge, and only then propagate the child error so // callers may safely remove cwd/session resources after close rejects. - child.kill('SIGKILL') - await exited + const fallbackError = Promise.withResolvers() + const observeFallbackError = (error: Error): void => { fallbackError.resolve(error) } + child.once('error', observeFallbackError) + if (!child.kill('SIGKILL')) { + child.off('error', observeFallbackError) + closeUpdateStream() + throw new AggregateError( + [failure, new Error('Fallback SIGKILL was not accepted by the child process')], + 'ACP test agent failed and fallback termination was refused', + ) + } + const fallbackFailure = await Promise.race([ + exited.then((): undefined => undefined), + fallbackError.promise, + ]) + child.off('error', observeFallbackError) + if (fallbackFailure !== undefined) { + closeUpdateStream() + throw new AggregateError( + [failure, fallbackFailure], + 'ACP test agent failed and fallback termination was refused', + ) + } await drained closeUpdateStream() throw failure diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 23b4192a4d..ff7c304121 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -131,6 +131,65 @@ describe('runScenario', () => { expect(launched.stderr()).toContain('late inherited stderr') }) + it('rejects promptly when fallback termination is refused', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockReturnValue(false) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [ + childFailure, + expect.objectContaining({ message: 'Fallback SIGKILL was not accepted by the child process' }), + ], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + + it('rejects promptly when fallback termination emits an error', async () => { + const { dir } = await scenario({}) + const launched = launchAcpTestAgent({ agent: AGENT, cwd: dir }) + await launched.spawned + + const childFailure = Object.assign(new Error('signal refused'), { code: 'EPERM' }) + const fallbackFailure = Object.assign(new Error('fallback signal refused'), { code: 'EPERM' }) + const originalKill = launched.child.kill.bind(launched.child) + const kill = vi.spyOn(launched.child, 'kill').mockImplementation((signal) => { + if (signal === 'SIGKILL') queueMicrotask(() => launched.child.emit('error', fallbackFailure)) + return signal === 'SIGKILL' + }) + const closed = new Promise(resolve => launched.child.once('close', () => { resolve() })) + try { + launched.child.emit('error', childFailure) + const rejection = await launched.close('SIGTERM').catch((error: unknown): unknown => error) + expect(rejection).toBeInstanceOf(AggregateError) + expect(rejection).toMatchObject({ + message: 'ACP test agent failed and fallback termination was refused', + errors: [childFailure, fallbackFailure], + }) + expect(kill).toHaveBeenNthCalledWith(1, 'SIGTERM') + expect(kill).toHaveBeenNthCalledWith(2, 'SIGKILL') + } finally { + kill.mockRestore() + originalKill('SIGKILL') + await closed + } + }) + it('waits for in-flight client callbacks after the ACP stream closes', { timeout: 20_000 }, async () => { const { dir, fixtureFile } = await scenario({ permissionProbe: true }) let releasePermission: (() => void) | undefined From 816b6e4c68e26c38c4ac0aab1cad3ee14714a510 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:25:49 +0800 Subject: [PATCH 058/163] fix: restore exact config sessions on loop reload --- docs/config-catalog.md | 2 +- .../2026-06-20-unify-agent-and-session-id.md | 10 ++--- .../2026-07-02-remove-stream-chunk-mirror.md | 2 +- packages/core/agent-loop/README.md | 6 +-- packages/core/agent-loop/src/index.ts | 29 ++++++++++++++- .../tests/config-session-id.spec.ts | 37 +++++++++++++++++++ packages/ui/stdio-agent/README.md | 2 +- 7 files changed, 75 insertions(+), 13 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 66791edb0a..024d3b766b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -119,7 +119,7 @@ export interface Config { agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string - /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */ + /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */ sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index 404ba83636..20ba40c196 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -4,19 +4,19 @@ Status: implemented ## Problem -The agent factory previously carried two ids for each live agent/session pair: `agentId`, the `AgentRegistry` routing handle, and `sessionId`, the event-sourced/persisted-log identity. `CreateAgentOptions` took both; `ResumeAgentOptions` took `agentId` plus `resumeSessionId`; in-process subagents minted two independent UUIDs despite recording lineage separately. +A live agent/session pair needs one identity for registry routing, event sourcing, and persistence. Giving the factory independent `agentId` and `sessionId` inputs would permit pairings no production path can use, while forcing every consumer to choose or translate between two names for the same lifecycle. -ACP already used the same value for both identities. Where they diverged, stdio kept `labelBySession` solely to recover an agent label from session events, and hooks exposed both values for authors to reconcile. No production path reattached one live agent object to several sessions or drove one session through several agent ids. +ACP uses the same value for both identities. Stdio and hooks also operate on the session event stream and need the corresponding live agent directly; no production path reattaches one live agent object to several sessions or drives one session through several agent ids. -The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) had no reservation side tables: create and resume used one `AgentCreationTransaction`, and agent/session entries used the same final-entry collision rule. Separate ids therefore did not duplicate asynchronous liveness, rollback, or quiescence machinery. Identity unification was only an API and representation simplification: it deleted one caller-supplied id, one UUID per in-process child, and the remaining translation paths without changing the transaction lifecycle. +The [agent-scope runtime](../../implemented/architecture/2026-07-12-agent-scope-runtime-design.md) uses one `AgentCreationTransaction` for create and resume, and agent/session entries share the same final-entry collision rule. A second identity would not represent separate liveness, rollback, or quiescence; it would only add API and translation state around the same transaction. -Session itself repeated the same fact as `Session.id` and `Session.header.id`. Construction rejected a header whose id differed, so the aliases were constrained equal; the durable boundary nevertheless had to validate the duplicate, and production consumers chose between its two homes. +Session identity likewise has one home in `Session.header.id`; `Session.id` is a derived accessor rather than independent state that needs duplicate validation. ## Decision An agent's registry id equals its session id. `CreateAgentOptions` accepts one `sessionId` used for both final registry entries; resume registers the agent under `resumeSessionId`; in-process subagent creation uses the child session id; and `Session.id` derives from `header.id`. A remote ACP run has no local agent/session pair: it keeps one parent-minted lifecycle id while the child server's wire-local session id remains private to ACP calls. The existing creation transaction, final-entry collision checks, and exact-entry detach semantics remain; maps and fields whose sole job was translating between local ids are gone. -The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. A fresh start normally mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide; a coupled app may pre-mint and pass the exact fresh `sessionId`, while `resumeSessionId` supplies the exact combined identity to load and register. The two exact-id inputs are mutually exclusive. Stdio uses this narrow escape hatch so its config-created agent and UI share one opaque identity instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`. +The config-driven path keeps `agents[].id` as a stable configuration label, not a live routing identity. An ordinary fresh start mints the combined id `${label}-session-${randomUUID()}` so durable restarts do not collide. A coupled app may pre-mint and pass an exact `sessionId`: first use creates it, while an AgentLoop remount with an already-present persistence service resumes materialized history under that same identity. `resumeSessionId` instead requires an existing persisted identity. The two exact-id inputs are mutually exclusive. Stdio uses the resume-or-create form so its config-created agent and UI share one opaque identity across loop reloads instead of guessing from a prefix. Logs may use the stable label while all live and durable lookups use the one `SessionId`. `agent/created` and `agent/disposed` remain. They are paired publication lifecycle events, not identity aliases; any later consumer-free removal needs its own proposal after a fresh search. diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index a272dfe0b2..6ca85b3d16 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -42,4 +42,4 @@ Not touched: ## Consequences -A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event` and filters `assistant/chunk` (the `Agent` handle, if needed, is recovered from a session-id→agent map built from `agent/created`/`agent/disposed`, exactly as boundary consumers already do). No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. +A plugin can no longer observe token deltas from an `Agent`-first event. It subscribes to `session/event`, filters `assistant/chunk`, and looks up the corresponding live handle directly with `ctx.agents.get(session.id)` when needed. No production consumer needed the live `Agent` at chunk time; this is the same acceptable trade the boundary-mirror removal made. diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 246c9cdc27..2db1a6c079 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -14,7 +14,7 @@ The caller fiber and the AgentLoop provider are co-owners. `AgentFactory.createA Each agent and its session share one caller-chosen `SessionId`, assumed globally unique; accidental UUID collisions are outside the supported model. Two concurrent operations with the same id may both prepare, but the final `enter()` calls arbitrate publication and every loser rolls its private resources back. Each detach is bound to the exact entered object, so a stale disposer cannot remove a later same-id replacement. A detach requested during a synchronous creation notification waits for that dispatch to unwind, preserving created/disposed pairing. Teardown runs stop and drain (including outstanding idle-injection flushes) → detach agent → detach session → unwind scope; the id becomes reusable at detach even if private scope cleanup is still finishing. Ordinary non-vetoing `agent/*` notifications go through `agentEvents(ctx, agent)`, per-step assembly goes through `assembleContextFor(agent)`, and turn-end durability checkpoints go through `ctx.sessions.flush(session)`. -- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary; an app may instead supply an exact fresh `sessionId` when another coupled component must bind to it. `resumeSessionId` loads and registers the exact persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. +- `ctx.agentLoop.create(id: SessionId, options?: AgentOptions, meta?: { cwd?: string }): ReactLoopAgent` — synchronous no-setup create under the exact shared agent/session id, disposed with the calling fiber. Declarative config treats `agents[].id` as a stable label and normally mints `${label}-session-` before calling this boundary. An app may instead supply a stable exact `sessionId`: first use creates it, while a remount with persistence already present resumes its materialized history. `resumeSessionId` requires and loads an existing persisted id and is mutually exclusive with `sessionId`. This keeps default fresh restarts collision-free without retaining a second live routing identity. `AgentLoop` also implements the `AgentFactory` seam and registers itself via `ctx.agents.setFactory(this)`, so plugins create/resume agents through `ctx.agents` (the interface): @@ -33,7 +33,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo interface Config { agents: Array<{ id: string // required stable label; prefixes fresh combined ids - sessionId?: string // optional exact identity for a fresh session + sessionId?: string // optional exact resume-or-create identity model?: string resumeSessionId?: string // load this persisted session instead of creating one cwd?: string // optional workspace cwd for the fresh session @@ -41,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` and optional `sessionId` apply only to fresh config-created sessions; `resumeSessionId` keeps the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 0041b984d1..33e8c3c443 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -326,7 +326,7 @@ export interface Config { agents: (AgentOptions & { /** Stable config label used in logs and as the fresh combined-id prefix. */ id: string - /** Optional exact identity for a fresh session; absent lets the loop mint one from the label. */ + /** Optional stable identity; remounts resume its materialized history, while first use creates it fresh. */ sessionId?: SessionId /** Optional workspace for a fresh session. */ cwd?: string @@ -364,8 +364,17 @@ export class AgentLoop extends Service implements AgentFactory { ctx.systemPrompt.variable('cwd', context => context.agent?.session.header.cwd) for (const { id, sessionId, cwd, resumeSessionId, ...options } of config.agents) { + const meta = cwd === undefined ? {} : { cwd } if (resumeSessionId === undefined || resumeSessionId === '') { - this.create(sessionId ?? SessionId(`${id}-session-${randomUUID()}`), options, cwd === undefined ? {} : { cwd }) + const configuredId = sessionId ?? SessionId(`${id}-session-${randomUUID()}`) + const persistence = sessionId === undefined ? undefined : ctx.get('sessionPersistence') + if (persistence === undefined) { + this.create(configuredId, options, meta) + } else { + void this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { + ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`) + }) + } continue } if (sessionId !== undefined) { @@ -385,6 +394,22 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** Restore a materialized exact config identity on remount, or create it on first use. */ + private async restoreOrCreateConfigured( + ownerCtx: Context, + persistence: SessionPersistence, + sessionId: SessionId, + agentOptions: AgentOptions, + meta: Pick, + ): Promise { + const exists = (await persistence.list()).some(header => header.id === sessionId) + if (exists) { + await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) + return + } + this.create(sessionId, agentOptions, meta) + } + /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 543335e5fd..9382804527 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -55,6 +55,43 @@ describe('config-driven session id', () => { await conflicting.fiber.dispose() }) + it('restores a materialized exact id across an AgentLoop-only reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-reload-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + ctx.llm.registerAdapter(['mock'], new MockAdapter([textResponse('first'), textResponse('second')])) + const config = { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-reload'), model: 'mock' }] } + + const firstLoop = await ctx.plugin(AgentLoop, config) + let first: ReactLoopAgent | undefined + for (let i = 0; i < 50 && first === undefined; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + first = ctx.agents.get(SessionId('stdio-exact-reload')) as ReactLoopAgent | undefined + } + expect(first).toBeDefined() + first!.send([{ type: 'text', text: 'remember me' }], { source: { kind: 'user' } }) + await waitForIdle(ctx, first!) + await firstLoop.dispose() + + const secondLoop = await ctx.plugin(AgentLoop, config) + let second: ReactLoopAgent | undefined + for (let i = 0; i < 50 && second === undefined; i++) { + await new Promise(resolve => setTimeout(resolve, 5)) + second = ctx.agents.get(SessionId('stdio-exact-reload')) as ReactLoopAgent | undefined + } + expect(second).toBeDefined() + expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') + second!.send([{ type: 'text', text: 'continue' }], { source: { kind: 'user' } }) + await waitForIdle(ctx, second!) + await ctx.sessions.flush(second!.session) + const loaded = await ctx.sessionPersistence.load(SessionId('stdio-exact-reload')) + expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) + + await secondLoop.dispose() + await ctx.fiber.dispose() + }) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index b509e46781..15b1c78f09 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; the UI's `main` text is only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. ## The bin From 656ae95263cfd9f832bdeefbf9da6a91b8ce72bb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:28:04 +0800 Subject: [PATCH 059/163] test: await exact stdio startup condition --- packages/ui/stdio-agent/tests/stdio-agent.spec.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index 9a7bf10bad..a14848a549 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -114,9 +114,8 @@ describe('dsh-stdio-agent app', () => { const ctx = new Context() // No persona: covers the omitted-persona forwarding branch too. stdioAgent.apply(ctx, { model: 'mock', skills: await isolatedSkillsConfig() }) - await new Promise(resolve => setTimeout(resolve, 80)) - expect(ctx.get('sessionPersistence')).toBeDefined() - expect(ctx.get('agents')?.list()).toHaveLength(1) + await expect.poll(() => ctx.get('sessionPersistence')).toBeDefined() + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) await ctx.fiber.dispose() }) From cf21e252fa8fd7e66c4b0c6a5acface4821d4712 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:32:02 +0800 Subject: [PATCH 060/163] fix: recognize provider-owned local subagents --- packages/subagent/subagent/README.md | 2 ++ packages/subagent/subagent/src/types.ts | 6 +++++- packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 22 ++++++++++++++++------ packages/ui/jsonrpc/tests/server.spec.ts | 5 ++++- 5 files changed, 28 insertions(+), 9 deletions(-) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 413bbbdafc..016d363d78 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -50,6 +50,8 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child. + The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 827c2db7a7..37e0b28323 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -147,7 +147,11 @@ export interface SubagentResult { * presence of the method IS the capability — narrow before calling. */ export interface SubagentRun { - /** Parent-scoped run id. Local runs use the published child session id; remote providers mint an id unique in the parent namespace. */ + /** + * Parent-scoped run id. A local run publishes a child session whose + * `parentSession` records `request.parent`; a remote provider mints an id + * unique in the parent namespace. + */ readonly id: SessionId /** * Resolves with the child's terminal {@link SubagentResult} when the run diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index df612ac263..4ef9e86ffc 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server verifies that the live child is owned by the exact delegating parent, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. +`inject: ['agents']` — the server creates one agent per SDK `sessionId` (get-or-create on `session/prompt`). A local subagent's shared agent/session id supplies `subagent.finished.childSessionId` directly; the server recognizes a live child through either exact delegating-parent runtime ownership or matching durable `parentSession` lineage, then counts local starts by provider/id and that parent carrier because a continuation may reuse one child and the child may be disposed before a later `subagent/end`. The paired event carrier preserves parent correlation even when reused ids settle out of order. Runs from remote providers are not reported even when their parent-scoped run id collides with an unrelated local agent. The LLM seam is read opportunistically via `ctx.get('llm')` (not injected): when `initialize.model` has no registered adapter, the plugin mounts `dsh-llm-deepseek` for it (credentials from `$DEEPSEEK_API_KEY` / `$DEEPSEEK_BASE_URL`) — a config-registered adapter for the model wins. Everything else — persistence, the tool stacks, the adapter set — comes from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index d78f24b5ab..723f2eb4eb 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -66,6 +66,15 @@ function subagentParentOf(carrier: Scoped): Agent { return carrierKeyOf(carrier) as Agent } +/** Whether the live id names a local child related to this exact delegating parent. */ +function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean { + const child = ctx.agents.get(id) + return child !== undefined && ( + ctx.agents.isOwnedBy(id, parent) + || child.session.header.parentSession === parent.session.id + ) +} + /** * The SDK server over a booted harness context. Constructing it subscribes to * session and subagent lifecycle events, forwarding durable session @@ -104,13 +113,14 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // In-process providers publish the child before start. Count those starts by - // the exact delegating-parent carrier so later completions remain local after - // child disposal and reused ids need no settlement-order assumption. + // In-process providers publish the child before start. Count starts related + // by exact runtime ownership or durable parent lineage so provider-owned + // roots remain local, completions survive child disposal, and reused ids + // need no settlement-order assumption. const localRuns = this.localRuns this.disposers.push(ctx.on('subagent/start', function (this: Scoped, info: SubagentRunInfo) { const parent = subagentParentOf(this) - if (!ctx.agents.isOwnedBy(info.id, parent)) return + if (!isLocalChild(ctx, info.id, parent)) return const providerRuns = localRuns.get(info.provider) ?? new Map>() const parentRuns = providerRuns.get(info.id) ?? new Map() parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1) @@ -130,9 +140,9 @@ export class HarnessSdkServer { } // This protocol reports LOCAL child sessions. A lineage-bearing child // has the session/created-driven start notification above. A remote run - // has neither a cached owned start nor a live child owned by this exact + // has neither a cached local start nor a live child related to this // parent; an unrelated local agent with the same id never makes it local. - if (pendingCount === undefined && !ctx.agents.isOwnedBy(info.id, parent)) return + if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index c97bd1a6e1..3861a6729d 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -277,11 +277,14 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const handle = await parentHandle.agent.ctx.agents.create({ + // A custom in-process provider may own its child at the provider/root + // scope while preserving durable parent lineage. + const handle = await ctx.agents.create({ sessionId: SessionId('child-session'), meta: { cwd: storageDir, parentSession: SessionId('main') }, agentOptions: { model: 'deepseek' }, }) + expect(ctx.agents.roots()).toContain(handle.agent) const parentlessHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('parentless-child-session'), meta: { cwd: storageDir }, From 949ee03377719aea96a26fd96ba0bc284cf740ec Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:38:53 +0800 Subject: [PATCH 061/163] test: cover exact session restore failure --- .../tests/config-session-id.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 9382804527..dcde82efe2 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -92,6 +92,27 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) + it('contains an exact-id persistence lookup failure', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const failure = new Error('persistence index failed') + vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-failure'), model: 'mock' }], + }) + + await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( + 'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed', + )) + expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() + warn.mockRestore() + await ctx.fiber.dispose() + }) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) From 46b8bce03acebd579cdcb4daf91c02e51eddf78e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:47:47 +0800 Subject: [PATCH 062/163] fix: join exact session startup on teardown --- docs/config-catalog.md | 2 +- docs/cordis-catalog/services.md | 2 +- packages/core/agent-loop/src/index.ts | 19 +++++++++++--- .../tests/config-session-id.spec.ts | 25 +++++++++++++++++++ 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 024d3b766b..d22bcca6f7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -131,7 +131,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:324`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:333`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 6b456ef942..e79422b94f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 33e8c3c443..d2d8648595 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -45,6 +45,7 @@ const INACTIVE_STATES: ReadonlySet = new Set([ class FactoryOwnership { private accepting = true private transactions = new Set() + private startupTasks = new Set>() constructor(private readonly fiber: Context['fiber']) {} @@ -57,12 +58,20 @@ class FactoryOwnership { return () => { this.transactions.delete(transaction) } } + /** Join config startup work that begins before an agent transaction exists. */ + trackStartup(task: Promise): void { + this.startupTasks.add(task) + const forget = () => { this.startupTasks.delete(task) } + void task.then(forget, forget) + } + async dispose(): Promise { this.accepting = false const reason = new Error('agent loop is not active') - await Promise.all( - [...this.transactions].map(transaction => transaction.disposeForFactory(reason)), - ) + await Promise.all([ + ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)), + ...this.startupTasks, + ]) } } @@ -371,9 +380,10 @@ export class AgentLoop extends Service implements AgentFactory { if (persistence === undefined) { this.create(configuredId, options, meta) } else { - void this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { + const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`) }) + this.ownership.trackStartup(startup) } continue } @@ -403,6 +413,7 @@ export class AgentLoop extends Service implements AgentFactory { meta: Pick, ): Promise { const exists = (await persistence.list()).some(header => header.id === sessionId) + if (!this.ownership.isActive()) return if (exists) { await this.resumeWith(ownerCtx, persistence, { resumeSessionId: sessionId, agentOptions }) return diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index dcde82efe2..c1a04fadd0 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -113,6 +113,31 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) + it('joins an exact-id persistence lookup before AgentLoop disposal completes', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const listing = Promise.withResolvers>>() + vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + const loop = await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], + }) + let disposed = false + const disposal = loop.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + listing.resolve([]) + await disposal + expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + await ctx.fiber.dispose() + }) + it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() await ctx.plugin(LlmService) From 288023dac182c407c296571f3730ff3a89a0696a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 10:51:36 +0800 Subject: [PATCH 063/163] docs: define local subagent lineage --- docs/core-data-structures/subagent.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 883c33020a..7b57789a7c 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -76,6 +76,8 @@ interface SubagentRun { } ``` +A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope; `parentSession` is the durable transport-neutral lineage. A remote provider instead returns a parent-scoped lifecycle id and does not publish a local child. + ## The provider seam: `SubagentProvider` One transport for running a child agent. Implementations register under a unique name via `SubagentService.registerProvider`; multiple coexist in one context. The service validates every requested start-time capability before calling `start`, so an implementation may assume e.g. `request.maxDepth` is honorable when present. `inheritsParentContext` is a DESCRIPTIVE fact beside the capabilities (nothing validates against it): whether a child sees the parent conversation (`fork`: true, `spawn`/`acp`: false) — the model-facing consumer derives truthful tool wording from it. It describes conversation history only, not tool registrations, injected services, or authority inheritance. From e8f1bd41f5bd96c529c59ed524cfdeef7d3d1ce3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:02:18 +0800 Subject: [PATCH 064/163] fix: buffer stdio until exact session starts --- docs/event-producer-consumer.md | 2 +- packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/src/stdio-chat.ts | 65 ++++++++++++----- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 72 +++++++++++++++---- 4 files changed, 108 insertions(+), 33 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 80389608cc..1439873f69 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -15,7 +15,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:348`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:473`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:525`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:369`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:333`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:540`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:558`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 15b1c78f09..bbf175d92d 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 93c8f9b4f1..17d396c042 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -3,9 +3,10 @@ * `steer()`, and renders the durable transcript to stdout. A UI is "just a * plugin" — it consumes the `session/event` feed (the assistant token stream, * turn/step boundaries, tool activity, todos) plus a few `agent/*` control - * events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` - * service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle - * exit handling, configured via {@link Config}. + * events (`agent/status`, `agent/created`/`agent/disposed`, + * `agent/session-start`) and the `agents` service. Dimmed chain-of-thought + * rendering plus robust piped-stdin EOF→idle exit handling, configured via + * {@link Config}. * * An internal module of the stdio app, not a package of its own: the app's * front-door cluster always includes this UI, and nothing else composes it. @@ -105,12 +106,6 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const matchesConfiguredIdentity = (agent: Agent): boolean => agent.id === config.sessionId && ctx.agents.roots().includes(agent) let target: Agent | undefined = ctx.agents.roots().find(agent => agent.id === config.sessionId) - ctx.on('agent/created', (agent) => { - if (matchesConfiguredIdentity(agent)) target = agent - }) - ctx.on('agent/disposed', (agent) => { - if (target === agent) target = undefined - }) // Transcript rendering off the durable `session/event` feed — the assistant // token stream, turn/step boundaries, tool activity, and todos all come from @@ -158,7 +153,6 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt }) ctx.effect(() => { - const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) // Piped-input exit, once stdin reaches EOF: // - If no line ever submitted work (empty stdin, blank-only lines), exit // immediately — no turn will ever start, so there is nothing to wait @@ -176,6 +170,36 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt let exitTimer: ReturnType | undefined let activeQuestion: PendingQuestion | undefined const questionQueue: PendingQuestion[] = [] + const queuedInput: string[] = [] + let targetReady = target !== undefined + let hadReadyTarget = targetReady + + const submit = (agent: Agent, text: string): void => { + submittedWork = true + if (agent.status === 'running') { + agent.steer([{ type: 'text', text }]) + } else { + agent.send([{ type: 'text', text }]) + } + } + + const disposeCreatedListener = ctx.on('agent/created', (agent) => { + if (!matchesConfiguredIdentity(agent)) return + target = agent + targetReady = false + }) + const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => { + if (agent !== target) return + targetReady = true + hadReadyTarget = true + for (const text of queuedInput.splice(0)) submit(agent, text) + }) + const disposeDisposedListener = ctx.on('agent/disposed', (agent) => { + if (target !== agent) return + target = undefined + targetReady = false + }) + const reader = createInterface({ input, output, terminal: isTTYPair(input, output) }) const maybeExit = (): void => { if (disposed || !stdinClosed) return @@ -351,16 +375,20 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const text = line.trim() if (!text) return const agent = target - if (!agent) { + if (agent === undefined || !targetReady) { + // Initial exact-id restoration is asynchronous. Preserve input until + // session-start, the first supported point for queueing agent work. + // After a previously ready target disappears, a line in the HMR gap + // still fails loud unless its exact replacement is already publishing. + if (!hadReadyTarget || agent !== undefined) { + submittedWork = true + queuedInput.push(text) + return + } ctx.logger.error('ui-stdio: main agent is not running') return } - submittedWork = true - if (agent.status === 'running') { - agent.steer([{ type: 'text', text }]) - } else { - agent.send([{ type: 'text', text }]) - } + submit(agent, text) }) reader.on('close', () => { // Fires for BOTH stdin EOF and plugin disposal (reader.close() below); @@ -376,6 +404,9 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt disposePendingQuestions() disposeUserInteractionProvider() disposeStatusListener() + disposeCreatedListener() + disposeSessionStartListener() + disposeDisposedListener() reader.close() } }, 'ui-stdio') diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index f1ca914525..943c48ef08 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -64,6 +64,13 @@ function makeAgent(id: string, status: AgentStatus = 'idle'): Agent & { } as never } +/** Register a fake configured agent and cross the supported startup-work boundary. */ +function registerReady(ctx: Context, agent: Agent, source: 'startup' | 'resume' = 'startup'): () => void { + const dispose = ctx.agents.register(agent) + ctx.emit('agent/session-start', agent, source) + return dispose +} + /** A session stub whose `header.id` matches an agent's, for `session/event` emits. */ function makeSession(id: string): Session { return { id, header: { id } } as Session @@ -198,15 +205,21 @@ describe('createStdioChat rendering', () => { expect(out.text()).toContain('[main turn 5] ') }) - it('accepts a lineage-bearing configured agent created after the UI installs', async () => { + it('buffers input for a lineage-bearing configured agent until its session starts', async () => { const { ctx, input } = await setup({ welcome: 'hi there', sessionId: 'resumed' }) + input.feed('continue') + await new Promise(resolve => setImmediate(resolve)) + const unrelated = makeAgent('unrelated') ctx.agents.register(unrelated) + ctx.emit('agent/session-start', unrelated, 'startup') const resumed = makeAgent('resumed') ;(resumed.session.header as { parentSession?: string }).parentSession = 'persisted-parent' ctx.agents.register(resumed) + await new Promise(resolve => setImmediate(resolve)) + expect(resumed.sent).toEqual([]) - input.feed('continue') + ctx.emit('agent/session-start', resumed, 'resume') await new Promise(resolve => setImmediate(resolve)) expect(unrelated.sent).toEqual([]) @@ -256,9 +269,11 @@ describe('createStdioChat rendering', () => { disposeOld() const replacement = makeAgent('main-session-fixed') ctx.agents.register(replacement) - input.feed('after hmr') await new Promise(resolve => setImmediate(resolve)) + expect(replacement.sent).toEqual([]) + ctx.emit('agent/session-start', replacement, 'resume') + await new Promise(resolve => setImmediate(resolve)) expect(prefixCollision.sent).toEqual([]) expect(replacement.sent).toEqual([[{ type: 'text', text: 'after hmr' }]]) @@ -269,7 +284,7 @@ describe('createStdioChat rendering', () => { const unrelated = makeAgent('unrelated') ctx.agents.register(unrelated) const configured = makeAgent('main') - const disposeConfigured = ctx.agents.register(configured) + const disposeConfigured = registerReady(ctx, configured) const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) disposeConfigured() @@ -693,7 +708,7 @@ describe('createStdioChat input', () => { it('sends a typed line to an idle agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('do a thing') await new Promise(r => setImmediate(r)) expect(agent.sent).toEqual([[{ type: 'text', text: 'do a thing' }]]) @@ -703,7 +718,7 @@ describe('createStdioChat input', () => { it('steers a typed line into a running agent', async () => { const { ctx, input } = await setup() const agent = makeAgent('main', 'running') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('steer me') await new Promise(r => setImmediate(r)) expect(agent.steered).toEqual([[{ type: 'text', text: 'steer me' }]]) @@ -719,18 +734,26 @@ describe('createStdioChat input', () => { expect(agent.sent).toEqual([]) }) - it('logs and drops a line when the target agent is not running', async () => { + it('buffers a line until the initial target session starts', async () => { const { ctx, input } = await setup() const spy = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) input.feed('nobody home') await new Promise(r => setImmediate(r)) - expect(spy).toHaveBeenCalledWith('ui-stdio: main agent is not running') + expect(spy).not.toHaveBeenCalled() + + const agent = makeAgent('main') + ctx.agents.register(agent) + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([]) + ctx.emit('agent/session-start', agent, 'startup') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) }) it('drives the exact app-configured resumed session', async () => { const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) const agent = makeAgent('worker') - ctx.agents.register(agent) + registerReady(ctx, agent, 'resume') input.feed('hi') await new Promise(r => setImmediate(r)) expect(agent.sent).toHaveLength(1) @@ -749,7 +772,7 @@ describe('createStdioChat EOF exit', () => { it('waits for the agent to settle idle after running before exiting', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) input.finish() @@ -764,10 +787,31 @@ describe('createStdioChat EOF exit', () => { expect(exit).toHaveBeenCalledWith(0) }) + it('keeps piped EOF pending until buffered startup input runs', async () => { + const { ctx, input, exit } = await setup() + input.feed('work') + input.finish() + await flushExit() + expect(exit).not.toHaveBeenCalled() + + const agent = makeAgent('main', 'idle') + ctx.agents.register(agent) + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([]) + ctx.emit('agent/session-start', agent, 'startup') + await new Promise(r => setImmediate(r)) + expect(agent.sent).toEqual([[{ type: 'text', text: 'work' }]]) + ctx.emit('agent/status', agent, 'running') + ;(agent as { status: AgentStatus }).status = 'idle' + ctx.emit('agent/status', agent, 'idle') + await flushExit() + expect(exit).toHaveBeenCalledWith(0) + }) + it('schedules the exit only once when idle fires repeatedly', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'running') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) ctx.emit('agent/status', agent, 'running') // sawRunning = true @@ -785,7 +829,7 @@ describe('createStdioChat EOF exit', () => { it('does not exit on an idle transition for a different agent', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) input.finish() @@ -799,7 +843,7 @@ describe('createStdioChat EOF exit', () => { it('does not exit while a turn is still running at EOF', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) ctx.emit('agent/status', agent, 'running') @@ -848,7 +892,7 @@ describe('createStdioChat disposal (HMR safety)', () => { it('removes the agent/status listener on dispose', async () => { const { ctx, fiber, input, exit } = await setup() const agent = makeAgent('main', 'idle') - ctx.agents.register(agent) + registerReady(ctx, agent) input.feed('work') await new Promise(r => setImmediate(r)) await fiber.dispose() From c1fb97c63a3ac7c3a6d4066ccc7615d292c0dfa7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:23:38 +0800 Subject: [PATCH 065/163] fix: surface config startup failures --- docs/architecture.md | 7 ++- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 12 +++++ docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 1 + docs/module-graph.md | 3 +- .../cordis/tool-cordis/src/api-catalog.ts | 6 +++ packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/index.ts | 30 +++++++++++- .../tests/config-session-id.spec.ts | 10 ++++ packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/package.json | 2 + packages/ui/stdio-agent/src/index.ts | 23 ++++----- packages/ui/stdio-agent/src/stdio-chat.ts | 20 ++++++++ .../ui/stdio-agent/tests/stdio-chat.spec.ts | 47 ++++++++++++++++++- pnpm-lock.yaml | 3 ++ 16 files changed, 151 insertions(+), 21 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a1f0cae9a8..06a3a86fb4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -51,14 +51,17 @@ Waterfall events behave like around-middleware: a listener delegates by calling ## Default Loop Lifecycle -The shipped loop drains work, assembles requests, streams model answers, executes tools, applies continuation policy, and checkpoints state. Every pause is a service call or event available to plugins. +Default loop processing remains exposed through plugin-visible services and events. A **session** is one agent's append-only event log. A **turn** drains one queued batch and runs until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. +Declarative startup chooses one agent/session identity. No id mints `-session-`; exact `sessionId` resumes when stored and otherwise creates; `resumeSessionId` requires stored history. Failures emit `agent-loop/config-start-failed(sessionId, error)`, letting front doors reject buffered work. + ### Turn Flow ```text -prepare private session + agent.ctx -> await unpublished setup +choose declarative identity and fresh/resume path + -> prepare private session + agent.ctx -> await unpublished setup -> enter session + agent -> session/created -> agent/created -> enable driving -> agent/session-start(source) -> start driver forever: diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d22bcca6f7..839e7967e6 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -131,7 +131,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:333`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:344`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index a5e018079a..3c56882494 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -175,6 +175,18 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/types.ts) +## `agent-loop/*` + +### `agent-loop/config-start-failed` — emit + +A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. + +```ts cordis-catalog +'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void +``` + +Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts) + ## `approval/*` ### `approval/request` — waterfall diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e79422b94f..63375a46bf 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:359`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 1439873f69..0ab96fca31 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,6 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:339`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 8ac71b11cc..cc4cd86c47 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -316,6 +316,7 @@ flowchart TD pkg_acp_agent --> pkg_user_interaction pkg_stdio_agent --> pkg_agent pkg_stdio_agent --> pkg_agent_core + pkg_stdio_agent --> pkg_agent_loop pkg_stdio_agent --> pkg_app_boot pkg_stdio_agent --> pkg_llm pkg_stdio_agent --> pkg_session @@ -394,4 +395,4 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-agent`](../packages/ui/acp-agent) | `ui` | [`acp`](../packages/ui/acp), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`stdio-agent`](../packages/ui/stdio-agent) | `ui` | [`agent`](../packages/core/agent), [`agent-core`](../packages/core/agent-core), [`agent-loop`](../packages/core/agent-loop), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index ff27f51067..a9d1d5acaa 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -237,6 +237,12 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ /** Every harness event, sorted by name. */ export const EVENT_API: readonly EventApiEntry[] = [ + { + name: 'agent-loop/config-start-failed', + mode: 'emit', + signature: '\'agent-loop/config-start-failed\'(sessionId: SessionId, error: unknown): void', + summary: 'A declarative agent entry failed before it could publish a live agent.', + }, { name: 'agent/created', mode: 'emit', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2db1a6c079..2593da7c47 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -41,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. A declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index d2d8648595..62c5d94b06 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -327,6 +327,17 @@ declare module 'cordis' { interface Context { agentLoop: AgentLoop } + interface Events { + /** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ + 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void + } } /** Plugin configuration for declarative startup agents. */ @@ -381,7 +392,7 @@ export class AgentLoop extends Service implements AgentFactory { this.create(configuredId, options, meta) } else { const startup = this.restoreOrCreateConfigured(ctx, persistence, configuredId, options, meta).catch((error: unknown) => { - ctx.logger.warn(`agent "${id}": config-driven restore of "${configuredId}" failed: ${String(error)}`) + this.reportConfiguredStartupFailure(id, 'restore', configuredId, error) }) this.ownership.trackStartup(startup) } @@ -396,7 +407,7 @@ export class AgentLoop extends Service implements AgentFactory { resumeSessionId, agentOptions: options, }).catch((error: unknown) => { - ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) + this.reportConfiguredStartupFailure(id, 'resume', resumeSessionId, error) }) }) return fiber.dispose @@ -404,6 +415,21 @@ export class AgentLoop extends Service implements AgentFactory { } } + /** Report a contained declarative-start failure to identity-bound consumers. */ + private reportConfiguredStartupFailure( + configId: string, + action: 'restore' | 'resume', + sessionId: SessionId, + error: unknown, + ): void { + this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${String(error)}`) + try { + this.ctx.emit('agent-loop/config-start-failed', sessionId, error) + } catch (listenerError) { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${String(listenerError)}`) + } + } + /** Restore a materialized exact config identity on remount, or create it on first use. */ private async restoreOrCreateConfigured( ownerCtx: Context, diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index c1a04fadd0..ad9ec6b792 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -98,6 +98,12 @@ describe('config-driven session id', () => { const ctx = await makeCoreContext() await ctx.plugin(SessionPersistenceJsonl, { root }) const failure = new Error('persistence index failed') + const listenerFailure = new Error('failure observer failed') + const failures: { sessionId: SessionId; error: unknown }[] = [] + ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + failures.push({ sessionId, error }) + }) + ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) @@ -108,6 +114,10 @@ describe('config-driven session id', () => { await expect.poll(() => warn).toHaveBeenCalledWith(expect.stringContaining( 'config-driven restore of "stdio-exact-failure" failed: Error: persistence index failed', )) + expect(failures).toEqual([{ sessionId: SessionId('stdio-exact-failure'), error: failure }]) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener threw: Error: failure observer failed', + ) expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() warn.mockRestore() await ctx.fiber.dispose() diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index bbf175d92d..a91bef85a0 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -32,7 +32,7 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `welcome` | `ready.` | the stdin-chat banner | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | -Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. +Fresh stdio sessions use the process launch directory as `session.header.cwd` and mint one combined `main-session-` agent/session id, so durable restarts cannot collide. The app passes that exact opaque id to both its config-created agent and UI; an AgentLoop-only reload resumes materialized history under that id, while the UI's `main` text remains only a display label and never selects another registry root by prefix or insertion order. Readline buffers nonblank startup input for that identity until `agent/session-start`, so piped stdin cannot outrun asynchronous exact-id restoration or let EOF discard the queued prompt; `agent-loop/config-start-failed` instead drains and reports buffered input so a missing or corrupt persisted session cannot hang EOF. A resumed run binds both components to the exact `resumeSessionId` and keeps the cwd stored in the persisted session header. ## The bin diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index fcd432a9fd..60a9529d47 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -35,6 +35,7 @@ "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-agent-loop": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -51,6 +52,7 @@ "@deepseek-ai/dsh-app-boot": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 03f3d49555..1278ded88a 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -102,16 +102,23 @@ export const Config: z = z.object({ }) /** - * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-core bundle pre-creating one agent from this app's - * `model`/`resumeSessionId` with the deployment `persona`, then the JSONL - * backend, then the readline UI rendering that object as `main`. The `hmr` dev-reload plugin is - * a leaf concern (see the module doc), so it is not mounted here. + * Compose the spine with the stdio front door. Console logging, persistence, + * and user interaction mount first; the readline UI then waits on the agent + * registry and subscribes to config-start failures before agent-core can start + * the configured identity. The ask-user tool waits on the completed spine. + * The `hmr` dev-reload plugin is a leaf concern (see the module doc), so it is + * not mounted here. */ export function apply(ctx: Context, config: Config): void { const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) ctx.plugin(ConsoleExporter) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(UserInteractionService) + ctx.plugin(uiStdio, { + welcome: config.welcome ?? 'ready.', + sessionId, + }) ctx.plugin(agentCore, { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, @@ -124,11 +131,5 @@ export function apply(ctx: Context, config: Config): void { }], ...config.skills !== undefined ? { skills: config.skills } : {}, }) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) - ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { - welcome: config.welcome ?? 'ready.', - sessionId, - }) } diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 17d396c042..39d59dfdc0 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -21,6 +21,7 @@ import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-loop' import { UserInteractionError, type AskUserQuestionAnswer, @@ -173,6 +174,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const queuedInput: string[] = [] let targetReady = target !== undefined let hadReadyTarget = targetReady + let failedStartup: { error: unknown } | undefined const submit = (agent: Agent, text: string): void => { submittedWork = true @@ -187,6 +189,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt if (!matchesConfiguredIdentity(agent)) return target = agent targetReady = false + failedStartup = undefined }) const disposeSessionStartListener = ctx.on('agent/session-start', (agent) => { if (agent !== target) return @@ -220,6 +223,18 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt exitTimer = setTimeout(() => { exit(0) }, 200) } + const disposeStartupFailedListener = ctx.on('agent-loop/config-start-failed', (sessionId, error) => { + if (sessionId !== config.sessionId || targetReady) return + failedStartup = { error } + const dropped = queuedInput.length + queuedInput.length = 0 + submittedWork = sawRunning + if (dropped > 0) { + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${String(error)}`) + } + maybeExit() + }) + const disposeStatusListener = ctx.on('agent/status', (subject, status) => { if (subject !== target) return if (status === 'running') sawRunning = true @@ -374,6 +389,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt } const text = line.trim() if (!text) return + if (failedStartup !== undefined) { + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${String(failedStartup.error)}`) + return + } const agent = target if (agent === undefined || !targetReady) { // Initial exact-id restoration is asynchronous. Preserve input until @@ -407,6 +426,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt disposeCreatedListener() disposeSessionStartListener() disposeDisposedListener() + disposeStartupFailedListener() reader.close() } }, 'ui-stdio') diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 943c48ef08..11e64ac19c 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -4,7 +4,7 @@ import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts' @@ -750,6 +750,32 @@ describe('createStdioChat input', () => { expect(agent.sent).toEqual([[{ type: 'text', text: 'nobody home' }]]) }) + it('drops later input after the configured startup fails', async () => { + const { ctx, input } = await setup() + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const failure = new Error('persisted session is corrupt') + ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure) + + input.feed('cannot run') + await new Promise(r => setImmediate(r)) + + expect(error).toHaveBeenCalledWith( + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: persisted session is corrupt', + ) + }) + + it('ignores a stale config-start failure after the exact target is ready', async () => { + const { ctx, input } = await setup() + const agent = makeAgent('main') + registerReady(ctx, agent) + ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('stale')) + + input.feed('still live') + await new Promise(r => setImmediate(r)) + + expect(agent.sent).toEqual([[{ type: 'text', text: 'still live' }]]) + }) + it('drives the exact app-configured resumed session', async () => { const { ctx, input } = await setup({ welcome: 'w', sessionId: 'worker' }) const agent = makeAgent('worker') @@ -808,6 +834,25 @@ describe('createStdioChat EOF exit', () => { expect(exit).toHaveBeenCalledWith(0) }) + it('drains buffered piped input and exits when configured startup fails', async () => { + const { ctx, input, exit } = await setup() + const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + input.feed('work') + input.finish() + await new Promise(r => setImmediate(r)) + ctx.emit('agent-loop/config-start-failed', SessionId('other'), new Error('unrelated')) + await flushExit() + expect(exit).not.toHaveBeenCalled() + + ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('missing persisted session')) + await flushExit() + + expect(error).toHaveBeenCalledWith( + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: missing persisted session', + ) + expect(exit).toHaveBeenCalledWith(0) + }) + it('schedules the exit only once when idle fires repeatedly', async () => { const { ctx, input, exit } = await setup() const agent = makeAgent('main', 'running') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6214564eae..7a8fe5a6ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1328,6 +1328,9 @@ importers: '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../app-boot From 7d6ef0a47260a096a7594e54c797fde9e1ba8ca0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:33:36 +0800 Subject: [PATCH 066/163] fix: contain config failure observers --- docs/event-producer-consumer.md | 2 +- packages/core/agent-loop/src/index.ts | 14 ++++++++++---- .../agent-loop/tests/config-session-id.spec.ts | 7 ++++++- scripts/gen-doc-graphs.ts | 3 +++ 4 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 0ab96fca31..de89a69f18 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:339`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:339`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 62c5d94b06..5a7484a378 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -423,10 +423,16 @@ export class AgentLoop extends Service implements AgentFactory { error: unknown, ): void { this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${String(error)}`) - try { - this.ctx.emit('agent-loop/config-start-failed', sessionId, error) - } catch (listenerError) { - this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${String(listenerError)}`) + const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] + for (const callback of this.ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(...args) + void Promise.resolve(returned).catch((listenerError: unknown) => { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${String(listenerError)}`) + }) + } catch (listenerError: unknown) { + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${String(listenerError)}`) + } } } diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index ad9ec6b792..a7292b2284 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -99,11 +99,13 @@ describe('config-driven session id', () => { await ctx.plugin(SessionPersistenceJsonl, { root }) const failure = new Error('persistence index failed') const listenerFailure = new Error('failure observer failed') + const asyncListenerFailure = new Error('async failure observer failed') const failures: { sessionId: SessionId; error: unknown }[] = [] + ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) + ctx.on('agent-loop/config-start-failed', () => Promise.reject(asyncListenerFailure) as never) ctx.on('agent-loop/config-start-failed', (sessionId, error) => { failures.push({ sessionId, error }) }) - ctx.on('agent-loop/config-start-failed', () => { throw listenerFailure }) vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(failure) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) @@ -118,6 +120,9 @@ describe('config-driven session id', () => { expect(warn).toHaveBeenCalledWith( 'agent "main": config-start-failed listener threw: Error: failure observer failed', ) + await expect.poll(() => warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener rejected: Error: async failure observer failed', + ) expect(ctx.agents.get(SessionId('stdio-exact-failure'))).toBeUndefined() warn.mockRestore() await ctx.fiber.dispose() diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5144e388a2..fa7daed85e 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -245,6 +245,9 @@ const DYNAMIC_EVENT_DISPATCHERS: Array<{ event: string; pkg: string; method: str // Registry disposal reuses the stable carrier captured before entry commit // and contains each listener directly rather than rebuilding via agentEvents. { event: 'agent/disposed', pkg: 'agent', method: 'events.dispatch' }, + // Config startup failures have no live Agent carrier; AgentLoop resolves the + // callbacks directly to contain each synchronous throw and async rejection. + { event: 'agent-loop/config-start-failed', pkg: 'agent-loop', method: 'events.dispatch' }, { event: 'session/created', pkg: 'session', method: 'events.dispatch' }, // Session event callbacks are likewise resolved before the log push, then // invoked individually after commit so observer failures are contained. From a25c0f084f035f94a11292b17ec9a31e93349c2e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:25:30 +0800 Subject: [PATCH 067/163] fix: totalize configured startup failures --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/core/agent-loop/src/index.ts | 15 ++++++-- .../tests/config-session-id.spec.ts | 36 +++++++++++++++++++ 6 files changed, 52 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index d7a973fd81..484ba1c632 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -131,7 +131,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:344`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 088e60450d..e44df3c3b2 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -185,7 +185,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` -Source: [`packages/core/agent-loop/src/index.ts:339`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8c00bd45ef..699c73fe2c 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:359`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:368`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 825f54b077..78a30bd4e1 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:339`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:348`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 5a7484a378..3c09ef2e7f 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -41,6 +41,15 @@ const INACTIVE_STATES: ReadonlySet = new Set([ FiberState.FAILED, ]) +/** Render an arbitrary thrown value without letting coercion escape containment. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + /** Factory-level ownership of every preparing or live transaction. */ class FactoryOwnership { private accepting = true @@ -422,16 +431,16 @@ export class AgentLoop extends Service implements AgentFactory { sessionId: SessionId, error: unknown, ): void { - this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${String(error)}`) + this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`) const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] for (const callback of this.ctx.events.dispatch('emit', args)) { try { const returned: unknown = callback(...args) void Promise.resolve(returned).catch((listenerError: unknown) => { - this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${String(listenerError)}`) + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener rejected: ${renderThrown(listenerError)}`) }) } catch (listenerError: unknown) { - this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${String(listenerError)}`) + this.ctx.logger.warn(`agent "${configId}": config-start-failed listener threw: ${renderThrown(listenerError)}`) } } } diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index a7292b2284..b35dc71739 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -128,6 +128,42 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) + it('contains startup and observer failures whose string coercion throws', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-unrenderable-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const unrenderable = { + [Symbol.toPrimitive](): never { + throw new Error('coercion escaped') + }, + } + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', () => { throw unrenderable }) + // Deliberately violate the normal Error-only rejection rule to exercise the unknown boundary. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + ctx.on('agent-loop/config-start-failed', () => Promise.reject(unrenderable) as never) + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + vi.spyOn(ctx.sessionPersistence, 'list').mockRejectedValue(unrenderable) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-unrenderable'), model: 'mock' }], + }) + + await expect.poll(() => failures).toEqual([unrenderable]) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-driven restore of "stdio-exact-unrenderable" failed: ', + ) + expect(warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener threw: ', + ) + await expect.poll(() => warn).toHaveBeenCalledWith( + 'agent "main": config-start-failed listener rejected: ', + ) + await ctx.fiber.dispose() + }) + it('joins an exact-id persistence lookup before AgentLoop disposal completes', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) dirs.push(root) From 287041e39e93b368646f0fe8298b65feab692acc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:35:25 +0800 Subject: [PATCH 068/163] fix: preserve malformed snapshot fixtures --- packages/support/acp-snapshot/src/suite.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index 4d87093c8a..906521c42d 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -486,8 +486,10 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { const entries = await readdir(dir, { withFileTypes: true }) await Promise.all(entries .filter(entry => entry.isFile() - && entry.name.startsWith('session.') - && entry.name.endsWith('.jsonl') + // Only valid numbered children are record-owned stale output. + // Malformed session-like names stay for the inventory guard to + // reject instead of being silently deleted during mutation. + && /^session\.[1-9]\d*\.jsonl$/.test(entry.name) && !outputNames.has(entry.name)) .map(entry => rm(join(dir, entry.name)))) fixtureFiles = outputFixtureFiles From 2027c70a17661fe2b8b5aab1685cd443ac2c3b56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:38:32 +0800 Subject: [PATCH 069/163] fix: drain failed ACP launches --- packages/support/acp-snapshot/src/launcher.ts | 4 +++- packages/support/acp-snapshot/tests/harness.spec.ts | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 3f9a21d596..30ca0b42b8 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -178,13 +178,14 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.all([stdioClosed, client.closed]).then(async () => { + const drained = Promise.allSettled([stdioClosed, client.closed]).then(async ([, clientResult]) => { // The ACP SDK's readable loop dispatches client callbacks without awaiting // them. Once `closed` settles no new callbacks can start, but callbacks // already in flight still belong to this launch's teardown boundary. while (inFlightClientCallbacks.size > 0) { await Promise.allSettled([...inFlightClientCallbacks]) } + if (clientResult.status === 'rejected') throw clientResult.reason }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the @@ -206,6 +207,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe try { await spawned } catch (error: unknown) { + await drained.catch(() => undefined) closeUpdateStream() throw error } diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index ff7c304121..6adb0d4268 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -62,8 +62,17 @@ describe('runScenario', () => { it('surfaces an asynchronous child spawn failure through startup and close', async () => { const { dir } = await scenario({}) const launched = launchAcpTestAgent({ agent: AGENT, cwd: join(dir, 'missing') }) + let stdioClosed = false + let clientClosed = false + launched.child.once('close', () => { stdioClosed = true }) + void launched.client.closed.then( + () => { clientClosed = true }, + () => { clientClosed = true }, + ) await expect(launched.spawned).rejects.toMatchObject({ code: 'ENOENT' }) await expect(launched.close()).rejects.toMatchObject({ code: 'ENOENT' }) + expect(stdioClosed).toBe(true) + expect(clientClosed).toBe(true) }) it('centralizes ACP boot, captures, updates, fail-closed permissions, and shutdown', { timeout: 20_000 }, async () => { From 9f9f83c11f0ba190da488156efb015e3f7d921f4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:40:22 +0800 Subject: [PATCH 070/163] docs: align compaction output contract --- packages/compact/compact/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index fafcad76b8..adad9cfdfa 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -10,7 +10,7 @@ This package is the interface tier of the compaction capability, split so each c | `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | -Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). +Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session`, and its durable `compact/summary` event uses the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). ## Service API (`ctx.compact`) From b47e4c5276604dd1e5a400f5f60d2c16e195df10 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:45:12 +0800 Subject: [PATCH 071/163] fix: suppress teardown startup failures --- docs/architecture.md | 2 +- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 4 ++-- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/index.ts | 4 +++- packages/core/agent-loop/tests/config-session-id.spec.ts | 5 ++++- 8 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 41ab5c716b..0eb7f1fe55 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ Default loop processing remains exposed through plugin-visible services and even A **session** is an append-only event log. A **turn** drains queued input until the model stops asking for tools and no plugin requests continuation. A **step** is one model request plus the tool executions caused by that response. In the flow below ([sequence companion](agent-lifecycle.md)), quoted names are durable session events and event names are extension points. -Startup resolves one identity. No id mints `-session-`; `sessionId` resumes stored or creates; `resumeSessionId` requires history. Failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject buffered work. +Startup resolves identity. No id mints `-session-`; `sessionId` resumes or creates; `resumeSessionId` requires history. Active failures emit `agent-loop/config-start-failed(sessionId, error)`, so front doors reject work; teardown stays silent. ### Turn Flow diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 484ba1c632..173806572a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -131,7 +131,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:353`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:354`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index e44df3c3b2..df3aec8b12 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -179,13 +179,13 @@ Source: [`packages/core/agent/src/types.ts:576`](../../packages/core/agent/src/t ### `agent-loop/config-start-failed` — emit -A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. +A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. ```ts cordis-catalog 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` -Source: [`packages/core/agent-loop/src/index.ts:348`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:349`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 965a640966..74496739e2 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:368`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:369`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 78a30bd4e1..cc155ebb16 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:348`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:349`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 2593da7c47..e27e8b625a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -41,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. A declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. While the factory is active, a declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal; cancellation caused by factory teardown is silent. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3c09ef2e7f..06c108d324 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -340,7 +340,8 @@ declare module 'cordis' { /** * A declarative agent entry failed before it could publish a live agent. * Consumers that buffer work for the configured identity use this - * transient signal to reject that work instead of waiting forever. + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. * @param sessionId - exact shared agent/session identity that failed startup. * @param error - persistence, setup, or publication failure. * @mode emit @@ -431,6 +432,7 @@ export class AgentLoop extends Service implements AgentFactory { sessionId: SessionId, error: unknown, ): void { + if (!this.ownership.isActive()) return this.ctx.logger.warn(`agent "${configId}": config-driven ${action} of "${sessionId}" failed: ${renderThrown(error)}`) const args: unknown[] = ['agent-loop/config-start-failed', sessionId, error] for (const callback of this.ctx.events.dispatch('emit', args)) { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index b35dc71739..53782fe03c 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -172,6 +172,8 @@ describe('config-driven session id', () => { const listing = Promise.withResolvers>>() vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) const loop = await ctx.plugin(AgentLoop, { agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], @@ -181,9 +183,10 @@ describe('config-driven session id', () => { await Promise.resolve() expect(disposed).toBe(false) - listing.resolve([]) + listing.reject(new Error('startup cancelled by teardown')) await disposal expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(failures).toEqual([]) expect(warn).not.toHaveBeenCalled() warn.mockRestore() await ctx.fiber.dispose() From 1f2c85c854985384febdf6829cc3d9eb2181b3ed Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:47:44 +0800 Subject: [PATCH 072/163] test: await fresh stdio agent publication --- packages/ui/stdio-agent/tests/stdio-agent.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index a14848a549..4d555f5965 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -85,6 +85,7 @@ describe('dsh-stdio-agent app', () => { expect(ctx.get('tools')?.get('ask_user_question')).toBeDefined() // The sole pre-created agent the UI drives. `main` is its stable config // label; each fresh process mints a durable combined agent/session id. + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) const agent = ctx.get('agents')?.list()[0] expect(agent).toBeDefined() expect(agent?.id).toBe(agent?.session.id) @@ -100,6 +101,7 @@ describe('dsh-stdio-agent app', () => { persistenceRoot: '/tmp/dsh-stdio-agent-spec-empty-resume', skills: await isolatedSkillsConfig(), }) + await expect.poll(() => ctx.get('agents')?.list()).toHaveLength(1) const agent = ctx.get('agents')?.list()[0] expect(agent?.id).toMatch(/^main-session-[0-9a-f-]{36}$/) expect(agent?.id).toBe(agent?.session.id) From 628006889e07846af0816a127683b3e5aa5f2b5c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:51:21 +0800 Subject: [PATCH 073/163] docs: keep agent-loop map concise --- docs/architecture.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture.md b/docs/architecture.md index 0e83cba072..89bb27604b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -17,7 +17,7 @@ A harness is one [Cordis](cordis-primer.md) context. Packages contribute service | `ctx.systemPrompt` | `dsh-system-prompt` | ordered prompt sections, tool schemas, and prompt variables | | `ctx.tools` | `dsh-tools` | tool registry and [execution pipeline](tool-execution-pipeline.md) | | `ctx.agents` | `dsh-agent` | live agent registry, public `Agent` handle, `agent/*` events | -| `ctx.agentLoop` | `dsh-agent-loop` | shipped concrete `Agent` driver | +| `ctx.agentLoop` | `dsh-agent-loop` | concrete `Agent` driver | ### Capability Services From bb40259083f10cb2071c024f05810c74021e4035 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:11:33 +0800 Subject: [PATCH 074/163] test: preserve ACP cleanup failures --- examples/acp-agent/tests/acp.e2e.ts | 18 ++++------ examples/acp-agent/tests/cleanup.e2e.ts | 38 ++++++++++++++++++++++ examples/acp-agent/tests/cleanup.ts | 23 +++++++++++++ examples/acp-agent/tests/escalation.e2e.ts | 18 ++++------ examples/acp-agent/tests/hooks.e2e.ts | 18 ++++------ 5 files changed, 82 insertions(+), 33 deletions(-) create mode 100644 examples/acp-agent/tests/cleanup.e2e.ts create mode 100644 examples/acp-agent/tests/cleanup.ts diff --git a/examples/acp-agent/tests/acp.e2e.ts b/examples/acp-agent/tests/acp.e2e.ts index a1ddbeccc6..a0bfc9d943 100644 --- a/examples/acp-agent/tests/acp.e2e.ts +++ b/examples/acp-agent/tests/acp.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, readFile } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * End-to-end: boot examples/acp-agent as a real subprocess speaking ACP over @@ -31,16 +32,11 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('acp-agent over real stdio (no key required)', () => { diff --git a/examples/acp-agent/tests/cleanup.e2e.ts b/examples/acp-agent/tests/cleanup.e2e.ts new file mode 100644 index 0000000000..1f6e6493e0 --- /dev/null +++ b/examples/acp-agent/tests/cleanup.e2e.ts @@ -0,0 +1,38 @@ +/** Regression coverage for ACP example teardown. */ + +import { access, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanupAcpExampleTest } from './cleanup.ts' + +let fallbackWorkdir: string | undefined + +afterEach(async () => { + if (fallbackWorkdir !== undefined) await rm(fallbackWorkdir, { recursive: true, force: true }) + fallbackWorkdir = undefined +}) + +describe('cleanupAcpExampleTest', () => { + it('removes the workspace after process shutdown fails', async () => { + fallbackWorkdir = await mkdtemp(join(tmpdir(), 'acp-cleanup-')) + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + await expect(cleanupAcpExampleTest(spawned, fallbackWorkdir)) + .rejects.toMatchObject({ errors: [closeFailure] }) + await expect(access(fallbackWorkdir)).rejects.toThrow() + fallbackWorkdir = undefined + }) + + it('reports process and workspace failures together', async () => { + const closeFailure = new Error('close failed') + const spawned = { close: vi.fn().mockRejectedValue(closeFailure) } + + const failure = await cleanupAcpExampleTest(spawned, '\0').catch((error: unknown) => error) + + expect(failure).toBeInstanceOf(AggregateError) + expect((failure as AggregateError).errors).toHaveLength(2) + expect((failure as AggregateError).errors[0]).toBe(closeFailure) + }) +}) diff --git a/examples/acp-agent/tests/cleanup.ts b/examples/acp-agent/tests/cleanup.ts new file mode 100644 index 0000000000..28a896334a --- /dev/null +++ b/examples/acp-agent/tests/cleanup.ts @@ -0,0 +1,23 @@ +/** Shared teardown for ACP example tests. */ + +import { rm } from 'node:fs/promises' +import type { LaunchedAcpTestAgent } from '@deepseek-ai/dsh-acp-snapshot' + +/** + * Close the test agent, then remove its workspace, attempting both operations + * and reporting every failure instead of allowing the later one to mask the + * earlier one. + */ +export async function cleanupAcpExampleTest( + spawned: Pick | undefined, + workdir: string | undefined, +): Promise { + const results: PromiseSettledResult[] = [] + if (spawned !== undefined) results.push(...await Promise.allSettled([spawned.close('SIGKILL')])) + if (workdir !== undefined) results.push(...await Promise.allSettled([rm(workdir, { recursive: true, force: true })])) + + const failures = results + .filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map(result => result.reason as unknown) + if (failures.length > 0) throw new AggregateError(failures, 'ACP example cleanup failed') +} diff --git a/examples/acp-agent/tests/escalation.e2e.ts b/examples/acp-agent/tests/escalation.e2e.ts index da8d3408c2..3b84dd721c 100644 --- a/examples/acp-agent/tests/escalation.e2e.ts +++ b/examples/acp-agent/tests/escalation.e2e.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process' -import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { mkdtemp, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -13,6 +13,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * The default ACP composition (`cordis.yml`) end to end. @@ -81,16 +82,11 @@ let spawned: Spawned | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe('default sandbox composition keyless smoke (real cordis.yml via the Loader)', () => { diff --git a/examples/acp-agent/tests/hooks.e2e.ts b/examples/acp-agent/tests/hooks.e2e.ts index df231184c3..dbe98ab358 100644 --- a/examples/acp-agent/tests/hooks.e2e.ts +++ b/examples/acp-agent/tests/hooks.e2e.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile, access } from 'node:fs/promises' +import { mkdtemp, writeFile, access } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -9,6 +9,7 @@ import { type AgentUnderTest, type LaunchedAcpTestAgent, } from '@deepseek-ai/dsh-acp-snapshot' +import { cleanupAcpExampleTest } from './cleanup.ts' /** * With-key e2e: the Claude Code hook bridge running against the REAL acp-agent @@ -38,16 +39,11 @@ let spawned: LaunchedAcpTestAgent | undefined let workdir: string | undefined afterEach(async () => { - try { - await spawned?.close('SIGKILL') - } finally { - spawned = undefined - try { - if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) - } finally { - workdir = undefined - } - } + const ownedSpawned = spawned + const ownedWorkdir = workdir + spawned = undefined + workdir = undefined + await cleanupAcpExampleTest(ownedSpawned, ownedWorkdir) }) describe.skipIf(!process.env.DEEPSEEK_API_KEY)('acp-agent e2e: a PreToolUse hook blocks bash (real model)', () => { From b58cf7ec2fdb3ba7d88ec097766dd47f2a188a2a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:24:21 +0800 Subject: [PATCH 075/163] fix: coordinate overlapping configured reloads --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- docs/event-producer-consumer.md | 2 +- packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/index.ts | 35 +++++++++++++++ .../tests/config-session-id.spec.ts | 44 +++++++++++++++++++ packages/ui/stdio-agent/src/stdio-chat.ts | 13 +++++- .../ui/stdio-agent/tests/stdio-chat.spec.ts | 12 +++-- 9 files changed, 103 insertions(+), 11 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 173806572a..9bebe62380 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -131,7 +131,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) · [`SessionId`](../packages/core/session/src/index.ts) -Source: [`packages/core/agent-loop/src/index.ts:354`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:361`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-bash-local` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index df3aec8b12..f913b95c36 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -185,7 +185,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` -Source: [`packages/core/agent-loop/src/index.ts:349`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:356`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index d3e8b74edf..e4e68fe17f 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:369`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:376`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index cc155ebb16..2b62ec93eb 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:349`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:356`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:304`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:319`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio-agent`](../packages/ui/stdio-agent) | | `agent/error` | `emit` | [`packages/core/agent/src/types.ts:593`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index e27e8b625a..881d60027a 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -41,7 +41,7 @@ interface Config { } ``` -Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. While the factory is active, a declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal; cancellation caused by factory teardown is silent. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. +Agents listed in config are auto-created at startup. `cwd` seeds a fresh config-created session; a materialized exact `sessionId` remount and an explicit `resumeSessionId` keep the persisted session header. An overlapping remount waits for an already-disposed same-id agent to finish detaching both registries before it inspects persistence, so asynchronous teardown cannot strand the configured identity. While the factory is active, a declarative lookup, resume, setup, or publication failure is contained, logged, and emitted as `agent-loop/config-start-failed(sessionId, error)` because no live `Agent` exists for an `agent/*` signal; cancellation caused by factory teardown is silent. Config agents have no per-agent persona field: they use `dsh-system-prompt`'s deployment default, while programmatic factory callers can register an agent-scoped `deployment:persona` shadow in `setup`. The plugin registers the built-in `model`/`cwd` prompt variables on `ctx.systemPrompt`, resolved per step from `assembleContextFor(agent)` — the helper couples the typed agent with its matching scope selector. These are runtime facts of the agents THIS loop drives, unlike the `harness:identity` and default `deployment:persona` sections, which live on `dsh-system-prompt` so they survive a swapped loop plugin. ### Exported concrete class diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 06c108d324..3274e36587 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -53,6 +53,7 @@ function renderThrown(value: unknown): string { /** Factory-level ownership of every preparing or live transaction. */ class FactoryOwnership { private accepting = true + private readonly inactive = Promise.withResolvers() private transactions = new Set() private startupTasks = new Set>() @@ -74,8 +75,14 @@ class FactoryOwnership { void task.then(forget, forget) } + /** Resolve `task`, or stop waiting when factory teardown begins. */ + async waitWhileActive(task: Promise): Promise { + await Promise.race([task, this.inactive.promise]) + } + async dispose(): Promise { this.accepting = false + this.inactive.resolve() const reason = new Error('agent loop is not active') await Promise.all([ ...[...this.transactions].map(transaction => transaction.disposeForFactory(reason)), @@ -455,6 +462,8 @@ export class AgentLoop extends Service implements AgentFactory { agentOptions: AgentOptions, meta: Pick, ): Promise { + await this.waitForDrainingConfiguredIdentity(ownerCtx, sessionId) + if (!this.ownership.isActive()) return const exists = (await persistence.list()).some(header => header.id === sessionId) if (!this.ownership.isActive()) return if (exists) { @@ -464,6 +473,32 @@ export class AgentLoop extends Service implements AgentFactory { this.create(sessionId, agentOptions, meta) } + /** Wait for an already-disposed same-id lifecycle to finish registry teardown. */ + private async waitForDrainingConfiguredIdentity(ownerCtx: Context, sessionId: SessionId): Promise { + const current = ownerCtx.agents.get(sessionId) + if (current?.status !== 'disposed') return + + const released = Promise.withResolvers() + const checkReleased = (): void => { + if (ownerCtx.agents.get(sessionId) === undefined && ownerCtx.sessions.get(sessionId) === undefined) { + released.resolve() + } + } + const disposeAgentListener = ownerCtx.on('agent/disposed', (agent) => { + if (agent.id === sessionId) checkReleased() + }) + const disposeSessionListener = ownerCtx.on('session/disposed', (session) => { + if (session.id === sessionId) checkReleased() + }) + try { + checkReleased() + await this.ownership.waitWhileActive(released.promise) + } finally { + disposeAgentListener() + disposeSessionListener() + } + } + /** * Create an agent and session under one caller-supplied identity, owned by * the accessing fiber. Constructor-driven config calls mint a fresh combined diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 53782fe03c..fda5681c29 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -92,6 +92,50 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) + it('waits for a draining exact-id lifecycle during an overlapping reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-overlap-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const sessionId = SessionId('stdio-exact-overlap') + const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const firstLoop = await ctx.plugin(AgentLoop, config) + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const first = ctx.agents.get(sessionId) as ReactLoopAgent + + const flushGate = Promise.withResolvers() + let flushStarted = false + ctx.on('session/flush', (session) => { + if (session !== first.session) return + flushStarted = true + return flushGate.promise + }) + first.inject([{ type: 'text', text: 'persist before replacement' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + expect(flushStarted).toBe(true) + + const firstDisposal = firstLoop.dispose() + await expect.poll(() => first.status).toBe('disposed') + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_id, error) => { failures.push(error) }) + const secondLoop = await ctx.plugin(AgentLoop, config) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(ctx.agents.get(sessionId)).toBe(first) + expect(failures).toEqual([]) + + flushGate.resolve(undefined) + await firstDisposal + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const second = ctx.agents.get(sessionId) as ReactLoopAgent + expect(second).not.toBe(first) + expect(JSON.stringify(second.session.deriveMessages())).toContain('persist before replacement') + expect(failures).toEqual([]) + + await secondLoop.dispose() + await ctx.fiber.dispose() + }) + it('contains an exact-id persistence lookup failure', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-')) dirs.push(root) diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 39d59dfdc0..4505f19b98 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -67,6 +67,15 @@ function isTTYPair(input: Readable, output: Writable): boolean { return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY) } +/** Render an arbitrary failure without allowing hostile coercion to escape the UI boundary. */ +function renderThrown(value: unknown): string { + try { + return String(value) + } catch { + return '' + } +} + interface PendingQuestion { request: AskUserQuestionRequest questionIndex: number @@ -230,7 +239,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt queuedInput.length = 0 submittedWork = sawRunning if (dropped > 0) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${String(error)}`) + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (${dropped} line(s)): ${renderThrown(error)}`) } maybeExit() }) @@ -390,7 +399,7 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt const text = line.trim() if (!text) return if (failedStartup !== undefined) { - ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${String(failedStartup.error)}`) + ctx.logger.error(`ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ${renderThrown(failedStartup.error)}`) return } const agent = target diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 11e64ac19c..c303577c48 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -83,6 +83,10 @@ function chunkEvent(chunk: StreamChunk): SessionEvent { const CONFIG: Config = { welcome: 'hi there', sessionId: 'main' } +function unrenderableFailure(): unknown { + return { [Symbol.toPrimitive](): never { throw new Error('coercion escaped') } } +} + async function setup(config: Config = CONFIG, runtimeOver: Partial = {}) { const ctx = new Context() await ctx.plugin(AgentRegistry) @@ -753,14 +757,14 @@ describe('createStdioChat input', () => { it('drops later input after the configured startup fails', async () => { const { ctx, input } = await setup() const error = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) - const failure = new Error('persisted session is corrupt') + const failure = unrenderableFailure() ctx.emit('agent-loop/config-start-failed', SessionId('main'), failure) input.feed('cannot run') await new Promise(r => setImmediate(r)) expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: persisted session is corrupt', + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', ) }) @@ -844,11 +848,11 @@ describe('createStdioChat EOF exit', () => { await flushExit() expect(exit).not.toHaveBeenCalled() - ctx.emit('agent-loop/config-start-failed', SessionId('main'), new Error('missing persisted session')) + ctx.emit('agent-loop/config-start-failed', SessionId('main'), unrenderableFailure()) await flushExit() expect(error).toHaveBeenCalledWith( - 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): Error: missing persisted session', + 'ui-stdio: main agent failed to start; dropped queued stdin (1 line(s)): ', ) expect(exit).toHaveBeenCalledWith(0) }) From 7e9bf9b951913b1c5d70b5e94e22ccd8f60bed76 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:03:01 +0800 Subject: [PATCH 076/163] refactor: remove impossible ACP drain branch --- packages/support/acp-snapshot/src/launcher.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index 30ca0b42b8..c0dc21dc06 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -178,14 +178,13 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe // `closed` follows parser exhaustion. Capture both eagerly so a caller that // invokes close after process exit still joins the complete drain boundary. const stdioClosed = new Promise(resolve => child.once('close', () => { resolve() })) - const drained = Promise.allSettled([stdioClosed, client.closed]).then(async ([, clientResult]) => { + const drained = Promise.all([stdioClosed, client.closed]).then(async () => { // The ACP SDK's readable loop dispatches client callbacks without awaiting // them. Once `closed` settles no new callbacks can start, but callbacks // already in flight still belong to this launch's teardown boundary. while (inFlightClientCallbacks.size > 0) { await Promise.allSettled([...inFlightClientCallbacks]) } - if (clientResult.status === 'rejected') throw clientResult.reason }) // A caller may await a pending update without calling close(). Make natural // stream exhaustion terminal for those waiters too, but only after the @@ -207,7 +206,7 @@ export function launchAcpTestAgent(options: AcpTestLaunchOptions): LaunchedAcpTe try { await spawned } catch (error: unknown) { - await drained.catch(() => undefined) + await drained closeUpdateStream() throw error } From d83aa8d11aa217fdbee379dce37aa5146228dd21 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:31:00 +0800 Subject: [PATCH 077/163] test: cover exact-id reload cancellation --- packages/core/agent-loop/src/index.ts | 8 +- .../tests/config-session-id.spec.ts | 85 +++++++++++++------ 2 files changed, 62 insertions(+), 31 deletions(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 3274e36587..ff0c692928 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -484,12 +484,8 @@ export class AgentLoop extends Service implements AgentFactory { released.resolve() } } - const disposeAgentListener = ownerCtx.on('agent/disposed', (agent) => { - if (agent.id === sessionId) checkReleased() - }) - const disposeSessionListener = ownerCtx.on('session/disposed', (session) => { - if (session.id === sessionId) checkReleased() - }) + const disposeAgentListener = ownerCtx.on('agent/disposed', checkReleased) + const disposeSessionListener = ownerCtx.on('session/disposed', checkReleased) try { checkReleased() await this.ownership.waitWhileActive(released.promise) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index fda5681c29..ceaf6597cb 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -136,6 +136,37 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) + it('cancels an exact-id reload while the prior lifecycle is still draining', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-cancel-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const sessionId = SessionId('stdio-exact-cancel') + const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } + const firstLoop = await ctx.plugin(AgentLoop, config) + await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() + const first = ctx.agents.get(sessionId) as ReactLoopAgent + + const flushGate = Promise.withResolvers() + ctx.on('session/flush', (session) => { + if (session === first.session) return flushGate.promise + }) + first.inject([{ type: 'text', text: 'persist before cancellation' }], { + source: { kind: 'plugin', plugin: 'test' }, + }) + + const firstDisposal = firstLoop.dispose() + await expect.poll(() => first.status).toBe('disposed') + const secondLoop = await ctx.plugin(AgentLoop, config) + await secondLoop.dispose() + expect(ctx.agents.get(sessionId)).toBe(first) + + flushGate.resolve(undefined) + await firstDisposal + expect(ctx.agents.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) + it('contains an exact-id persistence lookup failure', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-failure-')) dirs.push(root) @@ -208,33 +239,37 @@ describe('config-driven session id', () => { await ctx.fiber.dispose() }) - it('joins an exact-id persistence lookup before AgentLoop disposal completes', async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) - dirs.push(root) - const ctx = await makeCoreContext() - await ctx.plugin(SessionPersistenceJsonl, { root }) - const listing = Promise.withResolvers>>() - vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - const failures: unknown[] = [] - ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) + it.each(['resolve', 'reject'] as const)( + 'joins an exact-id persistence lookup that will %s before AgentLoop disposal completes', + async (outcome) => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-dispose-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + const listing = Promise.withResolvers>>() + vi.spyOn(ctx.sessionPersistence, 'list').mockReturnValue(listing.promise) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + const failures: unknown[] = [] + ctx.on('agent-loop/config-start-failed', (_sessionId, error) => { failures.push(error) }) - const loop = await ctx.plugin(AgentLoop, { - agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], - }) - let disposed = false - const disposal = loop.dispose().then(() => { disposed = true }) - await Promise.resolve() - expect(disposed).toBe(false) + const loop = await ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId('stdio-exact-dispose'), model: 'mock' }], + }) + let disposed = false + const disposal = loop.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) - listing.reject(new Error('startup cancelled by teardown')) - await disposal - expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() - expect(failures).toEqual([]) - expect(warn).not.toHaveBeenCalled() - warn.mockRestore() - await ctx.fiber.dispose() - }) + if (outcome === 'resolve') listing.resolve([]) + else listing.reject(new Error('startup cancelled by teardown')) + await disposal + expect(ctx.agents.get(SessionId('stdio-exact-dispose'))).toBeUndefined() + expect(failures).toEqual([]) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + await ctx.fiber.dispose() + }, + ) it('identity-nests the deferred resume fiber under its labeled owner effect', async () => { const ctx = new Context() From 4a5463cfc49b8f505b5f47aa7634909c08602a08 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:53:38 +0800 Subject: [PATCH 078/163] test: use the public agent type --- packages/core/agent-loop/tests/config-session-id.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index f001fa3154..2c8a5c31bd 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -145,7 +145,7 @@ describe('config-driven session id', () => { const config = { agents: [{ id: 'main', sessionId, model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) await expect.poll(() => ctx.agents.get(sessionId)).toBeDefined() - const first = ctx.agents.get(sessionId) as ReactLoopAgent + const first = ctx.agents.get(sessionId) as Agent const flushGate = Promise.withResolvers() ctx.on('session/flush', (session) => { From 64a3933270a0f4636b36b485197597c66945599b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:03:07 +0800 Subject: [PATCH 079/163] docs: refresh config catalog after parent merge --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 14cc973995..6f74792213 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -899,7 +899,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` From 5409452cc705804386fe029908f14b31840efe96 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 19:30:29 +0800 Subject: [PATCH 080/163] test: refresh snapshots after master merge --- .../tests/snapshots/advanced-toolchain/session.1.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.2.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/stdout.golden.jsonl | 2 +- .../snapshots/advanced-toolchain/system-prompt.golden.md | 2 +- .../acp-agent/tests/snapshots/both-mode-turn/session.jsonl | 2 +- .../tests/snapshots/both-mode-turn/stdout.golden.jsonl | 2 +- .../tests/snapshots/both-mode-turn/system-prompt.golden.md | 2 +- .../acp-agent/tests/snapshots/cancel/stdout.golden.jsonl | 2 +- .../tests/snapshots/code-mode-turn/stdout.golden.jsonl | 2 +- .../tests/snapshots/code-mode-turn/system-prompt.golden.md | 2 +- .../tests/snapshots/config-options/stdout.golden.jsonl | 6 +++--- .../tests/snapshots/error-finish/stdout.golden.jsonl | 2 +- .../tests/snapshots/escalation-approved/session.jsonl | 4 ++-- .../tests/snapshots/escalation-approved/stdout.golden.jsonl | 4 ++-- .../tests/snapshots/escalation-rejected/session.jsonl | 4 ++-- .../tests/snapshots/escalation-rejected/stdout.golden.jsonl | 4 ++-- .../acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl | 2 +- .../tests/snapshots/fs-policy-reject/stdout.golden.jsonl | 2 +- .../tests/snapshots/fs-read-window/stdout.golden.jsonl | 2 +- .../acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl | 2 +- .../tests/snapshots/fs-terminal-card/stdout.golden.jsonl | 2 +- .../tests/snapshots/fs-write-overwrite/stdout.golden.jsonl | 2 +- .../acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl | 2 +- .../acp-agent/tests/snapshots/handshake/stdout.golden.jsonl | 2 +- .../snapshots/hook-cc-posttool-block/stdout.golden.jsonl | 2 +- .../snapshots/hook-cc-posttool-context/stdout.golden.jsonl | 2 +- .../tests/snapshots/hook-cc-pretool-ask/session.jsonl | 4 ++-- .../tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl | 2 +- .../snapshots/hook-cc-pretool-deny/stdout.golden.jsonl | 2 +- .../hook-cc-promptsubmit-block/stdout.golden.jsonl | 2 +- .../hook-cc-promptsubmit-context/stdout.golden.jsonl | 2 +- .../snapshots/hook-cc-stop-continue/stdout.golden.jsonl | 2 +- .../snapshots/hook-codex-posttool-block/stdout.golden.jsonl | 2 +- .../hook-codex-posttool-context/stdout.golden.jsonl | 2 +- .../snapshots/hook-codex-pretool-block/stdout.golden.jsonl | 2 +- .../hook-codex-promptsubmit-block/stdout.golden.jsonl | 2 +- .../hook-codex-promptsubmit-context/stdout.golden.jsonl | 2 +- .../snapshots/hook-codex-stop-continue/stdout.golden.jsonl | 2 +- .../tests/snapshots/multi-turn/stdout.golden.jsonl | 2 +- .../tests/snapshots/permission-switching/session.jsonl | 2 +- .../snapshots/permission-switching/stdout.golden.jsonl | 6 +++--- .../tests/snapshots/repeat-tool-guard/stdout.golden.jsonl | 2 +- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- .../tests/snapshots/skill-load/stdout.golden.jsonl | 2 +- .../tests/snapshots/subagent-fork/stdout.golden.jsonl | 2 +- .../tests/snapshots/subagent-mixed/stdout.golden.jsonl | 2 +- .../tests/snapshots/subagent-multi/stdout.golden.jsonl | 2 +- .../tests/snapshots/subagent-spawn/stdout.golden.jsonl | 2 +- examples/acp-agent/tests/snapshots/text-turn/session.jsonl | 2 +- .../acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl | 2 +- .../acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl | 2 +- .../tests/snapshots/tool-call-turn/stdout.golden.jsonl | 2 +- .../tests/snapshots/workflow-run/stdout.golden.jsonl | 2 +- .../tests/snapshots/workspace-edit/stdout.golden.jsonl | 2 +- 55 files changed, 64 insertions(+), 64 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 991806d74b..cdc09c92c0 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index a6a3371913..79d9b94ad9 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index c6213cd558..7155b1c2a6 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl index bc4da17bb0..80a9cc9339 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-mount","title":"Mount plugin into live cordis runtime","kind":"execute","status":"in_progress","rawInput":{"code":"return { name: 'snapshot-marker', apply() {} }"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"advanced-mount","status":"completed","content":[{"type":"content","content":{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"advanced-code","title":"return await tools.cordis_inspect({ what: 'dynamic' })","kind":"execute","status":"in_progress","rawInput":"return await tools.cordis_inspect({ what: 'dynamic' })"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 5ad12cddff..6e8f157ace 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index a68bb37f9f..66cdecedfb 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl index 577f10445b..c9d2c87c27 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index ed60d52258..98b4f97fee 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl index 60235cac75..1f8c0ca024 100644 --- a/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/cancel/stdout.golden.jsonl @@ -1,4 +1,4 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"partial"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl index bfa379815e..5fe0944ac7 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index ed60d52258..98b4f97fee 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl index aa033fb673..7c85717676 100644 --- a/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/config-options/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":4,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"Invalid params: unknown permission value \"plan\""}} {"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"Invalid params: unknown config option \"reasoning-effort\""}} diff --git a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl index d5d4f1c400..99e00460c6 100644 --- a/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/error-finish/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"error":{"code":-32603,"message":"Internal error: turn failed: simulated provider error (HTTP 401)"}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl index 8aac1627bc..74fbed6ee3 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl @@ -131,8 +131,8 @@ {"type":"assistant/chunk","seq":129,"time":1783962245385,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":130,"time":1783962245385,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a command with sandbox_permissions set to danger-full-access, no prior run needed, justified as instructed."},{"type":"tool-call","id":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1501,"outputTokens":174,"cacheReadTokens":0,"reasoningTokens":28}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} {"type":"tool/call","seq":131,"time":1783962245385,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write file outside workspace and verify\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"3b0efb0e-5b4a-4911-87cf-9914bd346c17","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"3b0efb0e-5b4a-4911-87cf-9914bd346c17","outcome":"allowed-once"}} +{"type":"approval/asked","seq":132,"time":1783962245386,"data":{"id":"daa6d214-9e99-4cb6-b4f7-7f6086379133","toolName":"bash","callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":133,"time":1783962245387,"data":{"id":"daa6d214-9e99-4cb6-b4f7-7f6086379133","outcome":"allowed-once"}} {"type":"tool/result","seq":134,"time":1783962245399,"data":{"turn":1,"step":1,"callId":"call_00_d0sAHpJ9mYOJi0z7KNy30441","content":[{"type":"text","text":"escalated\n"}],"isError":false},"sourceEventSeqs":[131],"surfaceOp":"append"} {"type":"step/end","seq":135,"time":1783962245400,"data":{"turn":1,"step":1}} {"type":"step/start","seq":136,"time":1783962245400,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl index f438444dc4..5c63764aae 100644 --- a/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-approved/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl index 7c0a743413..bf86999cd9 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl @@ -155,8 +155,8 @@ {"type":"assistant/chunk","seq":153,"time":1783962246274,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":154,"time":1783962246274,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a specific command with `sandbox_permissions` set to `danger-full-access` and a specific justification. They explicitly said NOT to run it without sandbox_permissions first. Let me do exactly that."},{"type":"tool-call","id":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}],"usage":{"inputTokens":1509,"outputTokens":198,"cacheReadTokens":0,"reasoningTokens":48}},"sourceEventSeqs":[7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153],"surfaceOp":"append"} {"type":"tool/call","seq":155,"time":1783962246274,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","name":"bash","arguments":"{\"command\": \"printf 'escalated\\\\n' > /tmp/dsh-escalated.txt && cat /tmp/dsh-escalated.txt && rm /tmp/dsh-escalated.txt\", \"description\": \"Write to /tmp and verify, then clean up\", \"sandbox_permissions\": \"danger-full-access\", \"justification\": \"the user asked to write a file outside the workspace\"}"}} -{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"9e6c7946-af56-4535-9cce-993e0165c2f9","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} -{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"9e6c7946-af56-4535-9cce-993e0165c2f9","outcome":"rejected"}} +{"type":"approval/asked","seq":156,"time":1783962246275,"data":{"id":"71281495-1503-47de-bbdf-11d5de1e5f06","toolName":"bash","callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","reason":"escalate sandbox to danger-full-access: the user asked to write a file outside the workspace"}} +{"type":"approval/decided","seq":157,"time":1783962246275,"data":{"id":"71281495-1503-47de-bbdf-11d5de1e5f06","outcome":"rejected"}} {"type":"tool/result","seq":158,"time":1783962246275,"data":{"turn":1,"step":1,"callId":"call_00_WB1vnPomi8yr6MlcFKTj7912","content":[{"type":"text","text":"Error: the user rejected escalating this command to \"danger-full-access\""}],"isError":true},"sourceEventSeqs":[155],"surfaceOp":"append"} {"type":"step/end","seq":159,"time":1783962246276,"data":{"turn":1,"step":1}} {"type":"step/start","seq":160,"time":1783962246276,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl index b926355bd7..07e48d9e22 100644 --- a/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/escalation-rejected/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl index acc193ad1e..919c0f169e 100644 --- a/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl index 5aa75c026c..c27544a176 100644 --- a/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-policy-reject/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl index 05832400c8..8b75c30e8c 100644 --- a/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read-window/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl index 19abb7f418..30aa0d2460 100644 --- a/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-read/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl index 0e7dcca6f8..e045c7b228 100644 --- a/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-terminal-card/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl index 4801d9410d..f554fa3123 100644 --- a/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write-overwrite/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl index d5b3ca5d15..d04def6af2 100644 --- a/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/fs-write/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl index e4c4984fc5..312569a491 100644 --- a/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/handshake/stdout.golden.jsonl @@ -1,2 +1,2 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl index 0a93b649da..36f050a694 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl index e9243d8a05..bbc16779ba 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index b975c54aac..faed620b4c 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -55,8 +55,8 @@ {"type":"tool/call","seq":53,"time":1783352172557,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":54,"time":1783352172558,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} {"type":"hook/result","seq":55,"time":1783352172573,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":14.113374999999905}} -{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"da1d1e2e-c13f-4e70-8039-a42ae8f84fd5","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} -{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"da1d1e2e-c13f-4e70-8039-a42ae8f84fd5","outcome":"rejected"}} +{"type":"approval/asked","seq":56,"time":1783962235813,"data":{"id":"7b519d67-ef63-4ba2-9b37-1493e1525329","toolName":"bash","callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","reason":"bash requires manual approval in this session"}} +{"type":"approval/decided","seq":57,"time":1783962235813,"data":{"id":"7b519d67-ef63-4ba2-9b37-1493e1525329","outcome":"rejected"}} {"type":"tool/result","seq":58,"time":1783962235814,"data":{"turn":1,"step":1,"callId":"call_00_6k0oGSliVHxGSgqBmMEO4311","content":[{"type":"text","text":"Error: the user rejected tool \"bash\""}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":59,"time":1783962235814,"data":{"turn":1,"step":1}} {"type":"step/start","seq":60,"time":1783962235814,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl index 3905e82ca7..db38650167 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl index 92906adb59..f0c5e005d9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl index 6304582220..5d88350cad 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl index c4ad3ba541..ac203d50cc 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"'s"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl index 5c4a564d26..01297aa39d 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index cbaec45f9f..57b297ccfc 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl index 4bf92f3197..faa1255a32 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl index 5459da1a17..11b47d6e58 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl index 6304582220..5d88350cad 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/stdout.golden.jsonl @@ -1,3 +1,3 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"cancelled"}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl index 8cae81a5c9..116a8ebbbd 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asked"}}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl index 66d8c816be..d231f60e0a 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl index 1d9d45954a..b45d9e947c 100644 --- a/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/multi-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index 0d8e018ae5..a30f9e4a46 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -5,7 +5,7 @@ {"type":"approval/policy","seq":3,"time":1783962244578,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962244578,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962244579,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl index 651897e850..9c3c78be5c 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/stdout.golden.jsonl @@ -1,6 +1,6 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} -{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":3,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"workspace-write","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} @@ -44,7 +44,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"D"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ONE"}}}} {"jsonrpc":"2.0","id":4,"result":{"stopReason":"end_turn"}} -{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":5,"result":{"configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl index a0901b6297..876f6c490a 100644 --- a/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/repeat-tool-guard/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_1","title":"Update todo list","kind":"other","status":"in_progress","rawInput":[{"content":"watch the kettle boil","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"plan","entries":[{"content":"watch the kettle boil","priority":"medium","status":"in_progress"}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_1","status":"completed","content":[{"type":"content","content":{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}}]}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 9a26fa2efe..1c85ee81ba 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl index 10198918d6..b5f25f2c07 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Load the requested skill."}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_skill_load","title":"Load skill snapshot-skill","kind":"read","status":"in_progress","rawInput":"snapshot-skill"}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_skill_load","status":"completed","content":[{"type":"content","content":{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/snapshot-skill\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\nFollow these snapshot-only instructions.\nResolve referenced resources relative to this skill directory.\n\n"}}]}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl index 44fc4402fc..e390bda86a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl index 08ad14dc9c..db5f28fa79 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl index 93b38e33cb..4c0c7d2601 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl index 26d45699cc..ce114fccf0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index 0643740e4f..c66f5676cc 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl index 1059a9cc6c..8de447de6d 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl index f060b6b92f..a947ed4997 100644 --- a/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/todo-plan/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl index 041ea02703..9d06e9d6ac 100644 --- a/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/tool-call-turn/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl index 9c0bbd37be..56b8f3537a 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} diff --git a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl index 23f13ff3c2..513a02a6af 100644 --- a/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/workspace-edit/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" user"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" wants"}}}} From 8be096c540c6b82187051eab9febd461a46e4fc2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:08:37 +0800 Subject: [PATCH 081/163] fix: preserve master bash guidance wording --- packages/bash/tool-bash/src/index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 662b7a065f..4c699cd6c0 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -173,15 +173,15 @@ function bashDescription(escalationModes: readonly SandboxMode[]): string { + 'poll it with `bash_output` and stop it with `bash_kill`.' if (escalationModes.length === 0) return base return base + ' Attempting a command the sandbox may deny is safe and expected: run it and read the ' - + 'marker rather than assuming the denial. When a command IS denied and a wider mode would let it ' - + 'succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry ' + + 'marker rather than assuming the denial. When a command is denied and a wider mode would let it ' + + 'succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry ' + 'the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) ' + 'plus a one-sentence `justification`. Do not detour through chat to ask permission first — the ' - + 'approval prompt raised by that retry IS how the user consents. If the session states approval ' + + 'approval prompt raised by that retry is how the user consents. If the session states approval ' + 'prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. ' - + 'Never escalate speculatively: ground the request in a real denial — normally the one THIS command ' + + 'Never escalate speculatively: ground the request in a real denial — normally the one this command ' + 'just hit; escalating up front is fine only when this session already denied the same access. ' - + 'A rejected escalation is final for THAT command — stop and explain, never work around ' + + 'A rejected escalation is final for that command — stop and explain, never work around ' + 'it — but it does not forbid attempting or escalating other commands later.' } From 5a1194bf301ecd9f2ab007a599a2e121e57b72e6 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 20:18:24 +0800 Subject: [PATCH 082/163] test: refresh snapshots after bash guidance merge --- .../tests/snapshots/advanced-toolchain/session.1.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/session.2.jsonl | 2 +- .../acp-agent/tests/snapshots/advanced-toolchain/session.jsonl | 2 +- .../tests/snapshots/advanced-toolchain/system-prompt.golden.md | 2 +- examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl | 2 +- .../tests/snapshots/both-mode-turn/system-prompt.golden.md | 2 +- .../tests/snapshots/code-mode-turn/system-prompt.golden.md | 2 +- .../tests/snapshots/permission-switching/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/skill-load/session.jsonl | 2 +- examples/acp-agent/tests/snapshots/text-turn/session.jsonl | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index cdc09c92c0..991806d74b 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 79d9b94ad9..a6a3371913 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} {"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} {"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index 7155b1c2a6..c6213cd558 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"cordis_inspect","description":"Inspect the live cordis runtime that is running THIS agent. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (a flat list of the loaded plugins with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `dynamic` (plugins you mounted via cordis_mount: id, name, state, provided services, awaited services), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Omit `what` to get all six sections.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","dynamic","api","events"]}}}},{"name":"cordis_mount","description":"Mount a NEW cordis plugin into the live runtime that is running THIS agent (self-modification). `code` runs as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, async execute(args) { … } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'boolean'|'object'|'array', required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style { type: 'object', properties, required: […] } wrapper and type 'integer' are also accepted and normalized. A tool's `execute` MUST return an ARRAY of content blocks, e.g. `return [{ type: 'text', text: someString }]` — never a bare string. Mounts can COMPOSE: one plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically on unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned on unmount) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` VETOES the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"Body of an async JS function; must `return` the plugin to mount."}},"required":["code"]}},{"name":"cordis_unmount","description":"Dispose a plugin previously mounted with cordis_mount, by id. All its registrations (event listeners, tools, services) are cleaned up through the cordis effect lifecycle. Returns only after disposal has fully completed (quiescence, not just a request to stop).","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The dynamic mount id returned by cordis_mount (e.g. \"dyn-1\")."}},"required":["id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md index 6e8f157ace..5ad12cddff 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl index 66cdecedfb..a68bb37f9f 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783611774323,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783611774323,"data":{"content":[{"type":"text","text":"Call the run_code tool (NOT the native bash tool directly) with a program that runs exactly `echo BOTH_OK` via tools.bash and returns its output. Then reply with that output only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783611774324,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783611774325,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."}},"required":["code"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783611774792,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783611774879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md index 98b4f97fee..ed60d52258 100644 --- a/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md index 98b4f97fee..ed60d52258 100644 --- a/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md +++ b/examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.golden.md @@ -25,7 +25,7 @@ The available tools: ```ts declare const tools: { - /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ + /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later. */ bash(args: { /** The bash command to execute. */ command: string; diff --git a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl index 1f33766ec9..07cd6233b3 100644 --- a/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl +++ b/examples/acp-agent/tests/snapshots/permission-switching/session.jsonl @@ -5,7 +5,7 @@ {"type":"approval/policy","seq":3,"time":1783962244578,"data":{"policy":"ask"}} {"type":"user/message","seq":4,"time":1783962244578,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly this one command in a single call: printf 'before\\n' > out.txt && cat out.txt. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783962244579,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1783962244580,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":7,"time":1783860667444,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":8,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":9,"time":1783860667445,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} diff --git a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl index 1c85ee81ba..9a26fa2efe 100644 --- a/examples/acp-agent/tests/snapshots/skill-load/session.jsonl +++ b/examples/acp-agent/tests/snapshots/skill-load/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783654655602,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783654655603,"data":{"content":[{"type":"text","text":"Load the snapshot-skill skill with the skill tool, then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783654655608,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783654655608,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}],"messagePrefix":[{"role":"user","content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\n"}]}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Load the requested skill."}}} {"type":"assistant/chunk","seq":6,"time":1783654655609,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl index c66f5676cc..0643740e4f 100644 --- a/examples/acp-agent/tests/snapshots/text-turn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/text-turn/session.jsonl @@ -2,7 +2,7 @@ {"type":"turn/start","seq":0,"time":1783600629541,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"user/message","seq":1,"time":1783600629541,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"step/start","seq":2,"time":1783600629542,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command IS denied and a wider mode would let it succeed, escalate immediately in the SAME turn — the ONE sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry IS how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one THIS command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for THAT command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} +{"type":"request/header","seq":3,"time":1783600629542,"data":{"header":{"config":{"model":"deepseek-v4-flash"},"system":"{{system}}","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way (a background task reports the same marker via bash_output once it has finished). Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; poll it with `bash_output` and stop it with `bash_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately. No timeout applies."},"sandbox_permissions":{"type":"string","description":"The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.","enum":["workspace-write","danger-full-access"]},"justification":{"type":"string","description":"Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access."}},"required":["command","description"]}},{"name":"bash_kill","description":"Ask the executor to kill a running background bash task by task id.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"bash_output","description":"Read new output from a background bash task started with `bash` + `run_in_background`. Returns only output produced since the previous bash_output call, plus the task status. Tasks keep running while you do other work; poll again later for more output.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the bash tool."}},"required":["task_id"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that INHERITS this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Keep AT MOST ONE todo `in_progress` at a time; while work remains, exactly one active task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const — no oneOf/pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), `model` (override). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]})."}},"required":["script","meta"]}}]},"reason":"initial"}} {"type":"assistant/chunk","seq":4,"time":1783600630819,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} {"type":"assistant/chunk","seq":5,"time":1783600630820,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} {"type":"assistant/chunk","seq":6,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} From 10fd0b4504b47285b67aed611e4cc0a2e86c5db4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:22:37 +0800 Subject: [PATCH 083/163] docs: align unified identity translation --- docs/cookbook/extension-cookbook.i18n.yaml | 4 ++-- docs/cookbook/extension-cookbook.zh.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index ef1d8d606f..2761b419e9 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 40ee22b352c884d7f295c87726c54ab8e166c844 -extension-cookbook.zh.md: 4e5bc68c973649574bcb2404bea00096eb9ca41f +extension-cookbook.md: 0b95866515efa1b28a95ed48de0fe20d76a62b82 +extension-cookbook.zh.md: 038bf093ce2d289fb59be165a154cbe4df831d9e diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 4e5bc68c97..038bf093ce 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -40,7 +40,7 @@ UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/ch ```ts import type { Context } from 'cordis' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' declare function render(text: string): void declare function onUserInput(handler: (text: string) => void): void @@ -54,7 +54,7 @@ export function apply(ctx: Context) { render(event.data.chunk.text) } }) - onUserInput(text => ctx.agents.get(AgentId('main'))?.send([{ type: 'text', text }])) + onUserInput(text => ctx.agents.get(SessionId('client-session'))?.send([{ type: 'text', text }])) } ``` From 9745a0d43a979d34c44c2a42d8a485d03a25de71 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:36:04 +0800 Subject: [PATCH 084/163] fix: reject empty configured session ids --- packages/core/agent-loop/src/index.ts | 2 +- packages/core/agent-loop/tests/config-session-id.spec.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 8d9a184cc7..325ba7b92d 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -377,7 +377,7 @@ export class AgentLoop extends Service implements AgentFactory { static Config = z.object({ agents: z.array(z.object({ id: z.string().required(), - sessionId: z.string(), + sessionId: z.string().min(1), model: z.string(), cwd: z.string(), resumeSessionId: z.string(), diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 096a6eff9e..bd2a5522df 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -35,6 +35,15 @@ async function makeCoreContext(): Promise { } describe('config-driven session id', () => { + it('rejects an empty exact id before publishing an agent', async () => { + const ctx = await makeCoreContext() + await expect(ctx.plugin(AgentLoop, { + agents: [{ id: 'main', sessionId: SessionId(''), model: 'mock' }], + })).rejects.toThrow('expected string length >= 1') + expect(ctx.agents.get(SessionId(''))).toBeUndefined() + await ctx.fiber.dispose() + }) + it('accepts one exact fresh id and rejects it alongside a resume id', async () => { const exact = await makeCoreContext() await exact.plugin(AgentLoop, { From ed3654da00fd14597b79e8e97bf61f09c5981ced Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:38:53 +0800 Subject: [PATCH 085/163] docs: reconcile hidden internals with prose standard --- ...t-variables-and-tool-guidance-ownership.md | 16 ++--- .../subagent/subagent-subprocess/src/index.ts | 61 +++++-------------- packages/subagent/tool-subagent/src/index.ts | 49 +++------------ 3 files changed, 31 insertions(+), 95 deletions(-) diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index aae4564513..854807c109 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -24,21 +24,21 @@ The assembled system prompt had four defects, all of one family: facts the harne ### Prompt variables -Plugins contribute named values via `ctx.systemPrompt.variable(name, provider)`; prompt text references them as `{{name}}`. Providers are functions of the `AssembleContext` and may return `undefined` — "no value for THIS assembly". `assemble()` resolves every registered variable into `PromptAssembly.variables` (waterfall listeners can see, add, or override); `renderPrompt` interpolates. Rendering is STRICT — fail loud beats shipping a malformed prompt: a reference to an unregistered name throws (listing what exists; lookup is `Object.hasOwn`, so a prototype property like `{{constructor}}` is unknown, not a function spliced into the prompt), a registered-but-valueless reference throws, a complete `{{…}}` group that is not a well-formed name (`[a-z][a-z0-9_]*`, e.g. `{{ model }}`) throws, and a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`, `{{a{b}}`) throws. A lone `{{` with no `}}` anywhere after it is ordinary prose and passes through verbatim; substituted values are never re-scanned. Registration rejects duplicate and unreferenceable names, mirroring the tool registry — and `section()` now rejects duplicate section names, making the documented dedup real. +Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, provider)`. Assembly resolves them into the waterfall-visible variable map. Rendering rejects unknown own-property references, registered providers that return `undefined`, malformed complete references, and unbalanced references that still contain a closing `}}`; a lone unmatched `{{` remains prose, and substituted values are not rescanned. Registration rejects invalid or duplicate variable names, and section names are unique. `dsh-agent-loop` registers the two built-ins, both pure projections of the context agent: `model` (= `options.model`) and `cwd` (= `session.header.cwd`). The example personas write `powered by the {{model}} model` — the model name is stated once, in the `model:` config key. `{{cwd}}` is demonstrated in the ACP example only: every ACP session carries the client's cwd, while config-pre-created stdio agents have none (a persona claiming `{{cwd}}` there fails the turn — by design). The variables stay on the loop plugin (unlike the sections below): they are runtime facts of the agents THIS loop drives, and a replacement loop supplies its own. ### Persona as the order-0 section -`dsh-system-prompt` itself registers the two harness-owned sections (they must survive a swapped loop plugin, so they do NOT live on `dsh-agent-loop`): the static `harness:identity` at order `-100` — every prompt opens by stating the agent is powered by the DeepSeek Harness SDK — and the global default `deployment:persona` at order 0, whose text is the plugin's own `persona` config. `AgentOptions.systemPrompt` and the loop's special-case join are gone: `fullSystemPrompt ≡ renderPrompt(assembly)`, one ordered pipeline for everything the model sees, and `agent/pre-step` (compaction's token-pressure input) measures exactly the real prompt. An agent-scoped section with the same `deployment:persona` name shadows the default for that agent; programmatic setup may register one directly, and the subagent persona feature installs one before publishing an in-process child when the selected provider supports it. Order bands are convention: harness identity `-100`, persona `0`, tool guidance `100–199`; other negative orders also render before the persona. +`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and `agent/pre-step` therefore measures the exact prompt used for compaction. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. ### Tool guidance ownership -Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship in every request — the YAML prose was ~fully redundant with them. Sections carry only the cross-call habits a single call's description cannot: `dsh-tool-bash` contributes `tool:bash` (order 105) — check the `[exit code: N]` marker on every result; `dsh-tool-fs`'s read section gains the "not shell commands like cat" contrast. `todo_write` and the subagent tools need NO section — their descriptions already carry the whole contract. The leaf personas shrink to identity + behavior (verify your work; keep answers brief), and the welcome banner stops enumerating tools. +Per-tool semantics and selection guidance live in tool descriptions. Prompt sections carry only cross-call habits, such as checking bash exit markers or preferring filesystem tools over shell commands. `todo_write` and subagent tools need no section because their descriptions contain the full contract. Deployment personas contain only role and behavior. ### The subagent conversation-history descriptor -`SubagentProvider` gains `readonly inheritsParentContext: boolean` — a DESCRIPTIVE conversation-history fact beside `capabilities`, not in it (capabilities are start-time validation; nothing validates against this flag). Spawn and ACP declare `false`, fork declares `true`. The name refers only to conversation seeding, not Cordis scope, services, tools, or authority. `dsh-tool-subagent` derives both the tool description and the `prompt` parameter description from the flag: the fork instance now tells the model the child is seeded with the conversation's completed turns (not the in-flight turn) and that its prompt should state only what is new. Deriving the description from a provider that arrives on its own fiber is what forced the provider-lifecycle events and the tool's reactive registration — that mechanism, its Loader-concurrency rationale, and its rejected alternatives are recorded in [the provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). +`SubagentProvider.inheritsParentContext` describes conversation seeding, not scope, services, tools, or authority. Spawn and ACP set it to `false`; fork sets it to `true`. `dsh-tool-subagent` derives its tool and prompt-parameter descriptions from the flag, including that fork inherits completed turns but not the in-flight turn. Provider lifecycle events keep that wording synchronized with reactive provider registration; their rationale lives in the [provider-lifecycle-events RFC](2026-07-05-subagent-provider-lifecycle-events.md). ## Alternatives considered @@ -56,10 +56,10 @@ Per-tool semantics and when-to-use live in tool DESCRIPTIONS, which already ship ## Shipped invariants -- `renderPrompt(await assemble(assembleContextFor(agent)))` for the coding-agent example renders the harness identity, then the persona (with the agent's model name interpolated), then the fs/bash/web guidance sections; the loop has no other prompt-composition path. -- The `subagent_fork` schema description says the child inherits the conversation; the `subagent` one says it does not. The tool follows its provider: absent before the backend activates, present after, gone when the backend unloads, re-worded from the fresh provider on reload. -- Unknown/valueless/malformed/unbalanced `{{…}}` references throw with the section name in the message; duplicate section, variable, and tool-name registrations all throw. -- Snapshot goldens are prompt-independent by construction: llm-replay keys replay on (turn, step) chunk streams and never re-verifies the outgoing request. +- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. +- Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. +- Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. ## Consequences diff --git a/packages/subagent/subagent-subprocess/src/index.ts b/packages/subagent/subagent-subprocess/src/index.ts index bd792e7f23..3831d2bb6a 100644 --- a/packages/subagent/subagent-subprocess/src/index.ts +++ b/packages/subagent/subagent-subprocess/src/index.ts @@ -1,19 +1,8 @@ /** - * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn - * an external agent as a child process and must keep the parent deployment's - * credentials out of it, tear it down to quiescence, and isolate it from the - * host user's on-disk CLI state. The pieces: credential-shaped env scrubbing - * ({@link buildChildEnv}), spawn-failure capture ({@link spawnFailure}), - * bounded child-exit waits inside the stdin-EOF → SIGTERM → SIGKILL dispose - * ladder ({@link disposeChildProcess}), and the per-run isolated config dir - * ({@link createIsolatedConfigDir}). - * - * This package owns no provider and registers nothing; it is a pure library - * the out-of-process backend packages depend on (the `subagent-inprocess` - * shape, for the process boundary). Every tunable — the ladder's grace - * periods, a pinned config dir — is a PARAMETER here: defaults belong in each - * consuming plugin's Config, per the no-hardcoded-tunables rule. - * + * Shared machinery for OUT-OF-PROCESS subagent backends — providers that spawn an external + * agent as a child process and must keep the parent deployment's credentials out of it, tear + * it down to quiescence, and isolate it from the host user's on-disk CLI state. This package + * registers no provider; consuming plugins own and validate every timing or path default. * @module @deepseek-ai/dsh-subagent-subprocess */ @@ -50,11 +39,8 @@ export function buildChildEnv(extra: Record): NodeJS.ProcessEnv } /** - * Capture the child's spawn-level failure as a promise the run's result path - * can race. A spawn failure (e.g. `ENOENT` for a bad command) is emitted as an - * `error` EVENT, not a thrown exception — and without a listener Node treats - * it as an unhandled error and crashes the parent process. Call this in the - * SAME TICK as `spawn()`, so no window exists for the event to fire unheard. + * Capture the child's spawn-level `error` event as a promise. Call in the same tick as + * `spawn()`; otherwise an early event can be unhandled and crash the parent. * @param child - the just-spawned child process. * @returns a promise that RESOLVES (never rejects) with the child's first * `error` event; for a child that spawns cleanly it never settles. @@ -123,15 +109,8 @@ export interface DisposeLadderGraces { } /** - * Tear a child process down to QUIESCENCE: resolves only once the child has - * actually exited (or was already gone), never merely after requesting it. - * Three-tier escalation — - * - * 1. stdin EOF (when stdin is piped), then wait `disposeEofGraceMs`: a - * cooperative child quiesces on its own, its teardown and flushes intact; - * 2. `SIGTERM`, then wait `disposeGraceMs`; - * 3. `SIGKILL`, then await the (now-certain) exit — a child that ignores EOF - * and traps `SIGTERM` must not wedge dispose forever. + * Tear a child process down to quiescence, resolving only after exit: close stdin and allow + * cooperative flush, then send `SIGTERM`, then `SIGKILL` and await the forced exit. * * @param child - the child process to tear down. * @param graces - the two grace periods, from the consuming plugin's Config. @@ -139,10 +118,7 @@ export interface DisposeLadderGraces { export async function disposeChildProcess(child: ChildProcess, graces: DisposeLadderGraces): Promise { // Already gone: nothing to reap. if (child.exitCode !== null || child.signalCode !== null) return - // 1. Graceful: end the request stream (stdin EOF) and let the child quiesce - // on its own. Sending SIGTERM in the same tick (or too soon) would - // default-terminate a cooperative child mid-flush, orphaning its nested - // work. A child spawned without a stdin pipe skips straight to the wait. + // 1. Close stdin and allow cooperative teardown and durable-state flush. child.stdin?.end() if (await exitsWithin(child, graces.disposeEofGraceMs)) return // 2. SIGTERM, escalating if the child still does not exit within the grace. @@ -171,16 +147,9 @@ export interface IsolatedConfigDir { } /** - * An isolated config dir for one child run, so the child's behavior is a - * function of deployment config alone — never of whatever `~/.claude` / - * `~/.codex`-style state happens to exist on the host machine. Two modes: - * - * - no `pinnedPath` (the default): creates a FRESH private (0700) `mkdtemp` - * dir under the OS temp root; {@link IsolatedConfigDir.remove} deletes it - * best-effort; - * - `pinnedPath` set (a deployment deliberately sharing state across runs): - * the pinned path is returned as-is — never created, never removed — the - * deployment owns that directory's lifecycle. + * An isolated config dir for one child run, independent of host CLI state. Without + * `pinnedPath`, creates a private temp directory and removes it best-effort; a pinned directory + * is returned unchanged and remains deployment-owned. * * @param prefix - the `mkdtemp` name prefix for a fresh dir (e.g. * `dsh-subagent-codex-`); ignored when `pinnedPath` is set. @@ -207,10 +176,8 @@ export async function createIsolatedConfigDir(prefix: string, pinnedPath?: strin try { await rm(path, { recursive: true, force: true }) } catch { - // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — - // e.g. the dead child left an unreadable entry behind). The dir lives - // under the OS temp root, which reclaims it; failing dispose over - // cleanup would be worse than a leftover temp dir. + // Best-effort by contract: swallows rm failures (EACCES/EBUSY-style — e.g. the dead + // child left an unreadable entry behind). } }, } diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index ff54bc109a..59821147af 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -1,34 +1,11 @@ /** - * The model-facing `subagent` tool: delegate a task to a child agent and return - * its final output. Pure schema + lifecycle shaping — every transport concern - * lives behind the `ctx.subagents` provider registry - * (`@deepseek-ai/dsh-subagent`), so an in-process, ACP, or future A2A backend - * swaps in without touching what the model sees. - * - * Provider selection is config, not model-facing: this plugin is bound to - * EXACTLY ONE provider name (`Config.provider`). To expose more than one - * transport, load the plugin more than once, each bound to a different provider - * — there is no provider/type parameter in the model-facing schema. The model - * sees only `{ description, prompt }`. - * - * The tool DESCRIPTION is derived from the bound provider's conversation-history - * descriptor ({@link SubagentProvider.inheritsParentContext}): a - * fresh-conversation provider (spawn, ACP) gets the standalone-prompt wording, - * while a seeded-conversation provider (fork) tells the model the child already - * sees the conversation's completed turns. This descriptor says nothing about - * Cordis scope, services, tools, or authority. The tool MIRRORS the - * provider's lifecycle via `subagent/provider-added`/`-removed` — it registers - * when the provider is (or becomes) available and unregisters when the - * provider goes away — so no load-order requirement exists and an HMR reload - * of the backend re-derives the wording from the fresh provider. - * - * Collection is SYNCHRONOUS this cut: `execute` starts a run and awaits - * `run.result` inside a `try/finally` that always disposes the run, so the - * owned child agent/session is torn down on every path (success, error, abort) - * and never leaks as a live idle child. A non-`completed` stop reason maps to an - * `isError` tool result (by throwing) rather than returning partial output as - * success. + * Model-facing delegation tool bound by configuration to one provider; transport selection is not + * exposed in its `{ description, prompt }` schema. Provider lifecycle controls registration and + * re-derives conversation-history wording after reload, so load order is irrelevant. * + * Execution synchronously awaits the child result and always disposes the run. Non-completed stop + * reasons become error results, while transport details remain behind `ctx.subagents`. Load this + * plugin more than once to expose multiple configured providers. * @module @deepseek-ai/dsh-tool-subagent */ @@ -105,16 +82,8 @@ export const Config: z = z.object({ model: z.string(), }).default(undefined as unknown as { model: string }), persona: z.string(), - // A schemastery object materializes {} (with [] for nested arrays) when the - // key is omitted — for toolFilter that would mean an EMPTY ALLOW-LIST, i.e. - // deny-everything, silently. Force the omitted key to stay absent (the same - // shape discipline as SystemPrompt's toolOrder); the cast is needed because - // .default() expects the object type. - // The NESTED arrays get the same treatment as the object itself: a partial - // filter ({deny: […]}) must not materialize allow: [] beside it — an empty - // allow-list means deny-EVERYTHING, so the materialized default would turn - // a deny-one config into deny-all. An EXPLICIT allow: [] (grant-only - // children) survives, since only the omitted key defaults to undefined. + // Schemastery otherwise materializes omitted objects and nested arrays as `{ allow: [] }`, which + // silently means deny all. Preserve omission while retaining an explicit empty allow-list. toolFilter: z.object({ allow: z.array(z.string()).default(undefined as unknown as string[]), deny: z.array(z.string()).default(undefined as unknown as string[]), @@ -290,7 +259,7 @@ export function apply(ctx: Context, config: Config): void { if (present !== undefined) { mount(present) } else { - // Not an error: the backend's fiber may simply activate after this one. + // Not an error: the backend's fiber may activate after this one. // The tool appears the moment the provider registers; a typo'd provider // name shows up as this note plus a tool that never materializes. ctx.logger.info(`subagent provider "${config.provider}" not registered yet; the "${config.toolName ?? 'subagent'}" tool will register when it appears`) From d0df50aa8eee0c98b7eacadcdbc797f7780de565 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:43:53 +0800 Subject: [PATCH 086/163] fix: retain LLM HTTP status metadata --- packages/llm/llm-deepseek/src/adapter.ts | 2 +- packages/llm/llm-deepseek/tests/adapter.spec.ts | 7 ++++++- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/index.ts | 6 ++++-- packages/llm/llm/tests/service.spec.ts | 5 ++--- packages/support/llm-replay/src/index.ts | 4 ++-- packages/support/llm-replay/tests/llm-replay.spec.ts | 10 +++++----- 7 files changed, 21 insertions(+), 15 deletions(-) diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index f6051d1ccd..30760a8fbc 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -76,7 +76,7 @@ export class DeepSeekAdapter extends LlmAdapter { // Only swallow error-body parsing: status and code are already captured, // so malformed gateway JSON must not mask the actionable HTTP failure. } - throw new LlmError(message, code) + throw new LlmError(message, code, response.status) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 1f1aef3f05..46f123a1c7 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -158,7 +158,7 @@ describe('DeepSeekAdapter against a mock server', () => { status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior]) + const server = await mockServer([behavior, behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -166,6 +166,11 @@ describe('DeepSeekAdapter against a mock server', () => { assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) + // The numeric HTTP status is carried on the error for explicit handling. + await expect( + assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) + .catch((error: unknown) => (error as LlmError).status), + ).resolves.toBe(status) }) it('keeps the status-line message for JSON error bodies without a message', async () => { diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index c8f2002f9f..296fd0c3d3 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -42,7 +42,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. +- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. ### Real adapters diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 8f2c9b4f39..08f3f54c51 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -42,10 +42,12 @@ declare module 'cordis' { /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the - * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy; + * `status` carries the HTTP status when the error originated from a non-2xx + * provider response (absent for protocol/usage errors that have no HTTP status). */ export class LlmError extends HarnessError { - constructor(message: string, code: string, options?: ErrorOptions) { + constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { super(message, code, options) this.name = 'LlmError' } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 125a810261..f669069c44 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -79,12 +79,11 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const cause = new Error('root cause') - const err = new LlmError('boom', 'AUTH', { cause }) + const err = new LlmError('boom', 'AUTH', 401) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.cause).toBe(cause) + expect(err.status).toBe(401) }) it('HarnessError carries a code, names itself by subclass, and chains cause', async () => { diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index b7e4e30a46..2e509973e5 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -20,7 +20,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } | { kind: 'hang' } /** Resolved plugin configuration. */ @@ -221,7 +221,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) if (signal?.aborted) throw new Error('aborted') yield chunk } - throw new LlmError(entry.message, entry.code) + throw new LlmError(entry.message, entry.code, entry.status) case 'hang': // Replay a stream that stalls until cancelled (mirrors MockAdapter): one // chunk, then wait for abort and surface it as the consumer expects. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index aa3fea20d5..ac52ec11c9 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -175,7 +175,7 @@ describe('loadReplayScript', () => { it('uses the sidecar override when present, ignoring the JSONL', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') - const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }] writeFileSync(overrideFile, JSON.stringify(override), 'utf8') expect(loadReplayScript({ file, overrideFile })).toEqual(override) }) @@ -231,12 +231,12 @@ describe('installLlmReplay (through the real waterfall)', () => { expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(second) }) - it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) @@ -245,7 +245,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) expect(seen).toEqual(partial) }) @@ -350,7 +350,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) From cb177090f9573b30947af31daa876d586bea844a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 23:53:23 +0800 Subject: [PATCH 087/163] docs: refresh subagent config catalog --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 093e534d23..d9e42366ba 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -915,7 +915,7 @@ export interface Config { Depends on: [`AgentOptions`](../packages/core/agent/src/index.ts) -Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:24`](../packages/subagent/tool-subagent/src/index.ts) ## `@deepseek-ai/dsh-tool-web` From 0892831177c895b4139cb61b9c20a32b465fc923 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:01:00 +0800 Subject: [PATCH 088/163] docs: refresh LLM service catalog --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index de6bf20fd1..ca83b3c489 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -127,7 +127,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` From ff064917a25fe72af8ba109847fa822220cc4330 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 00:18:04 +0800 Subject: [PATCH 089/163] docs: refresh LLM service catalog --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index de6bf20fd1..ca83b3c489 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -127,7 +127,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` From 8e7cf8cc10c3559ded0ee7f93f62d14ff5f18ad4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:09:18 +0800 Subject: [PATCH 090/163] Update ACP launcher example path --- packages/support/acp-snapshot/src/launcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/support/acp-snapshot/src/launcher.ts b/packages/support/acp-snapshot/src/launcher.ts index c0dc21dc06..95292df38a 100644 --- a/packages/support/acp-snapshot/src/launcher.ts +++ b/packages/support/acp-snapshot/src/launcher.ts @@ -27,7 +27,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) /** The unbuilt agent entry, leaf config, and workspace tsconfig an ACP test boots. */ export interface AgentUnderTest { - /** The agent bin entry (for example `packages/ui/acp-agent/src/bin.ts`). */ + /** The agent bin entry (for example `packages/examples/acp-demo/src/bin.ts`). */ binScript: string /** The leaf `cordis.yml` loaded by the bin. */ configPath: string From f6350ac553dc9f34b7c397eb62991e2566f1ca6b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:10:46 +0800 Subject: [PATCH 091/163] Keep root instructions within budget --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 0c5272b105..4209b0f95c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ pnpm run demo:cordis # self-referential demo: the agent modifies its own runt pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ``` -### Run the CI gates locally before marking a PR ready +### Run CI gates locally before marking a PR ready Run narrow checks during implementation and this CI-equivalent sequence before marking a PR ready. Fresh worktrees need `pnpm run build` before publint and NodeNext inspect `lib/`: From d25a56b431d98a6df3ff242df6386975acce05d4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:23:39 +0800 Subject: [PATCH 092/163] Restore CompactionResult diagnostics --- packages/compact/compact-basic/src/index.ts | 6 +++++- .../compact-basic/tests/compact-basic.spec.ts | 4 +++- packages/compact/compact/src/index.ts | 2 +- packages/compact/compact/src/types.ts | 8 ++++++++ packages/compact/compact/tests/compact.spec.ts | 16 ++++++++++++---- 5 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 9449fc84f9..8bc21ddf5b 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -426,9 +426,13 @@ export class BasicCompactService extends CompactService { // compact/start and here leaves a detectable orphaned lock (a compact/start // with no matching compact/end) rather than a compact/end that falsely // claims compaction finished before the surface replacement landed. - session.append('compact/end', { turn: openTurn }) + const endEvent = session.append('compact/end', { turn: openTurn }) return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + endSeq: endEvent.seq, + summary, shadowedRange: { start, end }, shadowedSeqs, shadowedTokenCount, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 7cb872f274..4b06b321b7 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -346,6 +346,7 @@ describe('BasicCompactService.compactRegion', () => { expect(result.shadowedSeqs).toEqual([firstSeq, secondSeq]) expect(result.shadowedRange.start).toBe(firstSeq) expect(result.shadowedRange.end).toBe(secondSeq) + expect(result.summary).toEqual(svc.mockSummary) expect(result.shadowedTokenCount).toBe(20) const events = session.events @@ -1057,7 +1058,8 @@ describe('BasicCompactService.summarize (real ctx.llm.stream)', () => { const session = multiTurnSession(2, 1) const nodes = session.surface.nodes - await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') + const result = await compactRegion(svc, session, nodes[0]!, nodes[1]!, 'test-model') + expect(result.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) const summaryEvent = session.events.findLast(e => e.type === 'compact/summary')! expect(summaryEvent.data.summary).toEqual([{ type: 'text', text: 'CONDENSED' }]) // The raw summary is wrapped in the checkpoint framing on the surface. diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index e4baec6551..680f684ac6 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -73,7 +73,7 @@ export abstract class CompactService extends Service { * @param agent - context whose session is mutated and whose routing options guide summarization. * @param signal - optional cancellation; model-backed implementations must forward it. * @throws when compaction is active or the range is missing, reversed, or unbalanced. - * @returns the replaced range and token accounting; the durable event owns the summary. + * @returns the appended event seqs, summary, replaced range, and token accounting. */ abstract compactRegion( start: number, diff --git a/packages/compact/compact/src/types.ts b/packages/compact/compact/src/types.ts index 0d3d35ace8..10a5eabfcc 100644 --- a/packages/compact/compact/src/types.ts +++ b/packages/compact/compact/src/types.ts @@ -41,6 +41,14 @@ declare module '@deepseek-ai/dsh-session' { /** Result of a successful compaction operation. */ export interface CompactionResult { + /** The seq of the appended `compact/start` event. */ + startSeq: number + /** The seq of the appended `compact/summary` event. */ + summarySeq: number + /** The seq of the appended `compact/end` event. */ + endSeq: number + /** The summary content blocks produced by the backend. */ + summary: ContentBlock[] /** * The surface-boundary pair that was shadowed: the seqs of the first * (`start`) and last (`end`) surface nodes of the replaced range. A diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index e34c8420d9..3d95c12249 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -34,17 +34,22 @@ class StubCompactService extends CompactService { ): Promise { this.lastSignal = signal const session = agent.session + const summary = [{ type: 'text' as const, text: 'stub' }] // Minimal stub honoring the lock + log-only event contract. - session.append('compact/start', { turn: 0 }) - session.append('compact/summary', { - summary: [{ type: 'text', text: 'stub' }], + const startEvent = session.append('compact/start', { turn: 0 }) + const summaryEvent = session.append('compact/summary', { + summary, shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, model: 'stub', }) - session.append('compact/end', { turn: 0 }) + const endEvent = session.append('compact/end', { turn: 0 }) return { + startSeq: startEvent.seq, + summarySeq: summaryEvent.seq, + endSeq: endEvent.seq, + summary, shadowedRange: { start, end }, shadowedSeqs: [], shadowedTokenCount: 0, @@ -92,6 +97,9 @@ describe('CompactService seam', () => { // verify the runtime value is absent. const raw = startEvent as unknown as { surfaceOp?: unknown } expect(raw.surfaceOp).toBeUndefined() + expect(result.summary).toEqual([{ type: 'text', text: 'stub' }]) + expect(result.summarySeq).toBeGreaterThan(result.startSeq) + expect(result.endSeq).toBeGreaterThan(result.summarySeq) expect(result.shadowedRange).toEqual({ start: 0, end: 0 }) expect(session.events.filter(e => e.type.startsWith('compact/')).map(e => e.type)) .toEqual(['compact/start', 'compact/summary', 'compact/end']) From 8eccd5416e58d1ea63f6dfb411a247ff6c272ced Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:25:39 +0800 Subject: [PATCH 093/163] Document restored compaction result fields --- docs/core-data-structures/compaction.md | 10 +++++++++- packages/cordis/tool-cordis/src/api-catalog.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index d253166fcf..1075f3c985 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -20,10 +20,18 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b ## `CompactionResult` -What a successful compaction returns to its caller: the shadowed range and seqs plus the estimated token count. The durable `compact/summary` event owns the raw summary and bookkeeping-event identity. +What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. ```ts type-equiv interface CompactionResult { + /** The seq of the appended `compact/start` event. */ + startSeq: number + /** The seq of the appended `compact/summary` event. */ + summarySeq: number + /** The seq of the appended `compact/end` event. */ + endSeq: number + /** The summary content blocks produced by the backend. */ + summary: ContentBlock[] /** * The surface-boundary pair that was shadowed: the seqs of the first * (`start`) and last (`end`) surface nodes of the replaced range. A diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 8e6477258e..bfa494f829 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -631,7 +631,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'CompactionResult', - declaration: 'export interface CompactionResult {\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', + declaration: 'export interface CompactionResult {\n startSeq: number;\n summarySeq: number;\n endSeq: number;\n summary: ContentBlock[];\n shadowedRange: {\n start: number;\n end: number;\n };\n shadowedSeqs: number[];\n shadowedTokenCount: number;\n}', }, { name: 'ConfinedArgv', From cdeec6fc1d984adfdbd645d1a481dd0a2a9fdba8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:36:51 +0800 Subject: [PATCH 094/163] Update built JSON-RPC probe path --- packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts index bc6642263e..ed4dfbb9db 100644 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -33,7 +33,7 @@ const [ { SessionId }, ] = await Promise.all([ load("vendor/cordis/lib/index.js"), - load("packages/core/agent-core/lib/index.js"), + load("packages/examples/agent-spine-demo/lib/index.js"), load("packages/subagent/subagent/lib/index.js"), load("packages/session-persistence/session-persistence-jsonl/lib/index.js"), load("packages/ui/jsonrpc/lib/index.js"), From 87c900dfb2a8b73f153828a01c9fbd90cd962311 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 17:55:48 +0800 Subject: [PATCH 095/163] Document compaction result diagnostics --- packages/compact/compact/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index 51ba50382f..0e0b63d765 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -21,6 +21,8 @@ Both methods are **abstract** — the backend owns the entire strategy (token es | `compactIfNeeded(agent, fullSystemPrompt, sessionPrefix, signal)` | Estimate the surface-derived history size; if over the backend's threshold, compact an older range via `compactRegion`, keeping recent context intact. Returns the `CompactionResult`, or `null` if nothing needed compacting. All parameters required — the loop's `agent/pre-step` checkpoint supplies the agent, assembled `fullSystemPrompt`, composed `sessionPrefix` (request-only messages every request carries but the derived history omits — the pressure estimate must count them), and turn `signal`. A backend's summarization request is a direct `ctx.llm.stream()` call (not a loop step), so per-call interception happens at `llm/stream`. | | `compactRegion(start, end, agent, signal?)` | Forcibly summarize surface nodes `[start, end]` (inclusive seqs) from `agent.session` into a single replacement node. **Throws** if a compaction is already in progress, if `start`/`end` aren't surface nodes, or if `start` is positioned after `end` on the surface. The range is a SURFACE-POSITION span, not a numeric seq interval — after a prior replace lands a fresh high-seq summary node at the shadowed range's position, surface order no longer tracks seq order. | +`CompactionResult` keeps the raw summary and bookkeeping-event seqs available to callers alongside the shadowed range and token accounting; its drift-checked shape lives in the [compaction data-structure reference](../../../docs/core-data-structures/compaction.md#compactionresult). + `compactIfNeeded` takes a required `signal`; `compactRegion`'s is optional. A backend that summarizes via `ctx.llm.stream()` **must** forward it into the call's `GenerateOptions.signal`, so an abort or fiber dispose tears down the in-flight summarization instead of leaving an orphaned model call running past the cancellation. The session being compacted comes from the agent context; the turn that the `compact/*` events belong to is recoverable from the log (the currently-open turn), so the backend stamps it from the log rather than trusting a caller-supplied value. ## Surface contract From 54fb96ef04df12bf59c69b14de24bcba8ada98d4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 04:46:35 +0800 Subject: [PATCH 096/163] refactor: name front door defaults --- packages/examples/acp-demo/src/index.ts | 7 +++---- packages/examples/stdio-demo/src/index.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 0439363301..a26220dbf7 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -18,6 +18,7 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' export const name = 'acp-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' /** * App config: the swappable per-deployment values. `model` configures the @@ -54,9 +55,7 @@ export const Config: z = z.object({ // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, - // TODO(single-default-literal): share this schema default and the defensive - // apply() fallback through one named constant while retaining both boundaries. - persistenceRoot: z.string().default('./.sessions'), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), skills: agentCore.SkillConfigSchema, }) /* jscpd:ignore-end */ @@ -76,6 +75,6 @@ export function apply(ctx: Context, config: Config): void { ...config.skills !== undefined ? { skills: config.skills } : {}, }) ctx.plugin(UserInteractionService) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(acp, { model: config.model }) } diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 742371ecd5..c54f9499c6 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -23,6 +23,8 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from '@deepseek-ai/dsh-stdio' export const name = 'stdio-demo' +const DEFAULT_PERSISTENCE_ROOT = './.sessions' +const DEFAULT_WELCOME = 'ready.' /** * App config: the swappable per-demo values, each routed to where the app wires @@ -65,10 +67,8 @@ export const Config: z = z.object({ // schemastery's native [] default would read as an invalid configured list. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), tools: ToolRegistry.Config, - // TODO(single-default-literal): share these schema defaults and defensive - // apply() fallbacks through named constants while retaining both boundaries. - persistenceRoot: z.string().default('./.sessions'), - welcome: z.string().default('ready.'), + persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + welcome: z.string().default(DEFAULT_WELCOME), skills: agentCore.SkillConfigSchema, resumeSessionId: z.string(), }) @@ -85,10 +85,10 @@ export function apply(ctx: Context, config: Config): void { const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) ctx.plugin(ConsoleExporter) - ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) + ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT }) ctx.plugin(UserInteractionService) ctx.plugin(uiStdio, { - welcome: config.welcome ?? 'ready.', + welcome: config.welcome ?? DEFAULT_WELCOME, sessionId, }) ctx.plugin(agentCore, { From 732735b27fb23fe90ee7f6c9853c7953b8879fdc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:32:15 +0800 Subject: [PATCH 097/163] docs: align default fallback test comments --- packages/examples/acp-demo/tests/acp-agent.spec.ts | 2 +- packages/examples/stdio-demo/tests/stdio-agent.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index f3495c827c..ab36aa56c8 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -82,7 +82,7 @@ describe('dsh-acp-demo composition', () => { }) it('defaults the persistence root when omitted', async () => { - // Exercises the `?? './.sessions'` fallback for a direct-apply caller that + // Exercises the `DEFAULT_PERSISTENCE_ROOT` fallback for a direct-apply caller that // bypasses the schema's `.default(...)`: call `apply` directly (not via // `ctx.plugin`, which validates+defaults the config first) with no // persistenceRoot, so the runtime fallback is the one that fires. diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index 79681fd954..b2398fea7c 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -100,7 +100,7 @@ describe('dsh-stdio-demo app', () => { it('defaults persistenceRoot and welcome when omitted', async () => { // Direct apply (NOT via ctx.plugin, which validates+defaults the config - // first) so the runtime `?? './.sessions'` / `?? 'ready.'` fallbacks on + // first) so the runtime `DEFAULT_PERSISTENCE_ROOT` / `DEFAULT_WELCOME` fallbacks on // apply()'s last two lines are the ones that fire — covering a // schema-bypassing direct-mount caller. const ctx = new Context() From 29d545eb212e88f9589134be420eff3ed8673b81 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 19:52:45 +0800 Subject: [PATCH 098/163] Refresh front-door config catalog --- docs/config-catalog.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 29be66bbbe..5bbdbe5fd3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -63,7 +63,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/acp-demo/src/index.ts:31`](../packages/examples/acp-demo/src/index.ts) +Source: [`packages/examples/acp-demo/src/index.ts:32`](../packages/examples/acp-demo/src/index.ts) ## `@deepseek-ai/dsh-agent-loop` @@ -722,7 +722,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) -Source: [`packages/examples/stdio-demo/src/index.ts:37`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:39`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` From bb1c8fa27da12f2432afaf3d5c04fd8299df73e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:09:14 +0800 Subject: [PATCH 099/163] fix(llm): restore pruned status contract --- docs/cordis-catalog/services.md | 2 +- packages/llm/llm-deepseek/src/adapter.ts | 6 +++--- packages/llm/llm-deepseek/tests/adapter.spec.ts | 7 +------ packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/index.ts | 6 ++---- packages/llm/llm/tests/service.spec.ts | 5 +++-- packages/support/llm-replay/src/index.ts | 4 ++-- packages/support/llm-replay/tests/llm-replay.spec.ts | 10 +++++----- 9 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 18e00b7904..48fb887d27 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -128,7 +128,7 @@ stream(options: GenerateOptions): AsyncIterable Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) ## `ctx.permission` — `PermissionService` diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 30760a8fbc..978dec65c8 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -73,10 +73,10 @@ export class DeepSeekAdapter extends LlmAdapter { const parsed = await response.json() as WireError if (parsed.error?.message) message = parsed.error.message } catch { - // Only swallow error-body parsing: status and code are already captured, - // so malformed gateway JSON must not mask the actionable HTTP failure. + // Only swallow error-body parsing: the stable code and status-line message + // are already captured, so malformed gateway JSON must not mask the failure. } - throw new LlmError(message, code, response.status) + throw new LlmError(message, code) } if (!response.body) { throw new LlmError('DeepSeek API returned no response body', 'EMPTY_RESPONSE') diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 46f123a1c7..1f1aef3f05 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -158,7 +158,7 @@ describe('DeepSeekAdapter against a mock server', () => { status, body: JSON.stringify({ error: { message: `failed with ${status}`, type: 't', code: 'c' } }), } - const server = await mockServer([behavior, behavior, behavior]) + const server = await mockServer([behavior, behavior]) const ctx = await harness(server.url) await expect(assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] })) .rejects.toThrow(`failed with ${status}`) @@ -166,11 +166,6 @@ describe('DeepSeekAdapter against a mock server', () => { assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) .catch((error: unknown) => (error as LlmError).code), ).resolves.toBe(code) - // The numeric HTTP status is carried on the error for explicit handling. - await expect( - assemble(ctx,{ model: 'deepseek-v4-flash', messages: [] }) - .catch((error: unknown) => (error as LlmError).status), - ).resolves.toBe(status) }) it('keeps the status-line message for JSON error bodies without a message', async () => { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index dd1cb5b48c..281a4f13be 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -55,6 +55,6 @@ Unit suites run against a local `node:http` mock SSE server (pi-ai's openai SDK - **`tool_choice` is not mapped** — same MVP contract as llm-deepseek. - **In-history `system`-role messages fold into `user`-role wire messages** — pi-ai exposes a single `systemPrompt` slot, diverging from the hand-rolled twin's `role: 'system'` passthrough. -- **`LlmError.status` is never set** — pi-ai reports failures as in-stream events with no HTTP status, so error codes are regex-classified from the error text. +- **Provider HTTP status is unavailable** — pi-ai reports failures as in-stream events, so stable error codes are regex-classified from the error text. - **`buildModel` hardcodes descriptor metadata** — `contextWindow: 128000`, `maxTokens: 64000`, zero cost, identically for every registered model name; not configurable. - **pi-ai's built-in retries are disabled (`maxRetries: 0`)** — failures surface immediately; retry policy belongs to `llm/stream` listeners. diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 296fd0c3d3..c8f2002f9f 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -42,7 +42,7 @@ Every product adapter sends application identity on provider HTTP requests. `att - `LlmAdapter` — abstract base class for provider adapters. The only required method is `stream()`. - `BlockAssembler` — incrementally assembles raw chunks into complete content blocks and an assistant message. The agent loop feeds it raw chunks (logging them for replay) while reading the assembled blocks/message for history. - `HarnessError` — base class for the harness error taxonomy: a stable `code` string (distinct from the human `message`) plus `cause` chaining. Lives here, in the leaf package every other imports, so a single base is shared without a new dependency edge. Per-package errors (`LlmError`, `ToolArgsError`, `InvariantError`, …) extend it. `isHarnessError(value)` narrows at seams. -- `LlmError` — extends `HarnessError`; `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) plus an optional numeric `status` when the failure came from a non-2xx provider response. +- `LlmError` — extends `HarnessError`; its stable `code` string (`NO_ADAPTER`, `DUPLICATE_ADAPTER`, and adapter codes like `AUTH`/`RATE_LIMIT`) is the programmatic failure contract. ### Real adapters diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 08f3f54c51..8f2c9b4f39 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -42,12 +42,10 @@ declare module 'cordis' { /** * Typed error for LLM-related failures. Extends {@link HarnessError}, so the - * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy; - * `status` carries the HTTP status when the error originated from a non-2xx - * provider response (absent for protocol/usage errors that have no HTTP status). + * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy. */ export class LlmError extends HarnessError { - constructor(message: string, code: string, public status?: number, options?: ErrorOptions) { + constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) this.name = 'LlmError' } diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index f669069c44..125a810261 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -79,11 +79,12 @@ describe('LlmService', () => { it('LlmError extends the shared HarnessError base', async () => { const { HarnessError, isHarnessError } = await import('@deepseek-ai/dsh-llm') - const err = new LlmError('boom', 'AUTH', 401) + const cause = new Error('root cause') + const err = new LlmError('boom', 'AUTH', { cause }) expect(err).toBeInstanceOf(HarnessError) expect(isHarnessError(err)).toBe(true) expect(err.code).toBe('AUTH') - expect(err.status).toBe(401) + expect(err.cause).toBe(cause) }) it('HarnessError carries a code, names itself by subclass, and chains cause', async () => { diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 2e509973e5..b7e4e30a46 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -20,7 +20,7 @@ import { LlmError, assertNever } from '@deepseek-ai/dsh-llm' */ export type ReplayEntry = | { kind: 'chunks'; chunks: StreamChunk[] } - | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string; status?: number } + | { kind: 'throw'; chunks: StreamChunk[]; message: string; code: string } | { kind: 'hang' } /** Resolved plugin configuration. */ @@ -221,7 +221,7 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) if (signal?.aborted) throw new Error('aborted') yield chunk } - throw new LlmError(entry.message, entry.code, entry.status) + throw new LlmError(entry.message, entry.code) case 'hang': // Replay a stream that stalls until cancelled (mirrors MockAdapter): one // chunk, then wait for abort and surface it as the consumer expects. diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index ac52ec11c9..aa3fea20d5 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -175,7 +175,7 @@ describe('loadReplayScript', () => { it('uses the sidecar override when present, ignoring the JSONL', () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') - const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH', status: 401 }] + const override: ReplayEntry[] = [{ kind: 'throw', chunks: [], message: '401', code: 'AUTH' }] writeFileSync(overrideFile, JSON.stringify(override), 'utf8') expect(loadReplayScript({ file, overrideFile })).toEqual(override) }) @@ -231,12 +231,12 @@ describe('installLlmReplay (through the real waterfall)', () => { expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(second) }) - it('replays a sidecar throw-entry as an LlmError with code/status, after its prefix chunks', async () => { + it('replays a sidecar throw-entry as an LlmError with its stable code, after its prefix chunks', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) @@ -245,7 +245,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const seen: StreamChunk[] = [] await expect((async () => { for await (const c of ctx.llm.stream({ model: 'm', messages: [] })) seen.push(c) - })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH', status: 401 }) + })()).rejects.toMatchObject({ message: 'unauthorized', code: 'AUTH' }) expect(seen).toEqual(partial) }) @@ -350,7 +350,7 @@ describe('installLlmReplay (through the real waterfall)', () => { const overrideFile = join(dir, 'replay.override.json') const partial: StreamChunk[] = [{ type: 'block-start', index: 0, blockType: 'text' }] writeFileSync(overrideFile, JSON.stringify([ - { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH', status: 401 }, + { kind: 'throw', chunks: partial, message: 'unauthorized', code: 'AUTH' }, ]), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) From f8cc1af99b9b8a5681f93e8939bf8e93f98a4011 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:36:57 +0800 Subject: [PATCH 100/163] test(tasks): align tool fixtures with unified identity --- .../tasks/tool-tasks/tests/tool-tasks.spec.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index 1b5e221a3c..0a2d89431e 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -5,6 +5,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' import TaskService from '@deepseek-ai/dsh-tasks' import type { TaskHooks, TaskOutcome, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' @@ -23,17 +24,17 @@ async function setup(config: ToolTasks.Config = {}) { } /** - * A fake agent whose session token is `sessionId`, registered in `ctx.agents`. - * The agent id is deliberately different so session authorization and exact - * lifecycle ownership cannot be confused in tests. + * A fake agent with the shared agent/session identity, registered in + * `ctx.agents` with a dedicated lifecycle scope. */ function fakeAgent(ctx: Context, sessionId: string, inject: (...args: unknown[]) => void = () => {}): Agent { const scopeFiber = ctx.plugin(() => {}) + const id = SessionId(sessionId) const agent = { - id: `agent-${sessionId}`, + id, ctx: scopeFiber.ctx, inject, - session: { header: { version: 0, id: sessionId, createdAt: 0 } }, + session: { id, header: { version: 0, id, createdAt: 0 } }, } as unknown as Agent agentRegistryDisposers.set(agent, ctx.agents.register(agent)) return agent @@ -276,7 +277,7 @@ describe('completion notices', () => { await tick() // Disposed owner: inject throws the disposed message — contained. - const inject = vi.fn(() => { throw new Error('agent "agent-sess-1" is disposed') }) + const inject = vi.fn(() => { throw new Error('agent "sess-1" is disposed') }) const owner = fakeAgent(ctx, 'sess-1', inject) const p = producer({ owner }) ctx.tasks.start(p.spec) @@ -287,7 +288,7 @@ describe('completion notices', () => { it('does not route an old owner completion notice to a same-session replacement', async () => { const { ctx } = await setup() - const oldInject = vi.fn(() => { throw new Error('agent "agent-shared" is disposed') }) + const oldInject = vi.fn(() => { throw new Error('agent "shared" is disposed') }) const oldOwner = fakeAgent(ctx, 'shared', oldInject) const p = producer({ owner: oldOwner }) ctx.tasks.start(p.spec) From 8e37abb6c9dffa1fa2d5a24d05aa9eef4dde88e3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:50:33 +0800 Subject: [PATCH 101/163] docs: refresh module graph for unified identity --- docs/module-graph.md | 26 ++++++++++++++++---------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index 199f11e8d3..baa714bad1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -205,6 +205,7 @@ flowchart TD pkg_workflow --> pkg_agent pkg_workflow --> pkg_brand pkg_workflow --> pkg_llm + pkg_workflow --> pkg_session pkg_tools --> pkg_agent pkg_tools --> pkg_code_runtime pkg_tools --> pkg_llm @@ -219,10 +220,6 @@ flowchart TD pkg_permission --> pkg_sandbox pkg_permission --> pkg_session pkg_permission --> pkg_user_approval - pkg_stdio --> pkg_agent - pkg_stdio --> pkg_llm - pkg_stdio --> pkg_session - pkg_stdio --> pkg_user_interaction pkg_agent_loop --> pkg_agent pkg_agent_loop --> pkg_llm pkg_agent_loop --> pkg_scope @@ -250,6 +247,7 @@ flowchart TD pkg_subagent --> pkg_agent pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope + pkg_subagent --> pkg_session pkg_subagent --> pkg_tools pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -296,6 +294,7 @@ flowchart TD pkg_tool_workflow --> pkg_workflow pkg_subagent_acp --> pkg_agent pkg_subagent_acp --> pkg_llm + pkg_subagent_acp --> pkg_session pkg_subagent_acp --> pkg_subagent pkg_subagent_acp --> pkg_subagent_subprocess pkg_subagent_inprocess --> pkg_agent @@ -317,12 +316,18 @@ flowchart TD pkg_hooks_claude --> pkg_tools pkg_subagent_mock --> pkg_agent pkg_subagent_mock --> pkg_llm + pkg_subagent_mock --> pkg_session pkg_subagent_mock --> pkg_subagent pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent + pkg_stdio --> pkg_agent + pkg_stdio --> pkg_agent_loop + pkg_stdio --> pkg_llm + pkg_stdio --> pkg_session + pkg_stdio --> pkg_user_interaction pkg_agent_spine_demo --> pkg_agent pkg_agent_spine_demo --> pkg_agent_loop pkg_agent_spine_demo --> pkg_invariants @@ -356,6 +361,7 @@ flowchart TD pkg_acp_demo --> pkg_tools pkg_acp_demo --> pkg_user_interaction pkg_stdio_demo --> pkg_agent + pkg_stdio_demo --> pkg_agent_loop pkg_stdio_demo --> pkg_agent_spine_demo pkg_stdio_demo --> pkg_app_boot pkg_stdio_demo --> pkg_llm @@ -412,16 +418,15 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | -| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm) | +| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) | | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`sandbox`](../packages/sandbox/sandbox) | | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`user-approval`](../packages/ui/user-approval) | -| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-bash`](../packages/bash/tool-bash) | `bash` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | @@ -433,15 +438,16 @@ flowchart TD | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | -| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | +| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-subprocess`](../packages/subagent/subagent-subprocess) | | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent) | +| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | From 419b0bcf7dfd9c0e88bc15ecf303c9576a92b43f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:51:45 +0800 Subject: [PATCH 102/163] docs: refresh module graph for UI identity --- docs/module-graph.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index baa714bad1..83f98d734e 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -321,6 +321,7 @@ flowchart TD pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek + pkg_jsonrpc --> pkg_scope pkg_jsonrpc --> pkg_session pkg_jsonrpc --> pkg_subagent pkg_stdio --> pkg_agent @@ -443,7 +444,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | +| [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | From 828d6004ba35737a5887def0ce3c32b1c84b77ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:51:12 +0800 Subject: [PATCH 103/163] fix(sdk): share generated session identity --- packages/sdk/create-sdk/tests/create.spec.ts | 1 + packages/sdk/helper/src/features/builtin/app.ts | 7 ++++--- .../sdk/helper/src/templates/assets/index.ts.tpl | 14 +++++++------- packages/sdk/helper/tests/project.spec.ts | 15 +++++++++++++-- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/packages/sdk/create-sdk/tests/create.spec.ts b/packages/sdk/create-sdk/tests/create.spec.ts index 9d75700bb9..04a251351d 100644 --- a/packages/sdk/create-sdk/tests/create.spec.ts +++ b/packages/sdk/create-sdk/tests/create.spec.ts @@ -257,6 +257,7 @@ describe('CreateWizard and scaffolder', () => { expect(index).toContain('SdkBootContext') expect(index).toContain('ctx.agents.create') expect(index).toContain('agentOptions: { model: "deepseek-v4-flash" }') + expect(index).not.toContain('AgentId') const tsconfig = parseGeneratedTsConfig(await readFile(join(target, 'tsconfig.base.json'), 'utf8')) const manifest = parseGeneratedPackageManifest(await readFile(join(target, 'package.json'), 'utf8')) expect(tsconfig.compilerOptions.types).toEqual(['node']) diff --git a/packages/sdk/helper/src/features/builtin/app.ts b/packages/sdk/helper/src/features/builtin/app.ts index a89f2c84ae..e4ff11af20 100644 --- a/packages/sdk/helper/src/features/builtin/app.ts +++ b/packages/sdk/helper/src/features/builtin/app.ts @@ -4,6 +4,7 @@ * @module @deepseek-ai/dsh-helper/features/builtin/app */ +import { JsExpression } from '../../documents/cordis-yaml-file.ts' import { featureId } from '../../ids.ts' import type { ProjectProfile } from '../../project/types.ts' import { @@ -94,11 +95,11 @@ class AppOption extends FeatureOption { name: '@deepseek-ai/dsh-stdio', config: { welcome: 'agent REPL ready. Give it a coding task.', - agent: 'main', + sessionId: new JsExpression('process.env.DSH_SDK_SESSION_ID'), }, - }, ['welcome', 'agent'], config => [ + }, ['welcome', 'sessionId'], config => [ ...optionalString(config, 'welcome'), - ...requiredString(config, 'agent'), + ...config.sessionId instanceof JsExpression ? [] : requiredString(config, 'sessionId'), ]), ]) case 'embed': diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index d0315db80c..4c3099199c 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -2,14 +2,12 @@ import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' {{else}} import { randomUUID } from 'node:crypto' -import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import { startSDK, type SdkBootContext } from '@deepseek-ai/dsh-scripts' {{/if}} /** Boot this project's cordis.yml when invoked by dsh-scripts. */ export async function main(boot: SdkBootContext) { - const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) {{#if isStdio}} const model = boot.args.model if (typeof model !== 'string' || model.length === 0) throw new Error('stdio startup requires --model=') @@ -17,24 +15,26 @@ export async function main(boot: SdkBootContext) { if (resume !== undefined && (typeof resume !== 'string' || resume.length === 0)) { throw new Error('stdio startup requires --resume=') } + const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`) + process.env.DSH_SDK_SESSION_ID = sessionId +{{/if}} + const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) +{{#if isStdio}} if (resume === undefined) { await ctx.agents.create({ - agentId: AgentId('main'), - sessionId: SessionId(`main-session-${randomUUID()}`), + sessionId, meta: { cwd: boot.cwd }, agentOptions: { model }, }) } else { await ctx.agents.resume({ - agentId: AgentId('main'), - resumeSessionId: SessionId(resume), + resumeSessionId: sessionId, agentOptions: { model }, }) } {{else}} {{#if isEmbed}} await ctx.agents.create({ - agentId: AgentId('main'), sessionId: SessionId(`main-session-${randomUUID()}`), meta: { cwd: boot.cwd }, agentOptions: { model: {{modelLiteral}} }, diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index d55bb16be9..13fc046a04 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -167,6 +167,10 @@ describe('SdkProject and ProjectEditSession', () => { expect(index).toContain('SdkBootContext') expect(index).toContain('agents.create') expect(index).toContain('boot.args.resume') + expect(index).not.toContain('AgentId') + expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)') + expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId') + expect(index).toContain('resumeSessionId: sessionId') expect(project.packageManifest().scripts).toEqual({ dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"', build: 'dsh-sdk build', @@ -175,7 +179,11 @@ describe('SdkProject and ProjectEditSession', () => { config: 'dsh-sdk config', }) expect(await readFile(join(project.root, '.env.example'), 'utf8')).toContain('EXA_API_KEY=') - expect(project.cordis.entry('stdio')?.config).toMatchObject({ agent: 'main' }) + expect(project.cordis.entry('stdio')?.config?.sessionId).toMatchObject({ + source: 'process.env.DSH_SDK_SESSION_ID', + }) + expect(await readFile(join(project.root, 'cordis.yml'), 'utf8')) + .toContain('sessionId: !!js process.env.DSH_SDK_SESSION_ID') expect(project.cordis.entry('stdio')?.config).not.toHaveProperty('model') expect(project.cordis.entry('agent-loop')?.config).toEqual({ agents: [] }) expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') @@ -286,7 +294,10 @@ describe('SdkProject and ProjectEditSession', () => { const embed = (await embedEdit.commit()).project expect(embed.profile.runInterface).toBe('embed') expect(await readFile(join(embed.root, 'README.md'), 'utf8')).toContain('Embed the harness') - expect(await readFile(join(embed.root, 'index.ts'), 'utf8')).toContain('agents.create') + const embedIndex = await readFile(join(embed.root, 'index.ts'), 'utf8') + expect(embedIndex).toContain('agents.create') + expect(embedIndex).toContain("import { SessionId } from '@deepseek-ai/dsh-session'") + expect(embedIndex).not.toContain('AgentId') await writeFile(join(embed.root, 'README.md'), '# Custom README\n') const modified = await SdkProject.open(embed.root) From 336f4e51ba0b7fe0c79abbd7d617f6597f0fd7fb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:51:52 +0800 Subject: [PATCH 104/163] docs(acp): align identity routing contract --- packages/ui/acp/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3650cf27d4..923c0178fa 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -8,7 +8,7 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` `apply(ctx, config)` — wires an `AgentSideConnection` (from `@agentclientprotocol/sdk`) to `process.stdin`/`process.stdout` and implements the ACP `Agent` method surface. -The plugin injects `agents`, `sessions`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms. +The plugin injects `agents`, `sessionPersistence`, `tools`, and `userInteraction`, never the concrete loop. Persistence backs `session/load`; tool definitions own presentation; user interaction maps agent questions to ACP forms. ### Config @@ -36,7 +36,7 @@ The `initialize` handshake reports a fixed server identity (`agentInfo: { name: ## Multi-session -Forward and reverse indexes route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). +One id-keyed record map plus exact agent-object checks route every event, prompt, cancel, and approval to one session. Each session permits one in-flight prompt; teardown drains all sessions in parallel. See the [multi-session RFC](../../../docs/rfc/implemented/feature/2026-06-14-acp-multi-session.md). ## Session config options From 2a6fef66ef3d55aff250f31f3d728f15e15f5269 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:52:49 +0800 Subject: [PATCH 105/163] test(agent-loop): remove review bookkeeping --- packages/core/agent-loop/tests/agent.spec.ts | 3 +-- .../tests/contract-regressions.spec.ts | 18 +++++++++--------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7969d18200..91fc1a891d 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -401,8 +401,7 @@ describe('Agent', () => { // The waiter is internal agent state, NOT an effect-scoped ctx.on listener: // disposing the OWNING fiber runs the agent's listener disposers, which would // have dropped a ctx.on-based waiter before the 'disposed' transition and - // hung the promise. With internal waiters, the fiber disposer still settles - // it. Regression for the round-3 whenIdle finding. + // hung the promise. With internal waiters, the fiber disposer still settles it. const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) let agent!: Agent diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index ec2412aff5..00eafa18cb 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -44,7 +44,7 @@ function send(agent: Agent, text: string) { agent.send([{ type: 'text', text }]) } -describe('HIGH: session log records what agent/step-result actually produced', () => { +describe('session log records what agent/step-result actually produced', () => { it('a step-result rewrite is what the log, derived history, and tool dispatch all see', async () => { const adapter = new MockAdapter([textResponse('original'), textResponse('done')]) const ctx = await harness(adapter) @@ -94,7 +94,7 @@ describe('HIGH: session log records what agent/step-result actually produced', ( }) }) -describe('HIGH: abort during tool execution ends the turn', () => { +describe('abort during tool execution ends the turn', () => { it('aborting the in-flight step inside a tool prevents both remaining tools and the next model step', async () => { const adapter = new MockAdapter([ // model asks for two tool calls in one step @@ -146,7 +146,7 @@ describe('HIGH: abort during tool execution ends the turn', () => { }) }) -describe('HIGH: steering from late extension points is never stranded', () => { +describe('steering from late extension points is never stranded', () => { it('steer() from an agent/turn-continuation listener overrides a stop decision', async () => { const adapter = new MockAdapter([ textResponse('no tools, would stop here'), @@ -268,7 +268,7 @@ describe('HIGH: steering from late extension points is never stranded', () => { }) }) -describe('HIGH: plugin exceptions are contained', () => { +describe('plugin exceptions are contained', () => { it('a throwing agent/turn-continuation listener ends the turn with an error, loop survives', async () => { const adapter = new MockAdapter([textResponse('one'), textResponse('two')]) const ctx = await harness(adapter) @@ -323,7 +323,7 @@ describe('HIGH: plugin exceptions are contained', () => { }) }) -describe('MEDIUM: disposed status is part of the agent/status contract', () => { +describe('disposed status is part of the agent/status contract', () => { it('disposing the fiber emits agent/status(disposed) and ends the turn with reason disposed', async () => { const adapter = new MockAdapter(['hang']) const ctx = await harness(adapter) @@ -370,7 +370,7 @@ describe('MEDIUM: disposed status is part of the agent/status contract', () => { }) }) -describe('MEDIUM: misc registry and config fixes', () => { +describe('misc registry and config fixes', () => { it('duplicate adapter registration is rejected', async () => { const ctx = new Context() await ctx.plugin(LlmService) @@ -529,7 +529,7 @@ describe('MEDIUM: misc registry and config fixes', () => { }) }) -describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () => { +describe('turn numbering continues across seeded (forked) sessions', () => { it('a forked agent continues turn numbers after the seed log', async () => { const first = new MockAdapter([textResponse('turn one')]) const ctx = await harness(first) @@ -567,7 +567,7 @@ describe('MEDIUM: turn numbering continues across seeded (forked) sessions', () }) }) -describe('LOW: discriminated SessionEvent narrows without casts', () => { +describe('discriminated SessionEvent narrows without casts', () => { it('narrows event.data from event.type', () => { const session = new Session(SessionId('s')) const appended: SessionEvent = session.append('tool/call', { @@ -585,7 +585,7 @@ describe('LOW: discriminated SessionEvent narrows without casts', () => { }) }) -describe('HIGH: a finish-error stream chunk ends the turn as error, not completed', () => { +describe('a finish-error stream chunk ends the turn as error, not completed', () => { it('translates finish {kind:error} into a turn error with a logged error event', async () => { // The second sanctioned adapter error path (besides throwing): an // adapter that cannot throw mid-stream ends the stream with a From 60c4d523d1a868d6766b828a2e5a0771f7c42205 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 00:59:24 +0800 Subject: [PATCH 106/163] docs(tools): align schema default contract --- packages/core/tools/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 0f7974e0f9..00bbb67ff5 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -142,7 +142,7 @@ The available tools: - **Native tool calls execute sequentially** — `ToolDefinition` carries no concurrency-safety metadata; adding it (and parallel execution in the loop) waits on the deferred tool-shapes review (`TODO(review)`). - **`tools/pre-execute` deliberately cannot rewrite `exec.arguments`** — logged and rendered args would desync from what ran; the rewrite design is [a proposed RFC](../../../docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md). -- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and never applies `default` (`XXX(unused-default)` flags removing that field); raw-registered JSON-Schema tools validate their own input. +- **`defineTool`'s schema DSL is a deliberate subset** — string/number/boolean/object/array with string-only `enum`; `validateArgs` tolerates extra keys and preserves `default` as a model-visible JSON Schema annotation without applying it during validation; dynamic Cordis mounts may supply defaults even though first-party definitions do not, while raw-registered JSON-Schema tools validate their own input. - **`timeoutMs` on a definition is declarative only** — the registry never enforces deadlines; enforcement requires the `@deepseek-ai/dsh-timeout-policy` wrapper. - **Code Mode is TypeScript-only and the presentation mode is service-wide** — `mode: code`/`both` rejects prompt assembly unless `ctx.codeRuntime.language === 'typescript'`; scoped restrictions/shadows still choose each agent's visible bindings, but one tool cannot be native-only while another is code-only. - **Code Mode bindings return text only** — non-text content blocks in a sub-call result collapse to `[ content]` placeholders. From c148090961bf8de680f6b210e5d0532b23c174b8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:07:59 +0800 Subject: [PATCH 107/163] test(sdk): cover invalid generated session ids --- packages/sdk/helper/tests/project.spec.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index 13fc046a04..bfaa8cbf77 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -873,6 +873,12 @@ describe('extension points', () => { resource.kind === 'cordis-config-entry' && resource.entry.id === 'acp') expect(acpEntry?.entry.id).toBe('acp') expect(acpEntry?.validateConfig?.({ model: '' })).toHaveLength(1) + const stdioEntry = builtins.get(featureId('app')).contribution(selection('app', ['stdio']), profile).resources + .find((resource): resource is CordisConfigEntryResource => + resource.kind === 'cordis-config-entry' && resource.entry.id === 'stdio') + expect(stdioEntry?.validateConfig?.({ welcome: 'ready', sessionId: 1 })).toEqual([ + 'sessionId must be a non-empty string', + ]) const embedOption = app.options.find(option => option.id === 'embed') expect(embedOption?.markerConfigEntries(profile)).toEqual([]) expect(embedOption?.contribution(profile, {}).resources.map(resource => resource.kind)).toEqual([ From a6e406a7782303ea5838007e8e85401e95ce4e01 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:16:52 +0800 Subject: [PATCH 108/163] docs(agent-loop): correct identity limitations --- packages/core/agent-loop/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index 9a165fd66a..6028b2f97f 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -126,7 +126,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p ## Known Limitations and Deferred Work - **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`). -- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent. +- **Config labels are fresh by default** — omitting `sessionId` creates a fresh `${id}-session-` on every startup; exact resume-or-create behavior requires an explicit stable `sessionId`, while `resumeSessionId` requires existing persisted history. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. - **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. -- **`runLoop`/`Inbox`/`InboxMessage` stay exported with no outside consumer** — [removal is proposed](../../../docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md). From 1a111654ae13b9dd448cc7166499c45247e20731 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:17:26 +0800 Subject: [PATCH 109/163] docs(subagent): restore local run identity contract --- packages/subagent/subagent/src/types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 67fcc99600..b0f228da96 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -134,9 +134,9 @@ export interface SubagentResult { */ export interface SubagentRun { /** - * Parent-scoped run id. A local run publishes a child session whose - * `parentSession` records `request.parent`; a remote provider mints an id - * unique in the parent namespace. + * Parent-scoped run id. For a local run, this MUST equal the published child + * session id, whose `parentSession` records `request.parent`; a remote + * provider mints an id unique in the parent namespace. */ readonly id: SessionId /** From 90220b235fcbdc506c6106a4c33cddd3faebacb1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 01:17:46 +0800 Subject: [PATCH 110/163] docs(core): describe the public loop contract --- packages/core/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/README.md b/packages/core/README.md index 921591d85e..fdd8e3669e 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -9,7 +9,7 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co | `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` | | `tools/` | Scoped tool registry + pre-policy, guards, around-dispatch, post-policy, and final-result observation | `ctx.tools` | | `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` | -| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` | +| `agent-loop/` | Concrete plugin implementing the public `Agent` contract and owning the loop driver | `ctx.agentLoop` | `scope/` is the one non-service package here: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) the registries and the loop build per-agent scoping on — it sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. From d56cc8d42417c45c835c2f7576d16bab980eb428 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:00:56 +0800 Subject: [PATCH 111/163] fix(stdio): close startup failure gaps --- .../helper/src/templates/assets/index.ts.tpl | 31 ++++++++++++------- packages/sdk/helper/tests/project.spec.ts | 2 ++ packages/ui/stdio/README.md | 6 ++-- packages/ui/stdio/package.json | 5 +++ 4 files changed, 30 insertions(+), 14 deletions(-) diff --git a/packages/sdk/helper/src/templates/assets/index.ts.tpl b/packages/sdk/helper/src/templates/assets/index.ts.tpl index 4c3099199c..a79818908c 100644 --- a/packages/sdk/helper/src/templates/assets/index.ts.tpl +++ b/packages/sdk/helper/src/templates/assets/index.ts.tpl @@ -20,17 +20,26 @@ export async function main(boot: SdkBootContext) { {{/if}} const ctx = await startSDK(new URL('./cordis.yml', import.meta.url)) {{#if isStdio}} - if (resume === undefined) { - await ctx.agents.create({ - sessionId, - meta: { cwd: boot.cwd }, - agentOptions: { model }, - }) - } else { - await ctx.agents.resume({ - resumeSessionId: sessionId, - agentOptions: { model }, - }) + try { + if (resume === undefined) { + await ctx.agents.create({ + sessionId, + meta: { cwd: boot.cwd }, + agentOptions: { model }, + }) + } else { + await ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: { model }, + }) + } + } catch (error) { + try { + await ctx.fiber.dispose() + } catch (disposeError) { + throw new AggregateError([error, disposeError], 'stdio startup and cleanup failed') + } + throw error } {{else}} {{#if isEmbed}} diff --git a/packages/sdk/helper/tests/project.spec.ts b/packages/sdk/helper/tests/project.spec.ts index bfaa8cbf77..655b308911 100644 --- a/packages/sdk/helper/tests/project.spec.ts +++ b/packages/sdk/helper/tests/project.spec.ts @@ -171,6 +171,8 @@ describe('SdkProject and ProjectEditSession', () => { expect(index).toContain('const sessionId = SessionId(resume ?? `main-session-${randomUUID()}`)') expect(index).toContain('process.env.DSH_SDK_SESSION_ID = sessionId') expect(index).toContain('resumeSessionId: sessionId') + expect(index).toContain('await ctx.fiber.dispose()') + expect(index).toContain("new AggregateError([error, disposeError], 'stdio startup and cleanup failed')") expect(project.packageManifest().scripts).toEqual({ dev: 'dsh-sdk dev index.ts -- --model="deepseek-v4-flash"', build: 'dsh-sdk build', diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index b7d320880d..f9cebe11e6 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -9,7 +9,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera | Key | Default | Meaning | |---|---|---| | `welcome` | `ready.` | Banner printed before the first prompt | -| `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown | +| `sessionId` | `main` | Exact agent/session identity driven by stdin and observed for EOF shutdown | The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects. @@ -18,7 +18,7 @@ The plugin seeds display labels from the live agent registry, then tracks `agent name: '@deepseek-ai/dsh-stdio' config: welcome: 'agent REPL ready. Give it a coding task.' - agent: main + sessionId: main ``` ## Model Experience @@ -37,6 +37,6 @@ The plugin seeds display labels from the live agent registry, then tracks `agent ## Known Limitations and Deferred Work -- **One configured agent receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `agent` id rather than routing by the visible label. +- **One configured session receives stdin** — the session/event renderer can print output from any session, but input lines always drive the configured `sessionId` rather than routing by the visible label. - **Terminal questions are text-only and sequential** — the provider queues asks, supports option labels plus custom text, and has no richer UI shapes such as file pickers or diff previews. - **Closed stdin ends the terminal channel** — EOF rejects active or queued questions and exits after submitted work reaches idle; there is no reconnect path for a long-lived process. diff --git a/packages/ui/stdio/package.json b/packages/ui/stdio/package.json index 1c7311d0cb..e1bffdf171 100644 --- a/packages/ui/stdio/package.json +++ b/packages/ui/stdio/package.json @@ -29,6 +29,11 @@ "@deepseek-ai/dsh-user-interaction": "^0.0.1", "cordis": "^4.0.0-rc.7" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-agent-loop": { + "optional": true + } + }, "dependencies": { "schemastery": "^3.18.0" }, From 3855a77e7d8b779c74c53cbbe7736a6a29563ba2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:04:04 +0800 Subject: [PATCH 112/163] docs(approval): qualify audit guarantees --- .../implemented/feature/2026-07-06-approval-seam.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 9f4968c3a4..0945228593 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -71,7 +71,7 @@ The answerer routes through the bridge's exact-agent ownership check described b #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`. #### Entities and dependencies @@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). - Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. +- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. - A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. @@ -113,7 +113,7 @@ Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. +- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. ## FAQ @@ -123,10 +123,10 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. From 3befcfc566b4deec276d600df993949f6e49facc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:04:47 +0800 Subject: [PATCH 113/163] Revert "docs(approval): qualify audit guarantees" This reverts commit 14439a93c2bb915441e7b59f505d94a79b22d4af. --- .../implemented/feature/2026-07-06-approval-seam.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 0945228593..9f4968c3a4 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -71,7 +71,7 @@ The answerer routes through the bridge's exact-agent ownership check described b #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. #### Entities and dependencies @@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). - Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair. +- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. - A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. @@ -113,7 +113,7 @@ Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. +- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. ## FAQ @@ -123,10 +123,10 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. From 70c6c0ac39aacf14ab0b627319b192fd1ca6f03b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:04:04 +0800 Subject: [PATCH 114/163] docs(approval): qualify audit guarantees --- .../implemented/feature/2026-07-06-approval-seam.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 9f4968c3a4..0945228593 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -25,7 +25,7 @@ One `cordis.yml` entry mounts the seam. Not loading it is the fail-closed opt-ou The entry alone provides mechanism, not a channel: with no answerer composed, every ask resolves `unavailable` and the asking tool call denies — fail-closed needs no configuration. Composing the ACP app (`@deepseek-ai/dsh-acp-demo`, as in [the acp-agent example's default tree](../../../../examples/acp-agent/README.md)) completes the loop: its bridge registers an answerer that prompts the owning editor session via `session/request_permission`, so a hook's `ask` or an escalation request surfaces as a one-shot Allow/Reject prompt attached to the already-streamed tool call. `policy: never` is the unattended stance — every ask auto-rejects deterministically, stated in the system prompt, no human in the loop. `policy` is validated against the closed list at plugin load; anything else throws. -What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; every ask lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. +What a composed deployment observes: `allowed-once` lets exactly that call proceed; rejection, dismissal, and channel absence deny with three distinct reasons the model can tell apart; a successful in-turn request lands a durable `approval/asked`/`approval/decided` pair on the asking agent's session log; nothing about a grant persists past the call that asked. An idle request or audit append failure rejects instead of returning an unaudited decision. One ask under this composition, verbatim from the sandbox example's recorded `escalation-approved` scenario — the model requests a sandbox escalation, the gate asks, the bridge prompts the owning editor, the user clicks Allow once: @@ -71,7 +71,7 @@ The answerer routes through the bridge's exact-agent ownership check described b #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. One `decided` lands per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. +`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`. #### Entities and dependencies @@ -105,7 +105,7 @@ The implemented contract is pinned by the suites in Testing: - With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. - A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). - Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; post-append observer failures cannot split the pair. +- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair. - Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. - A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. @@ -113,7 +113,7 @@ Costs and accepted limits: - **Two decide-eager answerers race for the slot.** Sibling-plugin listener order is not deterministic, so the seam cannot referee competing terminal answerers — mitigated by convention (one terminal answerer per deployment; `prepend` only for decide-or-delegate gates) rather than a priority mechanism the event bus does not have. - **Production exercise rests on one composition.** `ask` has two producer families — the hook bridges through `tools/pre-execute`, and sandbox escalation through its own gate — with the wire recorded in the sandbox example's snapshot suite, so the seam's real-world coverage is that one composition until more deployments compose it. -- **Ownership keys on `Agent` object identity.** The answerer resolves sessions through the bridge's existing WeakMap; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need session-id matching instead. +- **Ownership keys on `Agent` object identity.** The answerer resolves the forward session-map record at `agent.session.id`, then requires that record to own the exact agent object; every current path hands the same object through the loop and the seams, but a future boundary that clones or proxies agents would make the bridge delegate and fail closed — safe, but silently UI-less — and would need a different ownership contract. ## FAQ @@ -123,10 +123,10 @@ Behavioral and usage questions only — every "why not X?" design question lives - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. -- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer — one audit pair either way, never two. +- **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. - **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent's editor is deferred (§ Deferred). -- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; the audit pair still lands for every auto-rejection. +- **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the system prompt states the policy; switches are narrated at boundaries; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when the UI plugin unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does the user see what they are approving?** On the tool call itself: the prompt attaches to the already-streamed call via `callId` — arguments included — and adds the asker's human-readable `reason`; the request carries no argument copy of its own. From c2cf2cbbd846f477e6b3674b095a87e5ed0aa2c0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:05:44 +0800 Subject: [PATCH 115/163] test(agent-loop): name regression contracts --- packages/core/agent-loop/tests/contract-regressions.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 00eafa18cb..098a53261c 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -370,7 +370,7 @@ describe('disposed status is part of the agent/status contract', () => { }) }) -describe('misc registry and config fixes', () => { +describe('registration, request routing, and queued-input ownership contracts', () => { it('duplicate adapter registration is rejected', async () => { const ctx = new Context() await ctx.plugin(LlmService) From b2064cba10f3a0c19ca24472f0ee7d7f81c1816f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:26:57 +0800 Subject: [PATCH 116/163] fix(agent-loop): reject duplicate configured identities --- packages/core/agent-loop/src/index.ts | 22 ++++++++++++++++--- .../tests/config-session-id.spec.ts | 19 ++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 325ba7b92d..7144ae8dfb 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -369,6 +369,24 @@ export interface Config { })[] } +/** Reject self-contained identity conflicts before any configured agent starts. */ +function validateConfiguredAgents(agents: Config['agents']): void { + const exactIdentities = new Map() + for (const { id, sessionId, resumeSessionId } of agents) { + const hasResumeId = resumeSessionId !== undefined && resumeSessionId !== '' + if (sessionId !== undefined && hasResumeId) { + throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`) + } + const exactIdentity = hasResumeId ? resumeSessionId : sessionId + if (exactIdentity === undefined) continue + const firstId = exactIdentities.get(exactIdentity) + if (firstId !== undefined) { + throw new Error(`agents "${firstId}" and "${id}" use duplicate exact session identity "${exactIdentity}"`) + } + exactIdentities.set(exactIdentity, id) + } +} + /** Concrete ReactLoopAgent factory and driver service. */ export class AgentLoop extends Service implements AgentFactory { static inject = ['agents', 'sessions', 'llm', 'tools', 'systemPrompt'] @@ -390,6 +408,7 @@ export class AgentLoop extends Service implements AgentFactory { constructor(ctx: Context, public config: Config) { super(ctx, 'agentLoop') + validateConfiguredAgents(config.agents) this.ownership = new FactoryOwnership(ctx.fiber) this.runtime = { ctx } ctx.effect(() => () => this.ownership.dispose(), 'agentLoop.transactions()') @@ -412,9 +431,6 @@ export class AgentLoop extends Service implements AgentFactory { } continue } - if (sessionId !== undefined) { - throw new Error(`agent "${id}": sessionId and resumeSessionId are mutually exclusive`) - } ctx.effect(() => { const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index bd2a5522df..56cc0bee40 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -64,6 +64,25 @@ describe('config-driven session id', () => { await conflicting.fiber.dispose() }) + it('rejects duplicate exact ids before asynchronous configured startup', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-duplicate-')) + dirs.push(root) + const ctx = await makeCoreContext() + await ctx.plugin(SessionPersistenceJsonl, { root }) + + const outcome = await ctx.plugin(AgentLoop, { + agents: [ + { id: 'first', sessionId: SessionId('shared'), model: 'mock' }, + { id: 'second', sessionId: SessionId('shared'), model: 'mock' }, + ], + }).then(() => undefined, (error: unknown) => error) + const published = ctx.agents.get(SessionId('shared')) + await ctx.fiber.dispose() + + expect(outcome).toEqual(new Error('agents "first" and "second" use duplicate exact session identity "shared"')) + expect(published).toBeUndefined() + }) + it('restores a materialized exact id across an AgentLoop-only reload', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-exact-reload-')) dirs.push(root) From 042d752fd2ca7f8699011275b03a952935a0a54a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:26:57 +0800 Subject: [PATCH 117/163] docs(repeat-guard): trim module orientation --- packages/guard/repeat-tool-guard/src/index.ts | 37 ++----------------- 1 file changed, 4 insertions(+), 33 deletions(-) diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index d51df1d829..bd6c5a4404 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -1,37 +1,8 @@ /** - * Repeat-tool-call guard: advisory loop-breaker for agents stuck re-issuing - * the same tool call with identical arguments. - * - * Not a model-facing tool — it registers no tool, never vetoes or rewrites a - * call, and adds exactly one behavior: watch each agent's stream of tool calls - * through the `tools/post-execute` waterfall, count runs of consecutive calls - * to the same tool with identical canonicalized arguments, and at configured - * run lengths fold an escalating advisory reminder onto the decision's - * `additionalContext`. The loop appends that context as a logged - * `context/message` after the step's tool results, so the reminder is - * model-visible, source-attributed, and reconstructable from the session log - * with no new session event. Decision record: - * docs/rfc/implemented/feature/2026-07-08-repeat-tool-guard.md. - * - * ```yaml - * - id: repeat-tool-guard - * name: '@deepseek-ai/dsh-repeat-tool-guard' - * config: - * thresholds: [3, 5, 8] # consecutive counts that trigger a reminder - * include: [] # tool-name patterns to track; empty = all tools - * exclude: [todo_write] # tool-name patterns transparent to the chain - * ``` - * - * Chain state is keyed by the live agent object — the tool registry is a - * context-level singleton whose waterfalls interleave every agent's calls, so - * a shared counter would let one agent's repetition trip another's reminder. - * State is in-memory only: a session resumed from persistence starts with a - * fresh chain (the guard is a heuristic nudge, not a logged invariant). - * - * Plugin export shape: named exports, NO default. The cordis Loader's - * `unwrapExports` does `exports.default ?? exports`, so a stray default would - * collapse the module to the bare `apply` (see docs/postmortem/0001). - * + * Advisory per-agent repeat-call detector. It enriches post-execute decisions + * with logged model context without vetoing or rewriting calls. Configuration + * and chain semantics live in the package README; rationale lives in the + * repeat-tool-guard RFC. * @module @deepseek-ai/dsh-repeat-tool-guard */ From 68b5c716b1265590f5f2936c33f9760b12c8d1db Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:29:08 +0800 Subject: [PATCH 118/163] docs(catalog): refresh AgentLoop source link --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 85e07f8191..f83f2bf616 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:373`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:391`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` From b77cfd64a1cba70c1c2168d370aeab3108989a12 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:29:32 +0800 Subject: [PATCH 119/163] docs(catalog): refresh repeat guard source link --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 46645b0575..29d37dd6db 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -548,7 +548,7 @@ export interface Config { } ``` -Source: [`packages/guard/repeat-tool-guard/src/index.ts:55`](../packages/guard/repeat-tool-guard/src/index.ts) +Source: [`packages/guard/repeat-tool-guard/src/index.ts:26`](../packages/guard/repeat-tool-guard/src/index.ts) ## `@deepseek-ai/dsh-sandbox-local` From 69c36e4fb5a6bceff4f44a8ba81ee8deb68fc1e8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:31:20 +0800 Subject: [PATCH 120/163] docs(subagent): name parent session lineage --- packages/subagent/subagent/src/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index b0f228da96..e5ba8fd84d 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -135,8 +135,8 @@ export interface SubagentResult { export interface SubagentRun { /** * Parent-scoped run id. For a local run, this MUST equal the published child - * session id, whose `parentSession` records `request.parent`; a remote - * provider mints an id unique in the parent namespace. + * session id, whose `parentSession` records `request.parent.session.id`; a + * remote provider mints an id unique in the parent namespace. */ readonly id: SessionId /** From 9366baf39fd84f169e1df7f42048088b5cc0c790 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:31:26 +0800 Subject: [PATCH 121/163] docs(acp): describe session terminal rendering --- packages/ui/acp/src/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 8bb25df579..d9c53cebf0 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1049,7 +1049,7 @@ function validateMcpServers(params: { mcpServers?: unknown[] }): void { * zero or more times per event (best-effort UI feed, never load-bearing). * @param presenter - resolves tool-owned render intent for tool events; * defaults to the generic-fallback {@link nullToolPresenter}. - * @param terminal - the connection's terminal-rendering context; defaults to + * @param terminal - the session's terminal-rendering context; defaults to * disabled (the plain-text console-block fallback). * @param options - `includeUserMessages` (default `true`): live streaming * passes `false` so a prompt the client just sent is not echoed back. @@ -1122,7 +1122,7 @@ export function todosToPlan(todos: TodoItem[]): Plan { } /** - * Per-connection terminal-rendering context threaded into + * Per-session terminal-rendering context threaded into * {@link streamSessionEventUpdate}: whether the client advertised the * `_meta.terminal_output` capability, and the session's workspace cwd (the * default terminal-card header when a tool doesn't supply its own). Kept out of From 2f1a40d9381aea67badc63f7c1d30e5cda5c0f02 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:35:31 +0800 Subject: [PATCH 122/163] docs(catalog): refresh AgentLoop source link --- docs/cordis-catalog/services.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7cc00a407a..44490c39f0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -19,7 +19,9 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise ``` -Source: [`packages/core/agent-loop/src/index.ts:391`](../../packages/core/agent-loop/src/index.ts) +Types: [Agent](../core-data-structures/core.md) + +Source: [`packages/core/agent-loop/src/index.ts:390`](../../packages/core/agent-loop/src/index.ts) ## `ctx.agents` — `AgentRegistry` From 4c08629b58ef493da1a02e0dcff9eed4d4eb1980 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:56:12 +0800 Subject: [PATCH 123/163] docs(acp): describe concurrent update routing --- packages/ui/acp/src/index.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index d9c53cebf0..7b5cb11324 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -20,11 +20,12 @@ * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to * its own `ReactLoopAgent`. Sessions are keyed by their shared agent/session id; * every `session/event` and `agent/*` event is routed strictly to its owning - * session record, so two sessions streaming at once never interleave their - * `session/update` notifications. Permission prompts use the same identity: the - * bridge answers `approval/request` for its own agents over - * `session/request_permission` (see the approval answerer below) — whether a - * call ASKS is policy (a hook or plugin returning `ask`), not the bridge's. + * session record, and each `session/update` carries that id. Concurrent updates + * may alternate on the shared connection without crossing session attribution. + * Permission prompts use the same identity: the bridge answers + * `approval/request` for its own agents over `session/request_permission` (see + * the approval answerer below) — whether a call ASKS is policy (a hook or + * plugin returning `ask`), not the bridge's. * * stdout is the protocol: this plugin must run in an example that loads NO * stdout logger (the console logger writes to stdout and would corrupt the @@ -483,8 +484,8 @@ export function apply(ctx: Context, config: AcpConfig): void { // whose end arrives late is ignored (see // SessionRecord.inflight). A turn that ends `error` REJECTS the prompt (ACP // has no error stop reason); other reasons resolve via the codec. Demux - // strictly by session id: a `session/event` is routed to its own record, so - // two sessions streaming at once never cross-settle or interleave updates. + // strictly by session id: concurrent updates may alternate on the shared + // connection, but they retain the owning id and never cross-settle. ctx.on('session/event', (session, event: SessionEvent) => { const rec = sessions.get(session.header.id) if (rec === undefined) return From 486362d2f8a6de1aee998cee320046d5c3db1a8d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:57:32 +0800 Subject: [PATCH 124/163] docs(catalog): refresh ACP config source link --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 4300404cfb..cb5aebbdf8 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -31,7 +31,7 @@ export interface AcpConfig { Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:244`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:245`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` From f7af6a87dcf06397bb66b91fd851572e31385815 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 16 Jul 2026 03:25:21 +0800 Subject: [PATCH 125/163] docs(acp): describe terminal capability lifetime --- packages/ui/acp/src/index.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 7b5cb11324..a12249d0b5 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -359,10 +359,10 @@ export function apply(ctx: Context, config: AcpConfig): void { // await and NOT install a record (which would resurrect a live agent/listeners // after the bridge closed). Checked after every load await. let closed = false - // Whether the client advertised the Zed `_meta.terminal_output` capability in - // `initialize`. When true, a tool's terminal presentation is rendered as a - // terminal card (content + `_meta.terminal_*`); when false, the bridge uses - // the tool's text fallback. Set once in `initialize`, read on every tool event. + // Connection-level terminal capability from the latest `initialize`; false + // before initialization. Each `session/new` or `session/load` snapshots it in + // `SessionRecord.terminalEnabled`, so later initialization affects only future + // sessions. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only From 96975f3840ba0dc64327a306e4044e9694858767 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 10:15:19 +0800 Subject: [PATCH 126/163] fix(session-persistence): create SQLite databases owner-only --- docs/config-catalog.md | 6 +-- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 17 +++++-- .../tests/sqlite.spec.ts | 50 ++++++++++++++++++- 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..9474a4877b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -603,8 +603,8 @@ Requires: `sessions` export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests); a file path is created (with parent - * dirs) on construction. + * opens an in-process database (tests). Missing directories and the database + * are created with owner-only permissions; existing path modes are preserved. */ path: string /** @@ -627,7 +627,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:38`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:48`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1411e177be..1dd480bc38 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 7d23292e9d..9f0b7cb7c4 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -8,7 +8,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { DatabaseSync } from 'node:sqlite' -import { mkdir } from 'node:fs/promises' +import { mkdir, open } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { SessionPersistence, PersistenceCoordinator, @@ -34,12 +34,22 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } +/** Create a missing database owner-only while preserving an existing file's mode. */ +async function createDatabaseFile(path: string): Promise { + try { + const handle = await open(path, 'wx', 0o600) + await handle.close() + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } +} + /** Plugin configuration. */ export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests); a file path is created (with parent - * dirs) on construction. + * opens an in-process database (tests). Missing directories and the database + * are created with owner-only permissions; existing path modes are preserved. */ path: string /** @@ -87,6 +97,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers if (path !== ':memory:') { const abs = resolve(path) await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) + await createDatabaseFile(abs) this.db = openDatabase(abs, journalMode) } else { this.db = openDatabase(path, journalMode) diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index bae79b0a53..3606afce67 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SurfaceEvent, SurfaceEventType } from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite, { SCHEMA_VERSION } from '@deepseek-ai/dsh-session-persistence-sqlite' @@ -348,6 +348,52 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { + it('creates a new database and WAL sidecars owner-only without changing an existing directory mode', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const dir = dirname(path) + await chmod(dir, 0o755) + + const b = await backend(path) + await b.ctx.sessionPersistence.list() + + expect((await stat(dir)).mode & 0o777).toBe(0o755) + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-wal`)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-shm`)).mode & 0o777).toBe(0o600) + await b.dispose() + }) + + it('preserves the mode of an existing database file', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + await writeFile(path, '', { mode: 0o644 }) + await chmod(path, 0o644) + + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'delete' }) + await ctx.sessionPersistence.list() + + expect((await stat(path)).mode & 0o777).toBe(0o644) + await fiber.dispose() + }) + + it('surfaces database pre-creation errors other than an existing file', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const blocked = join(dirname(path), 'blocked') + await mkdir(blocked, { mode: 0o500 }) + const b = await backend(join(blocked, 'sessions.db')) + + try { + await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'EACCES' }) + await b.dispose() + } finally { + await chmod(blocked, 0o700) + } + }) + it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { const path = await freshDbPath() const m = meta('rollback-insert') From ac893f97bbbc3727beeab4034d689464dc4f2018 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Fri, 17 Jul 2026 10:47:32 +0800 Subject: [PATCH 127/163] fix(session-persistence): address permission review --- docs/config-catalog.md | 4 +++- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 8 +++++++- .../tests/sqlite.spec.ts | 17 +++++------------ 4 files changed, 16 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 9474a4877b..49607eb494 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -605,6 +605,8 @@ export interface Config { * Filesystem path to the SQLite database file. The special value `:memory:` * opens an in-process database (tests). Missing directories and the database * are created with owner-only permissions; existing path modes are preserved. + * Parent directories writable by another principal are outside the backend's + * database-integrity boundary. */ path: string /** @@ -627,7 +629,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:48`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:52`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 1dd480bc38..b5f85bd1a8 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. This default prevents incidental exposure through the process umask; it does not protect database integrity when another principal can modify entries in an existing parent directory. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 9f0b7cb7c4..45c05f750b 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -34,7 +34,11 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { ] } -/** Create a missing database owner-only while preserving an existing file's mode. */ +/** + * Create a missing database owner-only while preserving an existing file's + * mode. `DatabaseSync` cannot adopt this handle, so a parent directory writable + * by another principal is outside the backend's database-integrity boundary. + */ async function createDatabaseFile(path: string): Promise { try { const handle = await open(path, 'wx', 0o600) @@ -50,6 +54,8 @@ export interface Config { * Filesystem path to the SQLite database file. The special value `:memory:` * opens an in-process database (tests). Missing directories and the database * are created with owner-only permissions; existing path modes are preserved. + * Parent directories writable by another principal are outside the backend's + * database-integrity boundary. */ path: string /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 3606afce67..50d761a39d 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { existsSync } from 'node:fs' -import { chmod, mkdtemp, mkdir, rm, stat, writeFile } from 'node:fs/promises' +import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -379,19 +379,12 @@ describe('SessionPersistenceSqlite: edge cases', () => { await fiber.dispose() }) - it('surfaces database pre-creation errors other than an existing file', async () => { - if (process.platform === 'win32') return + it('surfaces database pre-creation errors independently of process privileges', async () => { const path = await freshDbPath() - const blocked = join(dirname(path), 'blocked') - await mkdir(blocked, { mode: 0o500 }) - const b = await backend(join(blocked, 'sessions.db')) + const b = await backend(`${path}\0`) - try { - await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'EACCES' }) - await b.dispose() - } finally { - await chmod(blocked, 0o700) - } + await expect(b.ctx.sessionPersistence.list()).rejects.toMatchObject({ code: 'ERR_INVALID_ARG_VALUE' }) + await b.dispose() }) it('append rolls back and rethrows when an event INSERT fails inside the transaction', async () => { From 1119c537d0aa52f720955e9209eb07fc2e02cd09 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 17 Jul 2026 12:01:37 +0800 Subject: [PATCH 128/163] feat(ui): add dedicated TUI package Move the interactive pi-tui front door into @deepseek-ai/dsh-tui while keeping @deepseek-ai/dsh-stdio line-oriented for pipes. Select the terminal package in the demo app, preserve logger ownership, and cover the production Loader composition with a PTY smoke test. --- AGENTS.md | 2 +- README.i18n.yaml | 4 +- README.md | 2 +- README.zh.md | 2 +- docs/architecture.md | 2 +- docs/config-catalog.md | 57 +- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/core-data-structures/user-interaction.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/event-producer-consumer.md | 10 +- docs/i18n/translation-prompt.md | 6 +- docs/module-graph.md | 10 +- examples/README.md | 2 +- examples/coding-agent/README.md | 20 +- examples/coding-agent/composition.md | 4 +- examples/coding-agent/cordis.yml | 11 +- .../tests/tui-keyless-smoke.e2e.ts | 101 ++ examples/cordis-agent/composition.md | 2 +- examples/echo-agent/README.md | 2 +- examples/echo-agent/composition.md | 2 +- examples/echo-agent/cordis.yml | 2 +- knip.json | 2 +- packages/examples/README.md | 4 +- packages/examples/agent-spine-demo/README.md | 2 +- packages/examples/stdio-demo/README.md | 24 +- packages/examples/stdio-demo/package.json | 8 +- packages/examples/stdio-demo/src/bin.ts | 2 +- packages/examples/stdio-demo/src/index.ts | 86 +- .../stdio-demo/tests/stdio-agent.spec.ts | 45 +- packages/examples/stdio-demo/tsconfig.json | 3 + packages/todo/README.md | 2 +- packages/todo/tool-todo/README.md | 2 +- packages/ui/README.md | 7 +- packages/ui/acp/README.md | 2 +- packages/ui/tui/README.md | 56 + packages/ui/tui/package.json | 45 + packages/ui/tui/src/index.ts | 1284 +++++++++++++++++ packages/ui/tui/tests/plugin-shape.spec.ts | 19 + packages/ui/tui/tests/tui.spec.ts | 952 ++++++++++++ packages/ui/tui/tsconfig.json | 33 + packages/ui/user-interaction/README.md | 2 +- pnpm-lock.yaml | 56 + scripts/gen-doc-graphs.ts | 4 +- tsconfig.build.json | 1 + tsconfig.json | 1 + 49 files changed, 2808 insertions(+), 93 deletions(-) create mode 100644 examples/coding-agent/tests/tui-keyless-smoke.e2e.ts create mode 100644 packages/ui/tui/README.md create mode 100644 packages/ui/tui/package.json create mode 100644 packages/ui/tui/src/index.ts create mode 100644 packages/ui/tui/tests/plugin-shape.spec.ts create mode 100644 packages/ui/tui/tests/tui.spec.ts create mode 100644 packages/ui/tui/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 23d7c0263b..f695fbac3b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ packages/ Harness packages at packages///, all named @deepseek-ai cordis/ self-referential toolset: the agent inspects/mounts plugins in its own runtime hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP/stdio/JSON-RPC bridges; boot, approval, interaction plugins + ui/ ACP/stdio/TUI/JSON-RPC bridges; boot, approval, interaction plugins examples/ demo bundles (agent-spine + stdio/ACP/JSON-RPC bins) leaves load support/ dev/test infrastructure packages util/ zero-dependency utilities diff --git a/README.i18n.yaml b/README.i18n.yaml index 790812344d..628d7168da 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 53dd3896eb15800125673e7c44f7de02daca9376 -README.zh.md: ab826f62658248249ec18c57b35c0065c0f909d1 +README.md: 92cbfaa8196b49d2b801776f0e0c178cb5f673dc +README.zh.md: 28604ad1e2926edd7d4eb91c8027785433a54d32 diff --git a/README.md b/README.md index 53dd3896eb..92cbfaa819 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:repl # interactive pi-tui coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/README.zh.md b/README.zh.md index ab826f6265..28604ad1e2 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,7 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:repl # REPL agent demo (needs DEEPSEEK_API_KEY) +pnpm run demo:repl # interactive pi-tui coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/docs/architecture.md b/docs/architecture.md index 848980a45a..0f6de3af03 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -139,7 +139,7 @@ Some seams bend the template deliberately. LLM keeps interface and consumer voca ### Bundles And Apps -`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` for terminal REPL, and `dsh-acp-demo` for ACP over JSON-RPC stdio with no stdout logger ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). +`dsh-agent-spine-demo` is the default composition bundle: one plugin loading the shared spine ([README](../packages/examples/agent-spine-demo/README.md)). App packages compose it with a front door and boot `bin`: `dsh-stdio-demo` selects the `dsh-tui` package on interactive terminals and `dsh-stdio` on pipes, while `dsh-acp-demo` serves ACP over JSON-RPC stdio ([ui/](../packages/ui/README.md)). `dsh-jsonrpc-agent` instead boots an external `cordis.yml`; the Python SDK injects the package default only when no explicit config channel is set and drives `dsh-jsonrpc` over line-delimited stdio JSON-RPC ([Python SDK](../python/README.md)). A deployment is a thin `cordis.yml` leaf: swappable backends, one app entry, and optional product tools ([examples/](../examples/AGENTS.md), [runnable wirings](cookbook/extension-cookbook.md#runnable-wirings), [graph atlas](graph-atlas.md)). ### Where New Behavior Goes diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 91fe7c6497..79aa6ce17a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -700,7 +700,7 @@ Source: [`packages/ui/stdio/src/index.ts:30`](../packages/ui/stdio/src/index.ts) * is the explicit model-facing tool order (forwarded to the system-prompt plugin); * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner. + * `welcome` is the UI banner and `ui` configures terminal mode/presentation. */ export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ @@ -713,8 +713,10 @@ export interface Config { tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string - /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ + /** Terminal banner printed once on start. Defaults to `'ready.'`. */ welcome?: string + /** Terminal front-door selection and pi-tui presentation settings. */ + ui?: UiConfig /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ @@ -728,11 +730,22 @@ export interface Config { */ resumeSessionId?: string } + +/** App-level terminal selection with nested TUI presentation settings. */ +export interface UiConfig { + /** Select a concrete front door or infer it from the process streams. */ + mode?: TerminalMode + /** Settings forwarded only when the pi-tui front door is selected. */ + tui?: uiTui.TuiConfig +} + +/** Terminal front door selected by the app bundle. */ +export type TerminalMode = 'auto' | 'readline' | 'tui' ``` -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/stdio-demo/src/index.ts:36`](../packages/examples/stdio-demo/src/index.ts) +Source: [`packages/examples/stdio-demo/src/index.ts:73`](../packages/examples/stdio-demo/src/index.ts) ## `@deepseek-ai/dsh-subagent-acp` @@ -1075,6 +1088,42 @@ export type ToolPresentationMode = 'native' | 'code' | 'both' Source: [`packages/core/tools/src/index.ts:307`](../packages/core/tools/src/index.ts) +## `@deepseek-ai/dsh-tui` + +Requires: `agents` · `userInteraction` · `tools` + +```ts config-catalog +/** Serializable plugin configuration. */ +export interface Config extends TuiConfig { + /** Header subtitle. Defaults to `ready.`. */ + welcome?: string + /** Agent id driven by this terminal. Defaults to `main`. */ + agent?: string +} + +/** Presentation settings for the pi-tui terminal mode. */ +export interface TuiConfig { + /** Render model reasoning blocks. */ + showReasoning?: boolean + /** Maximum tool-output lines shown before the card is collapsed. */ + maxToolOutputLines?: number + /** Maximum options visible at once in a user-question dialog. */ + maxQuestionOptions?: number + /** User-question dialog width in terminal columns. */ + questionDialogWidth?: number + /** User-question dialog maximum height in terminal rows. */ + questionDialogMaxHeight?: number + /** Show the terminal's hardware cursor at the pi editor's IME marker. */ + showHardwareCursor?: boolean + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Terminal window title while the UI is mounted. */ + title?: string +} +``` + +Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 207ea114e0..598e9827b1 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 3474bc116b43f9be57b52e947f9cf99730f7e796 -extension-cookbook.zh.md: 1a605b20fe4e171a948ac2a046193a2f4d884e44 +extension-cookbook.md: 02dc22ff72807629a89d602f49752e6f429587b5 +extension-cookbook.zh.md: a204abdf6656312aea94640c60038af9f81f4f7b diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 3474bc116b..02dc22ff72 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a terminal REPL UI, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle. +Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a pi-tui coding interface with a readline fallback, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle. ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 1a605b20fe..a204abdf66 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合终端 REPL UI,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。 +三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合 pi-tui 编码界面,并为管道输入保留 readline 回退,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。 ## 功能→机制映射 diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 4edc415039..afe61af475 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -1,6 +1,6 @@ # User Interaction -The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` renders questions in readline, and `dsh-acp` maps them to ACP form elicitations. +The user-interaction seam of [dsh-user-interaction](../../packages/ui/user-interaction). It is the provider-neutral vocabulary a tool or permission plugin uses when it needs the human to answer before the agent can continue. UI surfaces provide the active `UserInteractionProvider`: `dsh-stdio-demo` selects keyboard-driven `dsh-tui` overlays or `dsh-stdio` readline prompts, and `dsh-acp` maps questions to ACP form elicitations. Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-interaction/src/index.ts) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 494eb09922..1d687a3f90 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: b3c338f03f548b4de4b676850732731323d611f1 -development.zh.md: 20f5c585dd378a7b0a3bd6b0af8ebf7dc5e0fd3c +development.md: 512fdcd89b4d481dd2033bc4f4c2816a04b5feaa +development.zh.md: 9f2038a99b289d06f16d7d51f0d043caa34a300e diff --git a/docs/development.md b/docs/development.md index b3c338f03f..512fdcd89b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,7 +109,7 @@ The echo demo does not need API credentials: pnpm run demo:echo ``` -The REPL agent demo uses the real DeepSeek adapter and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: +The coding-agent demo uses pi-tui interactively, falls back to readline for pipes, and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh pnpm run demo:repl diff --git a/docs/development.zh.md b/docs/development.zh.md index 20f5c585dd..9f2038a99b 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -109,7 +109,7 @@ echo 演示不需要 API 凭证: pnpm run demo:echo ``` -REPL agent 演示使用真实的 DeepSeek 适配器,需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: +coding-agent 演示在交互终端中使用 pi-tui,对管道输入回退到 readline,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:repl diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d8c924b24d..b67788ea6b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,16 +7,16 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | | `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | | `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | | `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | | `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | | `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | @@ -27,7 +27,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:39`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | - | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:108`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:82`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 8bc15d6e8e..4dd877bea4 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -123,9 +123,9 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Good: `A green gate does not mean the translation is correct.` ### Code block comments — never translate -- Source code block contains: `# REPL agent demo (needs DEEPSEEK_API_KEY)` -- Bad: `# REPL agent 演示(需要 DEEPSEEK_API_KEY)` -- Good: `# REPL agent demo (needs DEEPSEEK_API_KEY)` (byte-identical) +- Source code block contains: `# interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)` +- Bad: `# 交互式 pi-tui 编码 agent(需要 DEEPSEEK_API_KEY)` +- Good: `# interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) ### Language switcher — English to Chinese - Source: `English | [中文](README.zh.md)` diff --git a/docs/module-graph.md b/docs/module-graph.md index 82763bdc55..e5d56709a8 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -98,6 +98,7 @@ flowchart TD pkg_permission["permission"] pkg_stdio["stdio"] pkg_tool_ask_user["tool-ask-user"] + pkg_tui["tui"] pkg_user_approval["user-approval"] pkg_user_interaction["user-interaction"] end @@ -287,6 +288,11 @@ flowchart TD pkg_tool_ask_user --> pkg_agent pkg_tool_ask_user --> pkg_tools pkg_tool_ask_user --> pkg_user_interaction + pkg_tui --> pkg_agent + pkg_tui --> pkg_llm + pkg_tui --> pkg_session + pkg_tui --> pkg_tools + pkg_tui --> pkg_user_interaction pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_tools pkg_mcp_client --> pkg_llm @@ -370,6 +376,7 @@ flowchart TD pkg_stdio_demo --> pkg_stdio pkg_stdio_demo --> pkg_tool_ask_user pkg_stdio_demo --> pkg_tools + pkg_stdio_demo --> pkg_tui pkg_stdio_demo --> pkg_user_interaction ``` @@ -437,6 +444,7 @@ flowchart TD | [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`acp`](../packages/ui/acp) | `ui` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`permission`](../packages/ui/permission), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`user-interaction`](../packages/ui/user-interaction) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`tui`](../packages/ui/tui) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`llm`](../packages/llm/llm), [`tools`](../packages/core/tools) | | [`tool-tasks`](../packages/tasks/tool-tasks) | `tasks` | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -452,4 +460,4 @@ flowchart TD | [`subagent-fork`](../packages/subagent/subagent-fork) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`subagent-spawn`](../packages/subagent/subagent-spawn) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/ui/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | -| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | +| [`stdio-demo`](../packages/examples/stdio-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`stdio`](../packages/ui/stdio), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction) | diff --git a/examples/README.md b/examples/README.md index 5db1e18372..25c017824f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,7 +15,7 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigge ## coding-agent -A REPL agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. The UI is a terminal readline REPL. +A coding-agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. Interactive runs use the pi-tui coding interface; pipes use readline. Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 9e627324d4..c1c4747a22 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,6 +1,6 @@ # coding-agent -The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + stdio chat + JSONL persistence, loaded from `cordis.yml`. The UI is a terminal readline REPL. +The coding-agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + terminal chat + JSONL persistence, loaded from `cordis.yml`. Interactive runs use the pi-tui coding interface; piped runs use readline. ## Run it @@ -11,15 +11,9 @@ The REPL agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem t pnpm run demo:repl ``` -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write` (a whole-list task tracker rendered as a checklist). Reasoning streams dimmed; tool calls/results render inline. +Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`. -``` -> fix the failing test in /path/to/project -[main turn 1] (reasoning…) - [tool call] bash({"command": "node --test", "workdir": "/path/to/project"}) - [tool result] … [exit code: 1] - … -``` +The TUI renders resumed Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay rather than taking over the editor. ### Resuming a prior session @@ -29,7 +23,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio RESUME_SESSION_ID= pnpm run demo:repl ``` -The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing/unreadable id is non-fatal — it logs a warning and starts no `main` agent. +The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent, so unset it or choose an existing session id. ## Code Mode @@ -48,17 +42,17 @@ and watch the transcript: one `run_code` call, a program looping over tools, and ## What each leaf entry demonstrates -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (console logger, JSONL persistence, readline UI, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: +This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, TTY-selected `dsh-tui`/`dsh-stdio` channels, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + console logger + JSONL persistence + readline UI + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), and `resumeSessionId` — so persistence and the agent are configured here, not wired as separate leaf plugins | +| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` channels + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), `resumeSessionId`, and optional `ui` presentation settings | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a checklist in stdio | +| `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | | `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | ## End-to-end tests (`pnpm run test:e2e`, key-gated) diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index 90ff8222b9..35835953ac 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -3,7 +3,7 @@ # Coding Agent App Composition -The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. +The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. ```mermaid flowchart LR @@ -18,7 +18,7 @@ flowchart LR cfg --> plugin_coding_stdio_agent plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_coding_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] + plugin_coding_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 61e389ebc5..568988bd59 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,6 +1,6 @@ -# REPL agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo` -# supplies the agent spine, generic task controls, logging, JSONL persistence, -# readline UI, and `main` agent. +# Coding agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo` +# supplies the agent spine, generic task controls, JSONL persistence, TTY-selected +# `dsh-tui`/`dsh-stdio` terminal front doors, and `main` agent. # HMR remains a leaf because it requires Loader internals; `demo:repl` passes # `--expose-internals`. The app bin loads the gitignored root `.env`; this file # reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. @@ -38,6 +38,11 @@ resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' welcome: 'agent REPL ready. Give it a coding task.' + ui: + mode: auto + tui: + showReasoning: true + maxToolOutputLines: 12 # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} from this agent's configuration. persona: | diff --git a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts new file mode 100644 index 0000000000..a1057ba02f --- /dev/null +++ b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts @@ -0,0 +1,101 @@ +import { spawn } from 'node:child_process' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' + +const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) +const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) + +const PTY_DRIVER = String.raw` +import errno, os, pty, select, signal, sys, time +node, tsx_loader, bin_script, config_path, tsconfig_path, cwd = sys.argv[1:] +env = os.environ.copy() +env.update({ + "DEEPSEEK_API_KEY": "keyless-tui-no-call", + "DSH_HOME": os.path.join(cwd, ".dsh"), + "DSH_AGENTS_HOME": os.path.join(cwd, ".agents"), + "TSX_TSCONFIG_PATH": tsconfig_path, +}) +pid, fd = pty.fork() +if pid == 0: + os.chdir(cwd) + os.execvpe(node, [node, "--expose-internals", "--import", tsx_loader, bin_script, config_path], env) + +output = bytearray() +sent_exit = False +deadline = time.monotonic() + 25 +status = None +while time.monotonic() < deadline: + ready, _, _ = select.select([fd], [], [], 0.05) + if ready: + try: + chunk = os.read(fd, 65536) + except OSError as error: + if error.errno != errno.EIO: + raise + chunk = b"" + if chunk: + output.extend(chunk) + if not sent_exit and b"agent REPL ready." in output: + os.write(fd, b"/exit\r") + sent_exit = True + waited, candidate = os.waitpid(pid, os.WNOHANG) + if waited == pid: + status = candidate + break + +if status is None: + os.kill(pid, signal.SIGKILL) + _, status = os.waitpid(pid, 0) +sys.stdout.buffer.write(output) +if not sent_exit: + sys.stderr.write("TUI did not render its welcome marker before timeout\n") + sys.exit(124) +if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI child did not exit cleanly\n") + sys.exit(125) +` + +async function runTuiLoaderSmoke(): Promise { + const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-')) + try { + return await new Promise((resolve, reject) => { + const child = spawn('python3', [ + '-c', + PTY_DRIVER, + process.execPath, + tsxLoader, + binScript, + configPath, + tsconfigPath, + cwd, + ], { stdio: ['ignore', 'pipe', 'pipe'] }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stdout.on('data', (chunk: string) => { stdout += chunk }) + child.stderr.setEncoding('utf8') + child.stderr.on('data', (chunk: string) => { stderr += chunk }) + child.once('error', reject) + child.once('exit', (code) => { + if (code === 0) resolve(stdout) + else reject(new Error(`TUI PTY smoke exited ${String(code)}. stdout:\n${stdout}\nstderr:\n${stderr}`)) + }) + }) + } finally { + await rm(cwd, { recursive: true, force: true }) + } +} + +describe('coding-agent TUI keyless smoke (real Loader tree in a PTY)', () => { + it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { + const output = await runTuiLoaderSmoke() + expect(output).toContain('DEEPSEEK') + expect(output).toContain('agent REPL ready.') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md index 6a08c379fc..499379482f 100644 --- a/examples/cordis-agent/composition.md +++ b/examples/cordis-agent/composition.md @@ -24,7 +24,7 @@ flowchart LR cfg --> plugin_cordis_stdio_agent plugin_cordis_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_cordis_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] + plugin_cordis_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index d6156130db..183268f5da 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -4,7 +4,7 @@ Runnable demo: stdin chat with a scripted mock model and an echo tool. The all-m ## What it shows -This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, the console logger, JSONL persistence, the readline UI, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: +This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app (which bundles the whole [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) spine, JSONL persistence, the TTY-selected `dsh-tui`/`dsh-stdio` front doors, and a pre-created `main` agent), and swaps in two example-local backends plus `hmr`: - `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. - `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. diff --git a/examples/echo-agent/composition.md b/examples/echo-agent/composition.md index 5160bd20b7..bdc4d7e21d 100644 --- a/examples/echo-agent/composition.md +++ b/examples/echo-agent/composition.md @@ -20,7 +20,7 @@ flowchart LR cfg --> plugin_echo_stdio_agent plugin_echo_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_echo_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_echo_stdio_agent --> frontdoor_stdio["readline UI
console logger
pre-created main agent"] + plugin_echo_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/echo-agent/cordis.yml b/examples/echo-agent/cordis.yml index fbf998e770..516c53522e 100644 --- a/examples/echo-agent/cordis.yml +++ b/examples/echo-agent/cordis.yml @@ -22,7 +22,7 @@ - id: bash name: '@deepseek-ai/dsh-bash-local' -# The app pre-creates `main` on the mock model and supplies logging, persistence, and readline UI. +# The app pre-creates `main` on the mock model and supplies persistence plus TTY-selected `dsh-tui`/`dsh-stdio` front doors. - id: stdio-agent name: '@deepseek-ai/dsh-stdio-demo' config: diff --git a/knip.json b/knip.json index b978da3a76..502ded1657 100644 --- a/knip.json +++ b/knip.json @@ -1,7 +1,7 @@ { "$schema": "https://unpkg.com/knip@5/schema.json", "exclude": ["duplicates"], - "ignoreBinaries": ["bwrap", "sandbox-exec"], + "ignoreBinaries": ["bwrap", "python3", "sandbox-exec"], "ignoreWorkspaces": ["vendor/*", "python/sdk-runtime"], "workspaces": { ".": { diff --git a/packages/examples/README.md b/packages/examples/README.md index 5ee0206d0f..558bcb205c 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -5,11 +5,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin (`timer` + `llm` + sessions + system-prompt + tools + skills + agents + invariants + `tool-bash` + `tool-skill` + `agent-loop`) | -| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal stdio chat app: the spine + console logger + readline UI + a pre-created `main` agent, with a boot `bin` | +| `stdio-demo/` | `@deepseek-ai/dsh-stdio-demo` | Terminal chat app: the spine + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` front door + a pre-created `main` agent, with a boot `bin` | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with opposite front-door clusters (console logger + readline UI vs the stdout-owning ACP bridge) and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `stdio-demo` and `acp-demo` compose it with terminal and ACP front-door clusters and own their boot bins. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), the bridges/channels/boot-glue in [`ui/`](../ui/README.md), and the swappable backends (LLM adapter, bash executor) in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index eb56f9515c..a1f98d8167 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -33,7 +33,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`). - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **presentation + per-app infra** — the stdio UI / ACP bridge, a console logger, `hmr`. These form the coupled "front-door cluster" that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine (common to both, stdout-silent); a console logger is NOT (it writes to stdout, which the ACP bridge reserves for JSON-RPC). +- **presentation + per-app infra** — the terminal (`dsh-tui` / `dsh-stdio`) or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-stdio-demo`](../../examples/stdio-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. This is the [interface/implementation/consumer seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 98f639491e..96704998c4 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -1,8 +1,8 @@ # @deepseek-ai/dsh-stdio-demo -The **terminal stdio chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with the front-door cluster a terminal chat needs, and a `bin` that boots a leaf `cordis.yml`. +The **terminal chat app**: a Cordis app plugin that composes the default agent spine ([`@deepseek-ai/dsh-agent-spine-demo`](../../examples/agent-spine-demo/README.md)) with JSONL persistence, human interaction, a pre-created `main` agent, and a TTY-selected pi-tui/readline terminal front door. Its `bin` boots a leaf `cordis.yml`. -It is the readline counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, but each bakes in the OPPOSITE front-door cluster. +It is the terminal counterpart to [`@deepseek-ai/dsh-acp-demo`](../acp-demo/README.md): both consume the same spine, while ACP reserves stdout for JSON-RPC and creates sessions from the client. ## What it bakes in @@ -10,14 +10,15 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | Plugin | Why it is here | |---|---| -| `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | | `@deepseek-ai/dsh-agent-spine-demo` | the spine, pre-creating a `main` agent from this app's `model` with `process.cwd()` as the fresh session cwd and carrying its `persona` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | -| `@deepseek-ai/dsh-stdio` | the readline UI, bound to the `main` agent | +| `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path | +| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation | +| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs | -`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. +`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-core`, `hmr`, and the two leaf backends. @@ -33,7 +34,8 @@ The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapte | `toolBash` | owner defaults | model-facing bash config routed through `dsh-agent-spine-demo`, including bash's producer-local `enableRunInBackground` | | `toolTasks` | owner defaults | generic `task_output` wait bounds routed through `dsh-agent-spine-demo` | | `persistenceRoot` | `./.sessions` | the JSONL backend's root directory | -| `welcome` | `ready.` | the stdin-chat banner | +| `welcome` | `ready.` | terminal banner / TUI subtitle | +| `ui` | owner defaults | app mode selection and nested `dsh-tui` presentation config | | `resumeSessionId` | — | resume a persisted session id instead of starting fresh (sourced from an env var in the leaf) | Fresh stdio sessions use the process launch directory as `session.header.cwd`, so project-scoped features such as skill discovery and default bash workdir follow the directory where `dsh-stdio-demo` was started. Resumed sessions keep the cwd stored in the persisted session header. @@ -45,7 +47,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s ## Example leaf `cordis.yml` ```yaml -# A REPL agent demo: hmr + the DeepSeek adapter + local bash, then this app. +# A coding-agent demo: hmr + the DeepSeek adapter + local bash, then this app. - id: hmr name: '@cordisjs/plugin-hmr' config: @@ -64,6 +66,8 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s config: model: deepseek-v4-flash persona: 'You are a coding assistant powered by the {{model}} model.' + ui: + mode: auto ``` Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — "swap the backend, keep the app". @@ -72,9 +76,9 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — ### Composed terminal agent request -**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each readline submission becomes a user message. +**What the model sees**: Through `dsh-agent-spine-demo`, the `main` agent receives the harness identity, configured persona, skill catalog, and visible tools; this app also composes the generated [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user). Each terminal submission becomes a user message; submissions made while the agent runs steer the active turn. -**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The welcome banner, logger output, and rendered transcript are terminal-only and add zero model tokens. +**Token effect**: Child prompt and schema costs repeat per request; user input and tool history grow until compaction. The TUI/readline banners and rendered transcripts are terminal-only and add zero model tokens. ### Human-answer result @@ -84,6 +88,6 @@ Swap `llm-deepseek` for a `mock-llm` leaf plugin and you have the echo demo — ## Known Limitations and Deferred Work -- **One pre-created `main` agent drives the readline UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. +- **One pre-created `main` agent drives the terminal UI** — there is no multi-session or concurrent-agent surface in this app; a run is one conversation. - **The front-door cluster is fixed in code** — the JSONL persistence backend and the ask-user tooling are baked; a different composition is a leaf-level sibling entry or another app package. - **The question tool is not an approval answerer** — this app mounts `user-interaction` and `ask_user_question`, but not `ctx.approval`; a `tools/pre-execute` `ask` therefore fails closed unless the leaf composes an approval service and terminal answerer. diff --git a/packages/examples/stdio-demo/package.json b/packages/examples/stdio-demo/package.json index 3fe11f47a2..d20e210b8f 100644 --- a/packages/examples/stdio-demo/package.json +++ b/packages/examples/stdio-demo/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-stdio-demo", - "description": "Terminal stdio chat app: the agent-spine-demo bundle + console logger + readline UI + a pre-created main agent, with a bin to boot a leaf cordis.yml", + "description": "Terminal chat app: agent spine + JSONL persistence + TTY pi-tui/readline front-door selection + pre-created main agent", "version": "0.0.1", "private": true, "type": "module", @@ -32,14 +32,15 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-stdio": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", @@ -49,8 +50,8 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-app-boot": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", @@ -58,6 +59,7 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-stdio": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/examples/stdio-demo/src/bin.ts b/packages/examples/stdio-demo/src/bin.ts index 462e821e50..278bb5faca 100644 --- a/packages/examples/stdio-demo/src/bin.ts +++ b/packages/examples/stdio-demo/src/bin.ts @@ -2,7 +2,7 @@ /** * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in - * dsh-app-boot. The echo and REPL demos invoke this bin with their own leaf configs. + * dsh-app-boot. The echo and coding-agent demos invoke this bin with their own leaf configs. * @module @deepseek-ai/dsh-stdio-demo/bin */ diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 73f163d7f4..055e266498 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -1,8 +1,9 @@ /** * The stdio chat app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) plus the - * coupled front-door cluster a terminal chat needs — a console logger, the independently - * packaged readline UI, JSONL session persistence, the user-interaction seam with its - * `ask_user_question` tool, and a pre-created `main` agent the UI drives. + * coupled front-door cluster a terminal chat needs — the independently packaged + * pi-tui and readline front doors, JSONL session persistence, the user-interaction + * seam with its `ask_user_question` tool, and a pre-created `main` agent the UI + * drives. Interactive terminals use `dsh-tui`; pipes use `dsh-stdio` plus logging. * Swappable adapters, executors, optional tools, and HMR stay in the leaf. This * Loader plugin intentionally exposes named exports only; a default export * would hide its `Config` schema (see docs/postmortem/0001). @@ -20,9 +21,45 @@ import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiStdio from '@deepseek-ai/dsh-stdio' +import * as uiTui from '@deepseek-ai/dsh-tui' export const name = 'stdio-demo' +/** Terminal front door selected by the app bundle. */ +export type TerminalMode = 'auto' | 'readline' | 'tui' + +/** App-level terminal selection with nested TUI presentation settings. */ +export interface UiConfig { + /** Select a concrete front door or infer it from the process streams. */ + mode?: TerminalMode + /** Settings forwarded only when the pi-tui front door is selected. */ + tui?: uiTui.TuiConfig +} + +const terminalModeSchema = z.union(['auto', 'readline', 'tui'] as const).default('auto') + +/** Schemastery schema for the app-level terminal selection. */ +export const UiConfigSchema: z = z.object({ + mode: terminalModeSchema, + tui: uiTui.TuiConfigSchema, +}) + +/** + * Resolve the app's terminal front door. + * + * @param config - App-level terminal selection. + * @param isTTY - Whether both process streams are interactive TTYs. + * @returns The concrete UI package to mount. + */ +export function resolveTerminalMode(config: UiConfig | undefined, isTTY: boolean): Exclude { + const mode = config?.mode ?? 'auto' + if (mode === 'auto') return isTTY ? 'tui' : 'readline' + if (mode === 'tui' && !isTTY) { + throw new Error('stdio-demo: TUI mode requires both stdin and stdout to be TTYs; use mode "readline" for pipes') + } + return mode +} + /** * App config: the swappable per-demo values, each routed to where the app wires * it. `model`/`resumeSessionId` configure the pre-created `main` agent (through @@ -31,7 +68,7 @@ export const name = 'stdio-demo' * is the explicit model-facing tool order (forwarded to the system-prompt plugin); * fresh sessions use `process.cwd()` as their workspace cwd; resumed sessions * keep their persisted cwd. `persistenceRoot` is the JSONL backend's directory; - * `welcome` is the UI banner. + * `welcome` is the UI banner and `ui` configures terminal mode/presentation. */ export interface Config { /** Model name for the `main` agent (must have a registered adapter). */ @@ -44,8 +81,10 @@ export interface Config { tools?: ToolsConfig /** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */ persistenceRoot?: string - /** stdin-chat banner printed once on start. Defaults to `'ready.'`. */ + /** Terminal banner printed once on start. Defaults to `'ready.'`. */ welcome?: string + /** Terminal front-door selection and pi-tui presentation settings. */ + ui?: UiConfig /** Skill registry, local-provider, and model-facing consumer config forwarded to agent-spine-demo. */ skills?: agentCore.SkillConfig /** Model-facing bash tool config forwarded through agent-core. */ @@ -72,6 +111,7 @@ export const Config: z = z.object({ // apply() fallbacks through named constants while retaining both boundaries. persistenceRoot: z.string().default('./.sessions'), welcome: z.string().default('ready.'), + ui: UiConfigSchema, skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: agentCore.ToolTasksConfigSchema, @@ -79,14 +119,18 @@ export const Config: z = z.object({ }) /** - * Compose the spine with the stdio front door. The console logger comes first - * (infra), then the agent-spine-demo bundle pre-creating the `main` agent from this - * app's `model`/`resumeSessionId` with the deployment `persona`, then the JSONL - * backend, then the readline UI bound to `main`. The `hmr` dev-reload plugin is - * a leaf concern (see the module doc), so it is not mounted here. + * Compose the spine with one terminal front door. Interactive TTY pairs mount + * `dsh-tui` without a console exporter; pipes mount the readline `dsh-stdio` + * channel with the console logger. The `hmr` dev-reload plugin remains a leaf + * concern. + * + * @param ctx - Context receiving the app's child plugins. + * @param config - App configuration routed to the spine and front door. + * @param isTTY - Whether both terminal streams are interactive TTYs. */ -export function apply(ctx: Context, config: Config): void { - ctx.plugin(ConsoleExporter) +export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean): void { + const mode = resolveTerminalMode(config.ui, isTTY) + if (mode === 'readline') ctx.plugin(ConsoleExporter) ctx.plugin(agentCore, { ...config.persona !== undefined ? { persona: config.persona } : {}, ...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {}, @@ -104,5 +148,21 @@ export function apply(ctx: Context, config: Config): void { ctx.plugin(SessionPersistenceJsonl, { root: config.persistenceRoot ?? './.sessions' }) ctx.plugin(UserInteractionService) ctx.plugin(toolAskUser) - ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) + if (mode === 'tui') { + ctx.plugin(uiTui, { + ...config.ui?.tui, + welcome: config.welcome ?? 'ready.', + agent: 'main', + }) + } else { + ctx.plugin(uiStdio, { welcome: config.welcome ?? 'ready.', agent: 'main' }) + } } + +/** Compose the configured terminal front door with the agent app. */ +/* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered, + and the coding-agent PTY smoke covers the interactive process path */ +export function apply(ctx: Context, config: Config): void { + composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY) +} +/* v8 ignore stop */ diff --git a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts index b5fb0e0207..472afaa0b7 100644 --- a/packages/examples/stdio-demo/tests/stdio-agent.spec.ts +++ b/packages/examples/stdio-demo/tests/stdio-agent.spec.ts @@ -10,8 +10,8 @@ import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as stdioAgent from '../src/index.ts' /** - * Unit coverage for app composition and config forwarding: console logger, pre-created main agent, - * agent-core spine, JSONL backend, and readline UI. HMR is a Loader-only leaf concern covered by the + * Unit coverage for app composition and config forwarding: pre-created main agent, agent-core spine, + * JSONL backend, and adaptive terminal UI. HMR is a Loader-only leaf concern covered by the * keyless echo smoke; this tier pins the export shape because an inject-less app could otherwise * survive namespace collapse while silently losing its schema. */ @@ -65,6 +65,47 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } describe('dsh-stdio-demo app', () => { + it('selects readline for pipes and dsh-tui for interactive terminal pairs', () => { + expect(stdioAgent.resolveTerminalMode(undefined, false)).toBe('readline') + expect(stdioAgent.resolveTerminalMode(undefined, true)).toBe('tui') + expect(stdioAgent.resolveTerminalMode({ mode: 'readline' }, true)).toBe('readline') + expect(stdioAgent.resolveTerminalMode({ mode: 'tui' }, true)).toBe('tui') + expect(() => stdioAgent.resolveTerminalMode({ mode: 'tui' }, false)).toThrow('requires both stdin and stdout') + }) + + it('composes only the selected terminal package and keeps TUI settings in dsh-tui', () => { + const calls: Array<{ name: string; config: unknown }> = [] + const ctx = { + plugin(plugin: { name?: string }, config?: unknown) { + calls.push({ name: plugin.name ?? '', config }) + }, + } as unknown as Context + + stdioAgent.composeTerminalApp(ctx, { + model: 'mock', + welcome: 'TUI ready', + ui: { mode: 'tui', tui: { color: false, maxToolOutputLines: 3 } }, + }, true) + expect(calls.map(call => call.name)).toContain('ui-tui') + expect(calls.map(call => call.name)).not.toContain('ui-stdio') + expect(calls.map(call => call.name)).not.toContain('ConsoleExporter') + expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({ + agent: 'main', welcome: 'TUI ready', color: false, maxToolOutputLines: 3, + }) + + calls.length = 0 + stdioAgent.composeTerminalApp(ctx, { model: 'mock', ui: { mode: 'tui' } }, true) + expect(calls.find(call => call.name === 'ui-tui')?.config).toMatchObject({ + agent: 'main', welcome: 'ready.', + }) + + calls.length = 0 + stdioAgent.composeTerminalApp(ctx, { model: 'mock', ui: { mode: 'readline' } }, false) + expect(calls.map(call => call.name)).toContain('ui-stdio') + expect(calls.map(call => call.name)).toContain('ConsoleExporter') + expect(calls.map(call => call.name)).not.toContain('ui-tui') + }) + it('composes the spine + front-door cluster and pre-creates the main agent', async () => { const ctx = await mount({ model: 'mock', persona: 'hi', persistenceRoot: '/tmp/dsh-stdio-demo-spec', skills: await isolatedSkillsConfig() }) // The spine services (brought up by the agent-core bundle) are all present. diff --git a/packages/examples/stdio-demo/tsconfig.json b/packages/examples/stdio-demo/tsconfig.json index bb360810a5..3145047c03 100644 --- a/packages/examples/stdio-demo/tsconfig.json +++ b/packages/examples/stdio-demo/tsconfig.json @@ -38,6 +38,9 @@ { "path": "../../ui/stdio" }, + { + "path": "../../ui/tui" + }, { "path": "../../ui/tool-ask-user" }, diff --git a/packages/todo/README.md b/packages/todo/README.md index b6a9ce2fc5..bfe5ec7503 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../examples/stdio-demo) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [terminal app](../examples/stdio-demo) shows a persistent TUI plan or readline checklist, while the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index 0b07539f3d..f6097abe96 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../examples/stdio-demo) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [terminal app](../../examples/stdio-demo) shows a persistent TUI plan or readline checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape diff --git a/packages/ui/README.md b/packages/ui/README.md index 699a11bdb4..3dd26d80a8 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -9,12 +9,13 @@ Integrations that expose the agent to an external editor or client. These are ** | `permission/` | User-facing permission presets (`workspace-write`/`danger-full-access`): one product-level select bundling the sandbox-mode and approval-policy knobs, written through to their session events | `ctx.permission` | | `user-interaction/` | Abstract human question/answer seam used by UI-backed confirmation tools | `ctx.userInteraction` | | `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) | -| `stdio/` | Terminal readline channel over `ctx.agents`, `session/event`, and `ctx.userInteraction`; agent lifecycle stays with app/developer code | (drives `ctx.agents`) | +| `stdio/` | Line-oriented terminal channel for pipes and automation; drives `ctx.agents`, renders `session/event`, and answers `ctx.userInteraction` | (drives `ctx.agents`) | +| `tui/` | Interactive pi-tui terminal channel for TTY sessions; renders `session/event`, tool presentation intents, and answers `ctx.userInteraction` | (drives `ctx.agents`) | | `jsonrpc/` | Stdio JSON-RPC server for out-of-process SDK clients | (drives `ctx.agents`) | | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) plugin is the unstructured readline analogue of the `acp` bridge; app bundles and SDK projects compose it explicitly with the services and tools their product profile selects. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The `jsonrpc` plugin is the SDK-client sibling of the `acp` bridge (a JSON-RPC server over `ctx.agents` for out-of-process SDK clients rather than editors). The [`stdio`](stdio/README.md) and [`tui`](tui/README.md) plugins are the two terminal front doors: one is line-oriented for pipes, the other is interactive for TTYs. App bundles and SDK projects compose the appropriate channel explicitly with the services and tools their product profile selects. `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with their UI channel owners. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and the app/bridge packages provide concrete providers. -The runnable app bundles that bake these bridges into boot bins — the stdio chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles that bake these bridges into boot bins — the terminal chat app, the ACP server app, and the JSON-RPC SDK-runtime bin — live in [`examples/`](../examples/README.md) (`stdio-demo`, `acp-demo`, `jsonrpc-demo`), each composed over the [`agent-spine-demo`](../examples/agent-spine-demo/README.md) bundle. `ui/` keeps the reusable bridge/channel plugins and the `app-boot` glue; each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index 3650cf27d4..27346efcec 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -2,7 +2,7 @@ Agent Client Protocol bridge over JSON-RPC stdio. Editors can create or resume agents, stream their events, answer questions and approvals, and render tool calls. One connection supports multiple isolated sessions; Zed is the primary compatibility target. -It is a **client-driver / UI plugin**, the structured analogue of the readline `stdio-chat` plugin — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. +It is a **client-driver / UI plugin**, the structured analogue of the terminal `dsh-tui`/`dsh-stdio` channels — NOT a loop change and NOT a [capability seam](../../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md). It consumes the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, and `dsh-session-persistence`. ## Service / plugin diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md new file mode 100644 index 0000000000..1e678beac8 --- /dev/null +++ b/packages/ui/tui/README.md @@ -0,0 +1,56 @@ +# @deepseek-ai/dsh-tui + +The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead. + +This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. + +The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. + +While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. + +## Config + +| Key | Default | Meaning | +|---|---|---| +| `welcome` | `ready.` | Header subtitle | +| `agent` | `main` | Agent id driven by the terminal | +| `showReasoning` | `true` | Render reasoning blocks | +| `maxToolOutputLines` | `12` | Collapsed tool-card output limit | +| `maxQuestionOptions` | `8` | Visible options in a question overlay | +| `questionDialogWidth` | `72` | Question-overlay width in columns | +| `questionDialogMaxHeight` | `20` | Question-overlay maximum rows | +| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | +| `color` | `true` | Apply the built-in ANSI palette | +| `title` | `DeepSeek Harness` | Terminal window title | + +```yaml +- id: terminal + name: '@deepseek-ai/dsh-tui' + config: + welcome: 'Coding agent ready.' + agent: main + showReasoning: true + maxToolOutputLines: 12 +``` + +Startup fails before mounting when either process stream is not a TTY. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. + +## Model Experience + +### Interactive prompt input + +**What the model sees**: Each non-empty editor submission becomes one text block, sent with `agent.send()` while the target agent is idle and `agent.steer()` while it is running. Slash commands and keybindings are TUI-only. + +**Token effect**: Submitted text is retained under the agent loop's normal session-history and compaction rules. Headers, cards, Markdown rendering, status lines, plans, and help text add no tokens. + +### Interactive user-question answers + +**What the model sees**: When a consumer calls `ctx.userInteraction.ask()`, this provider presents each question in order and returns selected option labels or `custom` text. Abort, cancellation, or UI disposal becomes `Error: ask_user_question was interrupted before the user answered` through `dsh-tool-ask-user`. + +**Token effect**: Waiting and terminal overlays add no tokens; the resolved answer or error is model-visible only through the calling tool or plugin's result. + +## Known Limitations and Deferred Work + +- **One configured agent owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `agent`. +- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering. +- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must select `dsh-stdio` before mounting this plugin rather than expecting an internal fallback. diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json new file mode 100644 index 0000000000..e960f33e65 --- /dev/null +++ b/packages/ui/tui/package.json @@ -0,0 +1,45 @@ +{ + "name": "@deepseek-ai/dsh-tui", + "description": "Interactive pi-tui terminal front door for DeepSeek Harness agents", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-user-interaction": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "@earendil-works/pi-tui": "0.80.7", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts new file mode 100644 index 0000000000..14f45242d7 --- /dev/null +++ b/packages/ui/tui/src/index.ts @@ -0,0 +1,1284 @@ +/** + * Interactive pi-tui front door for DeepSeek Harness agents. It renders the + * durable session transcript, drives one configured agent, and provides + * keyboard-driven user-interaction dialogs without owning agent lifecycle. + * @module @deepseek-ai/dsh-tui + */ + +import { homedir } from 'node:os' +import { relative, resolve, sep } from 'node:path' +import { + Box, + CombinedAutocompleteProvider, + Container, + Editor, + Input, + Key, + Loader, + Markdown, + Spacer, + Text, + TUI, + ProcessTerminal, + matchesKey, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, + type Component, + type EditorTheme, + type Focusable, + type MarkdownTheme, + type OverlayHandle, + type SelectListTheme, + type Terminal, +} from '@earendil-works/pi-tui' +import type { Context } from 'cordis' +import z from 'schemastery' +import { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' +import type { + FileDiff, + TerminalCallView, + ToolCallView, + ToolDefinition, + ToolResultView, +} from '@deepseek-ai/dsh-tools' +import { + UserInteractionError, + type AskUserQuestionAnswer, + type AskUserQuestionAnswerItem, + type AskUserQuestionItem, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-interaction' + +export const name = 'ui-tui' +export const inject = ['agents', 'userInteraction', 'tools'] + +/** Presentation settings for the pi-tui terminal mode. */ +export interface TuiConfig { + /** Render model reasoning blocks. */ + showReasoning?: boolean + /** Maximum tool-output lines shown before the card is collapsed. */ + maxToolOutputLines?: number + /** Maximum options visible at once in a user-question dialog. */ + maxQuestionOptions?: number + /** User-question dialog width in terminal columns. */ + questionDialogWidth?: number + /** User-question dialog maximum height in terminal rows. */ + questionDialogMaxHeight?: number + /** Show the terminal's hardware cursor at the pi editor's IME marker. */ + showHardwareCursor?: boolean + /** Apply the built-in ANSI color palette. */ + color?: boolean + /** Terminal window title while the UI is mounted. */ + title?: string +} + +const showReasoningSchema = z.boolean().default(true) +const maxToolOutputLinesSchema = z.number().step(1).min(1).default(12) +const maxQuestionOptionsSchema = z.number().step(1).min(1).default(8) +const questionDialogWidthSchema = z.number().step(1).min(20).default(72) +const questionDialogMaxHeightSchema = z.number().step(1).min(6).default(20) +const showHardwareCursorSchema = z.boolean().default(false) +const colorSchema = z.boolean().default(true) +const titleSchema = z.string().default('DeepSeek Harness') + +/** Schemastery schema for presentation settings embedded by app bundles. */ +export const TuiConfigSchema: z = z.object({ + showReasoning: showReasoningSchema, + maxToolOutputLines: maxToolOutputLinesSchema, + maxQuestionOptions: maxQuestionOptionsSchema, + questionDialogWidth: questionDialogWidthSchema, + questionDialogMaxHeight: questionDialogMaxHeightSchema, + showHardwareCursor: showHardwareCursorSchema, + color: colorSchema, + title: titleSchema, +}) + +/** Serializable plugin configuration. */ +export interface Config extends TuiConfig { + /** Header subtitle. Defaults to `ready.`. */ + welcome?: string + /** Agent id driven by this terminal. Defaults to `main`. */ + agent?: string +} + +export const Config: z = z.object({ + welcome: z.string().default('ready.'), + agent: z.string().default('main'), + showReasoning: showReasoningSchema, + maxToolOutputLines: maxToolOutputLinesSchema, + maxQuestionOptions: maxQuestionOptionsSchema, + questionDialogWidth: questionDialogWidthSchema, + questionDialogMaxHeight: questionDialogMaxHeightSchema, + showHardwareCursor: showHardwareCursorSchema, + color: colorSchema, + title: titleSchema, +}) + +/** Fully defaulted TUI presentation settings. */ +export interface ResolvedTuiConfig { + showReasoning: boolean + maxToolOutputLines: number + maxQuestionOptions: number + questionDialogWidth: number + questionDialogMaxHeight: number + showHardwareCursor: boolean + color: boolean + title: string +} + +/** Runtime boundary used by the interactive TUI. */ +export interface TuiRuntime { + /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ + terminal: Terminal + /** Exit hook used by `/exit`, Ctrl+D, or Ctrl+C while idle. */ + exit(code: number): void +} + +/** + * Apply direct-call defaults after Loader schema validation has normally run. + * + * @param config - Deployment-provided terminal presentation settings. + * @returns Complete settings consumed by the TUI renderer. + */ +export function resolveTuiConfig(config: TuiConfig | undefined): ResolvedTuiConfig { + return { + showReasoning: config?.showReasoning ?? true, + maxToolOutputLines: config?.maxToolOutputLines ?? 12, + maxQuestionOptions: config?.maxQuestionOptions ?? 8, + questionDialogWidth: config?.questionDialogWidth ?? 72, + questionDialogMaxHeight: config?.questionDialogMaxHeight ?? 20, + showHardwareCursor: config?.showHardwareCursor ?? false, + color: config?.color ?? true, + title: config?.title ?? 'DeepSeek Harness', + } +} + +interface Palette { + accent: (text: string) => string + accent2: (text: string) => string + text: (text: string) => string + muted: (text: string) => string + dim: (text: string) => string + success: (text: string) => string + warning: (text: string) => string + error: (text: string) => string + code: (text: string) => string + added: (text: string) => string + removed: (text: string) => string + bold: (text: string) => string + italic: (text: string) => string + underline: (text: string) => string + strike: (text: string) => string + userBg: (text: string) => string + pendingBg: (text: string) => string + successBg: (text: string) => string + errorBg: (text: string) => string + selectedBg: (text: string) => string +} + +function ansi(open: string, close: string, enabled: boolean): (text: string) => string { + return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text +} + +function createPalette(enabled: boolean): Palette { + return { + accent: ansi('38;5;39', '39', enabled), + accent2: ansi('38;5;141', '39', enabled), + text: ansi('38;5;252', '39', enabled), + muted: ansi('38;5;245', '39', enabled), + dim: ansi('38;5;240', '39', enabled), + success: ansi('38;5;78', '39', enabled), + warning: ansi('38;5;221', '39', enabled), + error: ansi('38;5;203', '39', enabled), + code: ansi('38;5;215', '39', enabled), + added: ansi('38;5;78', '39', enabled), + removed: ansi('38;5;203', '39', enabled), + bold: ansi('1', '22', enabled), + italic: ansi('3', '23', enabled), + underline: ansi('4', '24', enabled), + strike: ansi('9', '29', enabled), + userBg: ansi('48;5;236', '49', enabled), + pendingBg: ansi('48;5;235', '49', enabled), + successBg: ansi('48;5;22', '49', enabled), + errorBg: ansi('48;5;52', '49', enabled), + selectedBg: ansi('48;5;24', '49', enabled), + } +} + +function markdownTheme(palette: Palette): MarkdownTheme { + return { + heading: text => palette.accent(text), + link: text => palette.accent(text), + // pi-tui requires this URL slot but its current Markdown renderer does not invoke it. + /* v8 ignore next */ + linkUrl: text => palette.dim(text), + code: text => palette.code(text), + codeBlock: text => palette.text(text), + codeBlockBorder: text => palette.dim(text), + quote: text => palette.muted(text), + quoteBorder: text => palette.accent2(text), + hr: text => palette.dim(text), + listBullet: text => palette.accent(text), + bold: text => palette.bold(text), + italic: text => palette.italic(text), + strikethrough: text => palette.strike(text), + underline: text => palette.underline(text), + } +} + +function selectTheme(palette: Palette): SelectListTheme { + return { + selectedPrefix: palette.accent, + selectedText: palette.accent, + description: palette.muted, + scrollInfo: palette.dim, + noMatch: palette.warning, + } +} + +function contentText(content: readonly ContentBlock[]): string { + const parts: string[] = [] + for (const block of content) { + switch (block.type) { + case 'text': + case 'reasoning': + parts.push(block.text) + break + case 'tool-call': + parts.push(`${block.name}(${block.arguments})`) + break + case 'tool-result': + parts.push(contentText(block.content)) + break + default: { + const rawType = (block as { type?: unknown }).type + parts.push(`[${typeof rawType === 'string' ? rawType : 'content'}]`) + break + } + } + } + return parts.join('') +} + +function textBlocks(content: readonly ContentBlock[], type: 'text' | 'reasoning'): string { + return content + .filter((block): block is Extract => block.type === type) + .map(block => block.text) + .join('\n\n') +} + +class HeaderComponent implements Component { + constructor( + private readonly agent: Agent, + private readonly welcome: string, + private readonly palette: Palette, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const usable = Math.max(1, width - 4) + const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}` + const model = this.agent.options.model ?? 'model unset' + const detail = `${this.agent.id} • ${model} • ${this.agent.session.id}` + const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`) + const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`) + const lines = [title, this.palette.muted(this.welcome), this.palette.dim(detail)] + .flatMap(line => wrapTextWithAnsi(line, usable)) + .map((line) => { + const clipped = truncateToWidth(line, usable, '') + return `${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, usable - visibleWidth(clipped)))} ${this.palette.accent('│')}` + }) + return [top, ...lines, bottom] + } +} + +class UserMessageComponent extends Box { + constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { + super(1, 1, value => palette.userBg(value)) + this.addChild(new Text(palette.bold(palette.accent(label)), 0, 0)) + this.addChild(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }, { + preserveOrderedListMarkers: true, + preserveBackslashEscapes: true, + })) + } +} + +class AssistantMessageComponent extends Container { + constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { + super() + const reasoning = textBlocks(content, 'reasoning').trim() + const text = textBlocks(content, 'text').trim() + if (reasoning && showReasoning) { + this.addChild(new Spacer(1)) + this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) + this.addChild(new Markdown(reasoning, 1, 0, mdTheme, { + color: value => palette.muted(value), + italic: true, + })) + } + if (text) { + this.addChild(new Spacer(1)) + this.addChild(new Text(palette.bold(palette.accent2('Assistant')), 1, 0)) + this.addChild(new Markdown(text, 1, 0, mdTheme, { color: value => palette.text(value) })) + } + } +} + +interface StreamingBlock { + type: string + text: string +} + +class StreamingAssistantComponent extends Container { + private readonly blocks = new Map() + + constructor( + private showReasoning: boolean, + private readonly palette: Palette, + private readonly mdTheme: MarkdownTheme, + ) { + super() + } + + update(chunk: StreamChunk): void { + if (chunk.type === 'block-start') { + this.blocks.set(chunk.index, { type: chunk.blockType, text: '' }) + } else if (chunk.type === 'text-delta' || chunk.type === 'reasoning-delta') { + const type = chunk.type === 'text-delta' ? 'text' : 'reasoning' + const block = this.blocks.get(chunk.index) ?? { type, text: '' } + block.text += chunk.text + this.blocks.set(chunk.index, block) + } else if (chunk.type === 'block-end' && (chunk.block.type === 'text' || chunk.block.type === 'reasoning')) { + this.blocks.set(chunk.index, { type: chunk.block.type, text: chunk.block.text }) + } + this.rebuild() + } + + setShowReasoning(show: boolean): void { + this.showReasoning = show + this.rebuild() + } + + private rebuild(): void { + this.clear() + const content: ContentBlock[] = [...this.blocks.entries()] + .sort(([left], [right]) => left - right) + .flatMap(([, block]) => { + if (block.type === 'text') return [{ type: 'text', text: block.text }] + if (block.type === 'reasoning') return [{ type: 'reasoning', text: block.text }] + return [] + }) + const component = new AssistantMessageComponent(content, this.showReasoning, this.palette, this.mdTheme) + for (const child of component.children) this.addChild(child) + } +} + +interface ParsedArguments { + value: unknown + valid: boolean +} + +function parseArguments(raw: string): ParsedArguments { + try { + return { value: JSON.parse(raw), valid: true } + } catch { + return { value: raw, valid: false } + } +} + +function pretty(value: unknown): string { + if (typeof value === 'string') return value + // The lib declaration narrows `unknown` to a string-returning overload, but + // JSON.stringify returns undefined for runtime values such as symbols. + const serialized = JSON.stringify(value, null, 2) as string | undefined + return serialized ?? String(value) +} + +function diffLines(diff: FileDiff, palette: Palette): string[] { + const lines = [palette.bold(diff.path)] + if (diff.oldText !== null) { + for (const line of diff.oldText.split('\n')) lines.push(palette.removed(`- ${line}`)) + } + for (const line of diff.newText.split('\n')) lines.push(palette.added(`+ ${line}`)) + return lines +} + +class ToolCardComponent implements Component { + private result: { content: ContentBlock[]; isError: boolean; meta?: unknown } | undefined + private expanded = false + private callView: ToolCallView + private resultView: ToolResultView | undefined + + constructor( + private readonly name: string, + private readonly parsed: ParsedArguments, + private readonly definition: ToolDefinition | undefined, + private readonly maxOutputLines: number, + private readonly palette: Palette, + ) { + this.callView = this.presentCall() + } + + private presentCall(): ToolCallView { + if (this.parsed.valid && this.definition?.presentCall) { + try { + const view = this.definition.presentCall(this.parsed.value) + if (view !== undefined) return view + } catch (error: unknown) { + return { card: 'generic', title: this.name, rawInput: `Presenter failed: ${String(error)}` } + } + } + return { card: 'generic', title: this.name, rawInput: this.parsed.value } + } + + updateResult(event: Extract['data']): void { + this.result = { + content: [...event.content], + isError: event.isError, + ...event.meta !== undefined ? { meta: event.meta } : {}, + } + if (this.parsed.valid && this.definition?.presentResult) { + try { + const view = this.definition.presentResult(this.parsed.value, this.result) + if (view !== undefined) this.resultView = view + } catch (error: unknown) { + this.resultView = { card: 'generic', content: [{ type: 'text', text: `Presenter failed: ${String(error)}` }] } + } + } + } + + setExpanded(expanded: boolean): void { + this.expanded = expanded + } + + invalidate(): void {} + + render(width: number): string[] { + const isError = this.result?.isError ?? false + const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') + const body = this.renderBody() + const title = truncateToWidth(`${glyph} ${this.title()}`, Math.max(1, width - 4), '') + const visibleBody = this.expanded || body.length <= this.maxOutputLines + ? body + : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] + const box = new Box(1, visibleBody.length > 0 ? 1 : 0, (value) => { + if (this.result === undefined) return this.palette.pendingBg(value) + return isError ? this.palette.errorBg(value) : this.palette.successBg(value) + }) + box.addChild(new Text(this.palette.bold(title), 0, 0)) + if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0)) + return box.render(width) + } + + private title(): string { + return this.resultView?.title ?? this.callView.title + } + + private renderBody(): string[] { + const view = this.resultView ?? this.callView + if (view.card === 'terminal') { + const pending = this.callView.card === 'terminal' ? this.callView : undefined + const lines: string[] = [] + if (pending?.description) lines.push(this.palette.muted(pending.description)) + if (pending?.cwd) lines.push(this.palette.dim(pending.cwd)) + if (this.resultView?.card === 'terminal') { + if (this.resultView.output) lines.push(...this.resultView.output.split('\n')) + if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) + if (this.resultView.signal !== undefined) lines.push(this.palette.error(`[signal ${this.resultView.signal}]`)) + } else if (this.result === undefined) { + // A pending terminal view is the call view itself; TerminalCallView requires a title. + lines.push(this.palette.code(`$ ${(pending as TerminalCallView).title}`)) + } else { + lines.push(...contentText(this.result.content).split('\n')) + } + return lines.filter(Boolean) + } + if (view.card === 'diff') { + return view.diffs.flatMap((diff, index) => [ + ...index > 0 ? [''] : [], + ...diffLines(diff, this.palette), + ]) + } + const content = view.content ?? this.result?.content + const lines: string[] = [] + if (content !== undefined) lines.push(...contentText(content).split('\n')) + const rawInput = this.result === undefined && this.callView.card === 'generic' + ? this.callView.rawInput + : undefined + if (rawInput !== undefined) lines.push(...pretty(rawInput).split('\n')) + return lines.filter((line, index, all) => line.length > 0 || (index > 0 && index < all.length - 1)) + } +} + +class TodoComponent implements Component { + private todos: readonly TodoItem[] = [] + + constructor(private readonly palette: Palette) {} + + update(todos: readonly TodoItem[]): void { + this.todos = todos + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.todos.length === 0) return [] + const lines = [this.palette.bold(this.palette.accent('Plan'))] + for (const todo of this.todos) { + const prefix = todo.status === 'completed' + ? this.palette.success('✓') + : todo.status === 'in_progress' + ? this.palette.warning('●') + : this.palette.dim('○') + const text = todo.status === 'completed' ? this.palette.muted(todo.content) : todo.content + lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) + } + return ['', ...lines] + } +} + +function formatTokens(value: number): string { + if (value < 1_000) return String(value) + if (value < 10_000) return `${(value / 1_000).toFixed(1)}k` + if (value < 1_000_000) return `${Math.round(value / 1_000)}k` + return `${(value / 1_000_000).toFixed(1)}m` +} + +function formatCwd(cwd: string | undefined): string { + if (cwd === undefined) return 'cwd unset' + const home = homedir() + const rel = relative(resolve(home), resolve(cwd)) + if (rel === '') return '~' + if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` + return cwd +} + +function sessionTokens(session: Session): { input: number; output: number } { + let input = 0 + let output = 0 + for (const event of session.events) { + if (event.type !== 'assistant/message' || event.data.usage === undefined) continue + input += event.data.usage.inputTokens + output += event.data.usage.outputTokens + } + return { input, output } +} + +class FooterComponent implements Component { + constructor( + private readonly agent: Agent, + private readonly palette: Palette, + private readonly toolsExpanded: () => boolean, + private readonly showReasoning: () => boolean, + private readonly tokens: () => { input: number; output: number }, + ) {} + + invalidate(): void {} + + render(width: number): string[] { + const { input, output } = this.tokens() + const left = `${formatCwd(this.agent.session.header.cwd)} ↑${formatTokens(input)} ↓${formatTokens(output)}` + const right = `${this.agent.status} reasoning:${this.showReasoning() ? 'on' : 'off'} tools:${this.toolsExpanded() ? 'expanded' : 'compact'}` + const leftStyled = this.palette.dim(left) + const available = Math.max(0, width - visibleWidth(left) - 2) + const rightClipped = truncateToWidth(right, available, '') + const gap = ' '.repeat(Math.max(1, width - visibleWidth(left) - visibleWidth(rightClipped))) + return [truncateToWidth(`${leftStyled}${gap}${this.palette.dim(rightClipped)}`, width, '')] + } +} + +interface QuestionSelection { + selected: string[] + custom?: string +} + +class QuestionDialog implements Component, Focusable { + private selectedIndex = 0 + private selected = new Set() + private mode: 'options' | 'custom' + private error = '' + private readonly input = new Input() + private readonly options: NonNullable + focused = false + + constructor( + private readonly question: AskUserQuestionItem, + private readonly maxVisible: number, + private readonly palette: Palette, + private readonly done: (selection: QuestionSelection) => void, + private readonly cancel: () => void, + ) { + this.options = question.options ?? [] + this.mode = this.options.length > 0 ? 'options' : 'custom' + this.input.onSubmit = (value) => { this.submitCustom(value) } + this.input.onEscape = () => { + if (this.options.length > 0) { + this.mode = 'options' + this.error = '' + } else { + this.cancel() + } + } + } + + invalidate(): void { + this.input.invalidate() + } + + handleInput(data: string): void { + this.invalidate() + if (this.mode === 'custom') { + this.input.focused = this.focused + this.input.handleInput(data) + return + } + const options = this.options + if (matchesKey(data, Key.up)) { + this.selectedIndex = this.selectedIndex === 0 ? options.length - 1 : this.selectedIndex - 1 + } else if (matchesKey(data, Key.down)) { + this.selectedIndex = this.selectedIndex === options.length - 1 ? 0 : this.selectedIndex + 1 + } else if (matchesKey(data, Key.space) && this.question.multiSelect) { + if (this.selected.has(this.selectedIndex)) this.selected.delete(this.selectedIndex) + else this.selected.add(this.selectedIndex) + } else if (matchesKey(data, Key.enter)) { + const indices = this.question.multiSelect ? [...this.selected].sort((a, b) => a - b) : [this.selectedIndex] + if (indices.length === 0) { + this.error = 'Select at least one option, or press C for a custom answer.' + return + } + this.done({ selected: indices.map(index => options[index]?.label).filter((label): label is string => label !== undefined) }) + } else if (data.toLowerCase() === 'c') { + this.mode = 'custom' + this.error = '' + } else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl('c'))) { + this.cancel() + } + } + + private submitCustom(value: string): void { + const custom = value.trim() + if (custom === '') { + this.error = 'Enter an answer before submitting.' + return + } + this.done({ selected: [], custom }) + } + + render(width: number): string[] { + this.input.focused = this.focused + const innerWidth = Math.max(1, width - 4) + const title = this.question.header ?? 'Question' + const topLabel = ` ${title} ` + const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` + const lines: string[] = [this.palette.accent(top)] + const push = (line: string): void => { + const clipped = truncateToWidth(line, innerWidth, '') + lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) + } + for (const line of wrapTextWithAnsi(this.palette.bold(this.question.question), innerWidth)) push(line) + push('') + if (this.mode === 'custom') { + for (const line of this.input.render(innerWidth)) push(line) + push(this.palette.dim(this.options.length > 0 ? 'Enter submit • Esc options' : 'Enter submit • Esc cancel')) + } else { + const options = this.options + const start = Math.max(0, Math.min( + this.selectedIndex - Math.floor(this.maxVisible / 2), + options.length - this.maxVisible, + )) + const end = Math.min(options.length, start + this.maxVisible) + for (let index = start; index < end; index += 1) { + // `index < end <= options.length`; the options array is borrowed immutably for this dialog. + const option = options[index] as NonNullable[number] + const cursor = index === this.selectedIndex ? this.palette.accent('›') : ' ' + const mark = this.question.multiSelect + ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]' + : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') + const description = option.description ? this.palette.muted(` — ${option.description}`) : '' + const line = `${cursor} ${mark} ${option.label}${description}` + push(index === this.selectedIndex ? this.palette.selectedBg(line) : line) + } + if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) + push(this.palette.dim(this.question.multiSelect + ? '↑↓ navigate • Space toggle • Enter submit • C custom • Esc cancel' + : '↑↓ navigate • Enter select • C custom • Esc cancel')) + } + if (this.error) push(this.palette.error(this.error)) + lines.push(this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`)) + return lines + } +} + +interface PendingQuestion { + request: AskUserQuestionRequest + index: number + answers: AskUserQuestionAnswerItem[] + resolve(answer: AskUserQuestionAnswer): void + reject(error: unknown): void + onAbort: () => void + overlay: OverlayHandle | undefined +} + +/** Lifecycle handle for a mounted interactive terminal channel. */ +export interface TuiController { + /** Stop rendering, restore the terminal, and reject pending questions. */ + dispose(): Promise +} + +function activeSurfaceSeqs(session: Session): Set { + return new Set(session.surface.nodes.map(node => node.seq)) +} + +function activeToolCallIds(session: Session, active: ReadonlySet): Set { + const ids = new Set() + for (const event of session.events) { + if (event.type !== 'assistant/message' || !active.has(event.seq)) continue + for (const block of event.data.content) { + if (block.type === 'tool-call') ids.add(block.id) + } + } + return ids +} + +/** + * Start the interactive pi-tui channel for an already-created target agent. + * @param ctx - agent, tools, session-event, and user-interaction context. + * @param config - target agent, banner, and TUI presentation config. + * @param runtime - terminal and process-exit boundary. + * @returns lifecycle controller used by the Cordis effect disposer. + */ +export function createTuiChat( + ctx: Context, + config: Config, + runtime: TuiRuntime, +): TuiController { + const agentId = AgentId(config.agent ?? 'main') + const agent = ctx.agents.get(agentId) + if (agent === undefined) throw new Error(`ui-tui: agent "${agentId}" is not running`) + const resolved = resolveTuiConfig(config) + const palette = createPalette(resolved.color) + const mdTheme = markdownTheme(palette) + const ui = new TUI(runtime.terminal, resolved.showHardwareCursor) + const chat = new Container() + const todoContainer = new Container() + const statusContainer = new Container() + const editor = new Editor(ui, { + borderColor: palette.dim, + selectList: selectTheme(palette), + } satisfies EditorTheme, { paddingX: 1 }) + const todo = new TodoComponent(palette) + let showReasoning = resolved.showReasoning + let toolsExpanded = false + let streaming: StreamingAssistantComponent | undefined + let statusLoader: Loader | undefined + let disposed = false + let shuttingDown: Promise | undefined + const tokens = sessionTokens(agent.session) + const toolCards = new Map() + const allToolCards = new Set() + const liveErrors = new Set() + const questionQueue: PendingQuestion[] = [] + let activeQuestion: PendingQuestion | undefined + + const welcome = config.welcome ?? 'ready.' + const header = new HeaderComponent(agent, welcome, palette) + const footer = new FooterComponent(agent, palette, () => toolsExpanded, () => showReasoning, () => tokens) + ui.addChild(header) + ui.addChild(chat) + ui.addChild(statusContainer) + todoContainer.addChild(todo) + ui.addChild(todoContainer) + ui.addChild(editor) + ui.addChild(footer) + ui.setFocus(editor) + runtime.terminal.setTitle(resolved.title) + + const requestRender = (): void => { + footer.invalidate() + ui.requestRender() + } + + const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { + const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted + chat.addChild(new Spacer(1)) + chat.addChild(new Text(color(message), 1, 0)) + requestRender() + } + + const clearStatus = (): void => { + statusLoader?.stop() + statusLoader = undefined + statusContainer.clear() + runtime.terminal.setProgress(false) + } + + const setStatus = (status: AgentStatus): void => { + clearStatus() + editor.borderColor = status === 'running' ? text => palette.accent(text) : text => palette.dim(text) + if (status === 'running') { + statusLoader = new Loader(ui, text => palette.accent(text), text => palette.muted(text), 'Working — Enter sends steering, Esc cancels') + statusContainer.addChild(statusLoader) + runtime.terminal.setProgress(true) + } + requestRender() + } + + const parsedTool = (event: Extract): ToolCardComponent => { + const parsed = parseArguments(event.data.arguments) + const card = new ToolCardComponent( + event.data.name, + parsed, + ctx.tools.get(event.data.name, agent), + resolved.maxToolOutputLines, + palette, + ) + card.setExpanded(toolsExpanded) + toolCards.set(event.data.callId, card) + allToolCards.add(card) + return card + } + + const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { + switch (event.type) { + case 'user/message': { + const text = contentText(event.data.content).trim() + if (text) { + chat.addChild(new Spacer(1)) + chat.addChild(new UserMessageComponent(text, palette, mdTheme)) + if (options.addHistory) editor.addToHistory(text) + } + break + } + case 'steering/message': { + const text = contentText(event.data.content).trim() + if (text) { + chat.addChild(new Spacer(1)) + chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering')) + } + break + } + case 'context/message': { + const text = contentText(event.data.content).trim() + if (text) { + const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.dim(`Context · ${source}`), 1, 0)) + chat.addChild(new Text(palette.muted(text), 1, 0)) + } + break + } + case 'prompt/blocked': + appendNotice(`Prompt blocked: ${event.data.reason}`, 'warning') + break + case 'assistant/chunk': + if (options.renderChunks) { + if (streaming === undefined) { + streaming = new StreamingAssistantComponent(showReasoning, palette, mdTheme) + chat.addChild(streaming) + } + streaming.update(event.data.chunk) + } + break + case 'assistant/message': { + if (streaming !== undefined) { + const index = chat.children.indexOf(streaming) + if (index >= 0) chat.children.splice(index, 1) + streaming = undefined + } + const component = new AssistantMessageComponent(event.data.content, showReasoning, palette, mdTheme) + if (component.children.length > 0) chat.addChild(component) + break + } + case 'tool/call': + chat.addChild(new Spacer(1)) + chat.addChild(parsedTool(event)) + break + case 'tool/result': { + let card = toolCards.get(event.data.callId) + if (card === undefined) { + card = new ToolCardComponent('tool', { value: {}, valid: true }, undefined, resolved.maxToolOutputLines, palette) + chat.addChild(new Spacer(1)) + chat.addChild(card) + allToolCards.add(card) + } + card.updateResult(event.data) + toolCards.delete(event.data.callId) + break + } + case 'todo/write': + todo.update(event.data.todos) + break + case 'turn/end': + if (event.data.reason.kind === 'error') { + const key = `${event.data.turn}:${event.data.reason.step}` + if (!liveErrors.delete(key)) appendNotice(event.data.reason.message, 'error') + } else if (event.data.reason.kind === 'aborted') { + appendNotice(event.data.reason.reason ?? 'Turn cancelled.', 'warning') + } else if (event.data.reason.kind === 'max-tokens') { + appendNotice('The model reached its output-token limit.', 'warning') + } else if (event.data.reason.kind === 'rejected') { + appendNotice(`Turn rejected: ${event.data.reason.reason}`, 'warning') + } else if (event.data.reason.kind === 'interrupted') { + appendNotice('The previous process ended during this turn.', 'warning') + } + break + default: + break + } + } + + const rebuildTranscript = (populateHistory: boolean): void => { + chat.clear() + toolCards.clear() + allToolCards.clear() + streaming = undefined + const active = activeSurfaceSeqs(agent.session) + const activeCalls = activeToolCallIds(agent.session, active) + for (const event of agent.session.events) { + const isSurface = event.type === 'user/message' + || event.type === 'assistant/message' + || event.type === 'tool/result' + || event.type === 'context/message' + || event.type === 'steering/message' + if (isSurface && !active.has(event.seq)) continue + if (event.type === 'tool/call' && !activeCalls.has(event.data.callId)) continue + renderEvent(event, { addHistory: populateHistory, renderChunks: false }) + } + requestRender() + } + + const removeAbortListener = (pending: PendingQuestion): void => { + pending.request.signal?.removeEventListener('abort', pending.onAbort) + } + + const rejectQuestion = (pending: PendingQuestion): void => { + pending.overlay?.hide() + pending.overlay = undefined + removeAbortListener(pending) + pending.reject(new UserInteractionError( + 'ask_user_question was interrupted before the user answered', + 'ASK_ABORTED', + )) + } + + const startNextQuestion = (): void => { + if (activeQuestion !== undefined || disposed) return + const pending = questionQueue.shift() + if (pending === undefined) return + activeQuestion = pending + const show = (): void => { + const question = pending.request.questions[pending.index] + if (question === undefined) { + activeQuestion = undefined + removeAbortListener(pending) + pending.resolve({ answers: pending.answers }) + startNextQuestion() + return + } + const dialog = new QuestionDialog( + question, + resolved.maxQuestionOptions, + palette, + (selection) => { + pending.overlay?.hide() + pending.overlay = undefined + pending.answers.push({ id: question.id, ...selection }) + pending.index += 1 + show() + }, + () => { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + }, + ) + pending.overlay = ui.showOverlay(dialog, { + width: resolved.questionDialogWidth, + maxHeight: resolved.questionDialogMaxHeight, + anchor: 'center', + margin: 1, + }) + requestRender() + } + show() + } + + const disposeUserInteraction = ctx.userInteraction.registerProvider({ + ask(request) { + return new Promise((resolveAnswer, reject) => { + const pending: PendingQuestion = { + request, + index: 0, + answers: [], + resolve: resolveAnswer, + reject, + overlay: undefined, + onAbort: () => { + if (activeQuestion === pending) { + activeQuestion = undefined + rejectQuestion(pending) + startNextQuestion() + return + } + // A non-active pending ask remains in the queue until this listener settles it. + questionQueue.splice(questionQueue.indexOf(pending), 1) + rejectQuestion(pending) + }, + } + request.signal?.addEventListener('abort', pending.onAbort, { once: true }) + questionQueue.push(pending) + startNextQuestion() + }) + }, + }) + + const shutdown = (exitProcess: boolean): Promise => { + shuttingDown ??= (async () => { + disposed = true + clearStatus() + if (activeQuestion !== undefined) { + const pending = activeQuestion + activeQuestion = undefined + rejectQuestion(pending) + } + for (const pending of questionQueue.splice(0)) rejectQuestion(pending) + disposeUserInteraction() + await runtime.terminal.drainInput(100, 20) + ui.stop() + if (exitProcess) runtime.exit(0) + })() + return shuttingDown + } + + const requestExit = (): void => { + if (agent.status === 'running') { + agent.cancel('terminal exit requested') + appendNotice('Cancelling the active turn before exit…', 'warning') + void agent.whenIdle().then(() => shutdown(true)) + return + } + void shutdown(true) + } + + editor.setAutocompleteProvider(new CombinedAutocompleteProvider([ + { name: 'help', description: 'Show keyboard shortcuts and commands' }, + { name: 'clear', description: 'Clear the transcript view (session history is unchanged)' }, + { name: 'cancel', description: 'Cancel the active turn' }, + { name: 'reasoning', description: 'Toggle reasoning blocks' }, + { name: 'tools', description: 'Expand or collapse all tool cards' }, + { name: 'redraw', description: 'Invalidate components and redraw the terminal' }, + { name: 'exit', description: 'Exit after the active turn reaches idle' }, + ], agent.session.header.cwd ?? process.cwd())) + + const toggleTools = (): void => { + toolsExpanded = !toolsExpanded + for (const card of allToolCards) card.setExpanded(toolsExpanded) + appendNotice(`Tool cards ${toolsExpanded ? 'expanded' : 'collapsed'}.`) + } + + const toggleReasoning = (): void => { + showReasoning = !showReasoning + const activeStreaming = streaming + rebuildTranscript(false) + if (activeStreaming !== undefined) { + streaming = activeStreaming + streaming.setShowReasoning(showReasoning) + chat.addChild(activeStreaming) + } + appendNotice(`Reasoning blocks ${showReasoning ? 'shown' : 'hidden'}.`) + } + + const showHelp = (): void => { + chat.addChild(new Spacer(1)) + chat.addChild(new Text(palette.bold(palette.accent('Keyboard shortcuts')), 1, 0)) + chat.addChild(new Text([ + 'Enter send • Shift/Alt+Enter newline • Up/Down prompt history', + 'Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning', + 'Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit', + '/help /clear /cancel /reasoning /tools /redraw /exit', + ].map(line => palette.muted(line)).join('\n'), 1, 0)) + requestRender() + } + + editor.onSubmit = (value: string) => { + const text = value.trim() + if (text === '') return + editor.addToHistory(text) + editor.setText('') + switch (text) { + case '/help': + showHelp() + return + case '/clear': + chat.clear() + requestRender() + return + case '/cancel': + if (agent.status === 'running') agent.cancel('cancelled from terminal') + else appendNotice('The agent is already idle.') + return + case '/reasoning': + toggleReasoning() + return + case '/tools': + toggleTools() + return + case '/redraw': + ui.invalidate() + ui.requestRender(true) + return + case '/exit': + requestExit() + return + default: + if (text.startsWith('/')) { + appendNotice(`Unknown command: ${text}`, 'warning') + return + } + } + if (agent.status === 'disposed') { + appendNotice(`Agent "${agent.id}" is disposed.`, 'error') + } else if (agent.status === 'running') { + agent.steer([{ type: 'text', text }]) + } else { + agent.send([{ type: 'text', text }]) + } + } + + const removeInputListener = ui.addInputListener((data) => { + if (activeQuestion !== undefined) return undefined + if (matchesKey(data, Key.ctrl('o'))) { + toggleTools() + return { consume: true } + } + if (matchesKey(data, Key.ctrl('r'))) { + toggleReasoning() + return { consume: true } + } + if (matchesKey(data, Key.ctrl('l'))) { + ui.invalidate() + ui.requestRender(true) + return { consume: true } + } + if (matchesKey(data, Key.escape) && agent.status === 'running') { + agent.cancel('cancelled from terminal') + return { consume: true } + } + if (matchesKey(data, Key.ctrl('c'))) { + if (agent.status === 'running') { + agent.cancel('cancelled from terminal') + } else if (editor.getText() !== '') { + editor.setText('') + } else { + requestExit() + } + return { consume: true } + } + if (matchesKey(data, Key.ctrl('d'))) { + if (agent.status === 'running') appendNotice('Cancel the active turn before exiting.', 'warning') + else requestExit() + return { consume: true } + } + return undefined + }) + + const disposeSessionEvents = ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'assistant/message' && event.data.usage !== undefined) { + tokens.input += event.data.usage.inputTokens + tokens.output += event.data.usage.outputTokens + } + if ('surfaceOp' in event && typeof event.surfaceOp === 'object') { + rebuildTranscript(false) + return + } + renderEvent(event, { addHistory: false, renderChunks: true }) + requestRender() + }) + const disposeStatus = ctx.on('agent/status', (subject, status) => { + if (subject !== agent) return + setStatus(status) + }) + const disposeError = ctx.on('agent/error', (subject, turn, step, error) => { + if (subject !== agent) return + liveErrors.add(`${turn}:${step}`) + appendNotice(error.message, 'error') + }) + const disposeAgent = ctx.on('agent/disposed', (subject) => { + if (subject !== agent) return + clearStatus() + appendNotice(`Agent "${agent.id}" was disposed.`, 'warning') + }) + + const detachListeners = (): void => { + removeInputListener() + disposeSessionEvents() + disposeStatus() + disposeError() + disposeAgent() + } + + rebuildTranscript(true) + setStatus(agent.status) + try { + ui.start() + } catch (error: unknown) { + disposed = true + detachListeners() + clearStatus() + disposeUserInteraction() + ui.stop() + throw error + } + + return { + async dispose(): Promise { + detachListeners() + await shutdown(false) + }, + } +} + +/** + * Open the pi-tui channel once its configured agent exists. + * + * @param ctx - Context supplying the agent registry, tools, and event stream. + * @param config - Target agent and presentation configuration. + * @param runtime - Terminal and process-exit boundary. + */ +export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void { + const agentId = AgentId(config.agent ?? 'main') + const start = (): void => { + ctx.effect(() => { + const controller = createTuiChat(ctx, config, runtime) + return () => controller.dispose() + }, 'ui-tui') + } + if (ctx.agents.get(agentId) !== undefined) { + start() + return + } + const dispose = ctx.on('agent/created', (agent) => { + if (agent.id !== agentId) return + dispose() + start() + }) +} + +/** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */ +/* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat, + and the coding-agent PTY smoke covers the real entry */ +export function apply(ctx: Context, config: Config): void { + if (!process.stdin.isTTY || !process.stdout.isTTY) { + throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-stdio for pipes') + } + mountTui(ctx, config, { + terminal: new ProcessTerminal(), + exit: code => process.exit(code), + }) +} +/* v8 ignore stop */ diff --git a/packages/ui/tui/tests/plugin-shape.spec.ts b/packages/ui/tui/tests/plugin-shape.spec.ts new file mode 100644 index 0000000000..d1e4b92f3d --- /dev/null +++ b/packages/ui/tui/tests/plugin-shape.spec.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import Loader from '@cordisjs/plugin-loader' +import * as tui from '../src/index.ts' + +/** Real Loader export-path guard for the namespace TUI plugin. */ +describe('dsh-tui plugin export shape', () => { + it('preserves name, inject, Config, and apply through Loader unwrapping', () => { + expect('default' in tui).toBe(false) + expect(typeof tui.apply).toBe('function') + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(tui) as Record + expect(unwrapped).toBe(tui) + expect(unwrapped.name).toBe('ui-tui') + expect(unwrapped.inject).toEqual(['agents', 'userInteraction', 'tools']) + expect(unwrapped.Config).toBeDefined() + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts new file mode 100644 index 0000000000..e4ffe2efd0 --- /dev/null +++ b/packages/ui/tui/tests/tui.spec.ts @@ -0,0 +1,952 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import type { Terminal } from '@earendil-works/pi-tui' +import AgentRegistry, { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { + createTuiChat, + mountTui, + resolveTuiConfig, + type Config, + type TuiRuntime, +} from '../src/index.ts' + +class FakeTerminal implements Terminal { + columns = 88 + rows = 32 + kittyProtocolActive = false + output = '' + title = '' + progress: boolean[] = [] + started = 0 + stopped = 0 + drainInput = vi.fn(() => Promise.resolve()) + private onInput: (data: string) => void = () => {} + private onResize: () => void = () => {} + + start(onInput: (data: string) => void, onResize: () => void): void { + this.started += 1 + this.onInput = onInput + this.onResize = onResize + } + + stop(): void { + this.stopped += 1 + } + + write(data: string): void { + this.output += data + } + + moveBy(lines: number): void { + this.output += `[move:${lines}]` + } + + hideCursor(): void { + this.output += '[hide]' + } + + showCursor(): void { + this.output += '[show]' + } + + clearLine(): void { + this.output += '[clear-line]' + } + + clearFromCursor(): void { + this.output += '[clear-rest]' + } + + clearScreen(): void { + this.output += '[clear-screen]' + } + + setTitle(title: string): void { + this.title = title + } + + setProgress(active: boolean): void { + this.progress.push(active) + } + + send(data: string): void { + this.onInput(data) + } + + resize(columns: number, rows = this.rows): void { + this.columns = columns + this.rows = rows + this.onResize() + } +} + +interface FakeAgent extends Agent { + status: AgentStatus + sent: ContentBlock[][] + steered: ContentBlock[][] + cancelled: string[] +} + +async function tick(): Promise { + await new Promise(resolve => setTimeout(resolve, 25)) +} + +async function setup(options: { + status?: AgentStatus + config?: Config + tools?: Record + beforeMount?: (session: Session) => void + cwd?: string | null +} = {}) { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const tools = options.tools ?? {} + ctx.provide('tools', { + get(name: string) { + return tools[name] + }, + } as never) + const session = ctx.sessions.create( + SessionId('main-session'), + options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? process.cwd() } }, + ) + options.beforeMount?.(session) + const sent: ContentBlock[][] = [] + const steered: ContentBlock[][] = [] + const cancelled: string[] = [] + const agent: FakeAgent = { + id: AgentId('main'), + options: { model: 'deepseek-v4-flash' }, + session, + status: options.status ?? 'idle', + ctx, + sent, + steered, + cancelled, + send(content) { + sent.push(content) + }, + steer(content) { + steered.push(content) + }, + inject() {}, + cancel(reason) { + cancelled.push(reason ?? '') + }, + whenIdle() { + return Promise.resolve() + }, + } + ctx.agents.register(agent) + const terminal = new FakeTerminal() + const exit = vi.fn() + const controller = createTuiChat(ctx, Object.assign({ + welcome: 'Coding agent ready.', + agent: 'main', + color: false, + }, options.config), { terminal, exit }) + await tick() + return { ctx, session, agent, terminal, exit, controller } +} + +async function dispose(setupResult: Awaited>): Promise { + await setupResult.controller.dispose() + await setupResult.ctx.fiber.dispose() +} + +function appendUser(session: Session, text: string): void { + session.append('user/message', { + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +function appendAssistant(session: Session, content: ContentBlock[], usage?: { inputTokens: number; outputTokens: number }): void { + session.append('assistant/message', { + turn: 1, + step: 0, + content, + ...usage === undefined ? {} : { usage }, + }, { surfaceOp: 'append' }) +} + +describe('TUI config', () => { + it('defaults every direct-call TUI option', () => { + expect(resolveTuiConfig(undefined)).toEqual({ + showReasoning: true, + maxToolOutputLines: 12, + maxQuestionOptions: 8, + questionDialogWidth: 72, + questionDialogMaxHeight: 20, + showHardwareCursor: false, + color: true, + title: 'DeepSeek Harness', + }) + expect(resolveTuiConfig({ + showReasoning: false, + maxToolOutputLines: 2, + maxQuestionOptions: 3, + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + showHardwareCursor: true, + color: false, + title: 'DSH', + })).toEqual({ + showReasoning: false, + maxToolOutputLines: 2, + maxQuestionOptions: 3, + questionDialogWidth: 60, + questionDialogMaxHeight: 14, + showHardwareCursor: true, + color: false, + title: 'DSH', + }) + }) +}) + +describe('pi-tui chat lifecycle and transcript', () => { + it('renders its header, footer, replay, streaming answer, todos, and status', async () => { + const result = await setup({ + beforeMount(session) { + appendUser(session, 'restored prompt') + appendAssistant(session, [ + { type: 'reasoning', text: 'restored thought' }, + { type: 'text', text: '**restored answer**' }, + ], { inputTokens: 1_250, outputTokens: 42 }) + session.append('todo/write', { + todos: [ + { content: 'read code', status: 'completed' }, + { content: 'write tests', status: 'in_progress' }, + { content: 'ship', status: 'pending' }, + ], + }) + }, + }) + + expect(result.terminal.started).toBe(1) + expect(result.terminal.title).toBe('DeepSeek Harness') + expect(result.terminal.output).toContain('DEEPSEEK') + expect(result.terminal.output).toContain('Coding agent ready.') + expect(result.terminal.output).toContain('restored prompt') + expect(result.terminal.output).toContain('restored thought') + expect(result.terminal.output).toContain('restored answer') + expect(result.terminal.output).toContain('write tests') + expect(result.terminal.output).toContain('↑1.3k ↓42') + + result.agent.status = 'running' + result.ctx.emit('agent/status', result.agent, 'running') + result.session.append('user/message', { content: [{ type: 'text', text: ' ' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: 'steering note' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('steering/message', { turn: 2, content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('context/message', { content: [{ type: 'text', text: 'user context' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('context/message', { content: [{ type: 'text', text: '' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + result.session.append('prompt/blocked', { content: [{ type: 'text', text: 'blocked' }], source: { kind: 'user' }, reason: 'test policy' }) + appendAssistant(result.session, []) + result.session.append('turn/end', { turn: 9, reason: { kind: 'aborted' } }) + result.session.append('turn/end', { turn: 10, reason: { kind: 'completed' } }) + result.session.append('step/start', { turn: 11, step: 0 }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'reasoning-delta', index: 0, text: 'live thought' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'reasoning-delta', index: 9, text: 'unannounced thought' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'live thought complete' } }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'text-delta', index: 1, text: 'live answer' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-end', index: 1, block: { type: 'text', text: 'live answer done' } }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-start', index: 2, blockType: 'tool-call' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-end', index: 2, block: { type: 'tool-call', id: 'stream-tool' as never, name: 'tool', arguments: '{}' } }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'tool-call-delta', index: 2, id: 'stream-tool' as never, argumentsDelta: '{}' }, + }) + result.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2 } }, + }) + await tick() + expect(result.terminal.output).toContain('live thought') + result.terminal.send('\x12') + await tick() + appendAssistant(result.session, [{ type: 'text', text: 'final live answer' }], { inputTokens: 500, outputTokens: 8 }) + await tick() + + expect(result.terminal.output).toContain('Working') + expect(result.terminal.output).toContain('Steering') + expect(result.terminal.output).toContain('user context') + expect(result.terminal.output).toContain('Prompt blocked') + expect(result.terminal.output).toContain('Turn cancelled') + expect(result.terminal.output).toContain('final live answer') + expect(result.terminal.output).toContain('↑1.8k ↓50') + expect(result.terminal.progress).toContain(true) + + result.session.append('assistant/chunk', { + turn: 3, + step: 0, + chunk: { type: 'text-delta', index: 0, text: 'cleared stream' }, + }) + result.terminal.send('/clear') + result.terminal.send('\r') + appendAssistant(result.session, [{ type: 'text', text: 'answer after clear' }]) + await tick() + expect(result.terminal.output).toContain('answer after clear') + + result.agent.status = 'idle' + result.ctx.emit('agent/status', result.agent, 'idle') + await tick() + expect(result.terminal.progress.at(-1)).toBe(false) + await dispose(result) + expect(result.terminal.stopped).toBe(1) + expect(result.terminal.drainInput).toHaveBeenCalledWith(100, 20) + }) + + it('renders the ANSI palette and every markdown/content style', async () => { + const result = await setup({ + config: { color: true }, + beforeMount(session) { + session.append('user/message', { + content: [ + { type: 'text', text: '# Heading\n\n[link](https://example.com) `code`\n\n```ts\nconst x = 1\n```\n\n> quote\n\n---\n\n- item\n\n**bold** *italic* ~~strike~~' }, + { type: 'tool-call', id: 'nested' as never, name: 'nested_tool', arguments: '{}' }, + { type: 'tool-result', toolCallId: 'nested' as never, content: [{ type: 'reasoning', text: 'nested result' }] }, + { type: 'future-block' } as never, + {} as never, + ], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + appendAssistant(session, [ + { type: 'reasoning', text: 'styled reasoning' }, + { type: 'text', text: 'styled answer' }, + ], { inputTokens: 2_000_000, outputTokens: 1_500_000 }) + session.append('todo/write', { todos: [ + { content: 'done', status: 'completed' }, + { content: 'active', status: 'in_progress' }, + { content: 'later', status: 'pending' }, + ] }) + }, + }) + result.terminal.send('/') + await tick() + result.terminal.send('zz') + await tick() + result.terminal.send('\x0c') + await tick() + + expect(result.terminal.output).toContain('\x1b[') + expect(result.terminal.output).toContain('Heading') + expect(result.terminal.output).toContain('nested_tool({})') + expect(result.terminal.output).toContain('nested result') + expect(result.terminal.output).toContain('[future-block]') + expect(result.terminal.output).toContain('[content]') + expect(result.terminal.output).toContain('↑2.0m ↓1.5m') + await dispose(result) + }) + + it('suppresses stale replay chunks and does not duplicate editor history on rebuild', async () => { + const result = await setup({ + beforeMount(session) { + appendUser(session, 'first prompt') + appendUser(session, 'second prompt') + session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'text-delta', index: 0, text: 'stale partial response' }, + }) + }, + }) + + expect(result.terminal.output).not.toContain('stale partial response') + result.terminal.send('/reasoning') + result.terminal.send('\r') + result.terminal.send('\x1b[A') + result.terminal.send('\x1b[A') + result.terminal.send('\x1b[A') + result.terminal.send('\r') + expect(result.agent.sent).toEqual([[{ type: 'text', text: 'first prompt' }]]) + await dispose(result) + }) + + it('formats large token totals and cwd variants', async () => { + const homeResult = await setup({ + cwd: process.env.HOME ?? process.cwd(), + beforeMount(session) { + appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 }) + }, + }) + expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k') + await dispose(homeResult) + + const unsetResult = await setup({ cwd: null }) + expect(unsetResult.terminal.output).toContain('cwd unset') + await dispose(unsetResult) + + const outsideResult = await setup({ cwd: '/opt' }) + expect(outsideResult.terminal.output).toContain('/opt') + await dispose(outsideResult) + }) + + it('sends, steers, handles commands, global keys, and disposed-agent input', async () => { + const result = await setup() + + result.terminal.send('do the work') + result.terminal.send('\r') + expect(result.agent.sent).toEqual([[{ type: 'text', text: 'do the work' }]]) + + result.terminal.send(' ') + result.terminal.send('\r') + + result.agent.status = 'running' + result.terminal.send('steer it') + result.terminal.send('\r') + expect(result.agent.steered).toEqual([[{ type: 'text', text: 'steer it' }]]) + + result.terminal.send('\x1b') + result.terminal.send('\x04') + result.terminal.send('\x03') + result.terminal.send('\x12') + result.terminal.send('\x0f') + result.terminal.send('/cancel') + result.terminal.send('\r') + expect(result.agent.cancelled).toContain('cancelled from terminal') + + result.agent.status = 'idle' + for (const command of ['/help', '/reasoning', '/tools', '/redraw']) { + result.terminal.send(command) + result.terminal.send('\r') + await tick() + } + for (const command of ['/clear', '/cancel', '/wat']) { + result.terminal.send(command) + result.terminal.send('\r') + } + await tick() + result.terminal.send('draft') + result.terminal.send('\x03') + result.terminal.send('\x04') + await tick() + + expect(result.terminal.output).toContain('Keyboard shortcuts') + expect(result.terminal.output).toContain('Reasoning blocks') + expect(result.terminal.output).toContain('Tool cards') + expect(result.terminal.output).toContain('already idle') + expect(result.terminal.output).toContain('Unknown command') + expect(result.exit).toHaveBeenCalledWith(0) + await result.controller.dispose() + await result.ctx.fiber.dispose() + + const ctrlCExit = await setup() + ctrlCExit.terminal.send('\x03') + await tick() + expect(ctrlCExit.exit).toHaveBeenCalledWith(0) + await ctrlCExit.controller.dispose() + await ctrlCExit.ctx.fiber.dispose() + + const disposedAgent = await setup() + disposedAgent.agent.status = 'disposed' + disposedAgent.terminal.send('late input') + disposedAgent.terminal.send('\r') + await tick() + expect(disposedAgent.terminal.output).toContain('is disposed') + await dispose(disposedAgent) + }) + + it('cancels before /exit while running and handles agent errors/disposal', async () => { + const result = await setup({ status: 'running' }) + result.terminal.send('/exit') + result.terminal.send('\r') + await tick() + expect(result.agent.cancelled).toContain('terminal exit requested') + expect(result.exit).toHaveBeenCalledWith(0) + + const events = await setup() + const unrelatedSession = events.ctx.sessions.create(SessionId('unrelated-session')) + const unrelatedAgent = { ...events.agent, id: AgentId('unrelated'), session: unrelatedSession } + unrelatedSession.append('todo/write', { todos: [{ content: 'hidden', status: 'pending' }] }) + events.ctx.emit('agent/status', unrelatedAgent, 'running') + events.ctx.emit('agent/error', unrelatedAgent, 1, 1, new Error('hidden error')) + events.ctx.emit('agent/disposed', unrelatedAgent) + events.ctx.emit('agent/error', events.agent, 3, 2, new Error('live failure')) + events.session.append('turn/end', { turn: 3, reason: { kind: 'error', step: 2, message: 'live failure' } }) + events.session.append('turn/end', { turn: 4, reason: { kind: 'error', step: 1, message: 'durable failure' } }) + events.session.append('turn/end', { turn: 5, reason: { kind: 'aborted', reason: 'stopped' } }) + events.session.append('turn/end', { turn: 6, reason: { kind: 'max-tokens' } }) + events.session.append('turn/end', { turn: 7, reason: { kind: 'rejected', reason: 'policy' } }) + events.session.append('turn/end', { turn: 8, reason: { kind: 'interrupted' } }) + events.ctx.emit('agent/disposed', events.agent) + await tick() + expect(events.terminal.output).toContain('live failure') + expect(events.terminal.output).toContain('durable failure') + expect(events.terminal.output).toContain('stopped') + expect(events.terminal.output).toContain('output-token limit') + expect(events.terminal.output).toContain('Turn rejected') + expect(events.terminal.output).toContain('previous process ended') + expect(events.terminal.output).toContain('was disposed') + await dispose(events) + }) +}) + +describe('tool cards and surface replay', () => { + const tools: Record = { + bash: { + name: 'bash', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'terminal', title: 'printf hello', description: 'Run command', cwd: '/tmp' }), + presentResult: () => ({ card: 'terminal', output: 'hello\nworld\nthird', exitCode: 0 }), + }, + signal: { + name: 'signal', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'terminal', title: 'sleep 10' }), + presentResult: () => ({ card: 'terminal', signal: 'SIGTERM' }), + }, + edit: { + name: 'edit', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ + card: 'diff', + title: 'Edit files', + diffs: [ + { path: 'a.txt', oldText: 'old', newText: 'new' }, + { path: 'b.txt', oldText: 'before', newText: 'after' }, + ], + }), + presentResult: () => ({ card: 'diff', diffs: [{ path: 'a.txt', oldText: null, newText: 'created' }] }), + }, + generic: { + name: 'generic', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Inspect value', rawInput: { alpha: 1 } }), + presentResult: () => ({ card: 'generic', title: 'Inspected', content: [{ type: 'text', text: 'result text' }] }), + }, + throwing: { + name: 'throwing', description: '', parameters: {}, execute: async () => [], + presentCall: () => { throw new Error('call presenter boom') }, + presentResult: () => { throw new Error('result presenter boom') }, + }, + rawTerminal: { + name: 'rawTerminal', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'terminal', title: 'raw command' }), + }, + undefinedViews: { + name: 'undefinedViews', description: '', parameters: {}, execute: async () => [], + presentCall: () => undefined, + presentResult: () => undefined, + }, + empty: { + name: 'empty', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Empty card' }), + }, + terminalResult: { + name: 'terminalResult', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Becomes terminal' }), + presentResult: () => ({ card: 'terminal', output: 'converted terminal' }), + }, + symbolic: { + name: 'symbolic', description: '', parameters: {}, execute: async () => [], + presentCall: () => ({ card: 'generic', title: 'Symbol input', rawInput: Symbol('input') }), + }, + } + + it('uses terminal, diff, generic, fallback, and collapsed tool presentations', async () => { + const result = await setup({ tools, config: { maxToolOutputLines: 1 } }) + const calls = [ + ['c1', 'bash', '{"command":"printf hello"}'], + ['c2', 'signal', '{}'], + ['c3', 'edit', '{}'], + ['c4', 'generic', '{}'], + ['c5', 'throwing', '{}'], + ['c6', 'unknown', 'not-json'], + ['c7', 'rawTerminal', '{"value":"raw"}'], + ['c8', 'undefinedViews', '{"value":8}'], + ['c10', 'empty', '{}'], + ['c11', 'terminalResult', '{}'], + ['c12', 'symbolic', '{}'], + ] as const + appendAssistant(result.session, [ + { type: 'text', text: 'Calling tools' }, + ...calls.map(([id, name, args]) => ({ + type: 'tool-call' as const, id: id as never, name, arguments: args, + })), + ]) + for (const [id, name, args] of calls) { + result.session.append('tool/call', { turn: 1, step: 0, callId: id as never, name, arguments: args }) + } + await tick() + expect(result.terminal.output).toContain('$ raw command') + result.terminal.send('/reasoning') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('call presenter boom') + expect(result.terminal.output).toContain('Symbol(input)') + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c1' as never, content: [{ type: 'text', text: 'raw bash' }], isError: false, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c2' as never, content: [{ type: 'text', text: 'stopped' }], isError: true, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c3' as never, content: [{ type: 'text', text: 'done' }], isError: false, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c4' as never, content: [{ type: 'text', text: 'raw generic' }], isError: false, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c5' as never, content: [{ type: 'text', text: 'raw throwing' }], isError: false, + meta: { value: 1 }, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c7' as never, + content: [ + { type: 'tool-call', id: 'inner' as never, name: 'inner', arguments: '{}' }, + { type: 'tool-result', toolCallId: 'inner' as never, content: [{ type: 'text', text: 'nested output' }] }, + { type: 'future-result' } as never, + ], + isError: false, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c8' as never, content: [{ type: 'text', text: '\nundefined presenter output\n\nkept tail\n' }], isError: false, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'c11' as never, content: [{ type: 'text', text: '\nconverted terminal\n\nfinished\n' }], isError: false, + }, { surfaceOp: 'append' }) + result.session.append('tool/result', { + turn: 1, step: 0, callId: 'orphan' as never, content: [{ type: 'text', text: 'orphan result' }], isError: false, + }, { surfaceOp: 'append' }) + await tick() + + const output = result.terminal.output + expect(output).toContain('Run command') + expect(output).toContain('printf hello') + expect(output).toContain('more lines') + expect(output).toContain('SIGTERM') + expect(output).toContain('Edit files') + expect(output).toContain('Inspected') + expect(output).toContain('result text') + expect(output).toContain('Presenter failed') + expect(output).toContain('not-json') + expect(output).toContain('nested output') + expect(output).toContain('[future-result]') + expect(output).toContain('undefined presenter output') + expect(output).toContain('Empty card') + expect(output).toContain('converted terminal') + expect(output).toContain('orphan result') + + result.terminal.send('/redraw') + result.terminal.send('\r') + await tick() + result.terminal.send('\x0f') + await tick() + expect(result.terminal.output).toContain('world') + expect(result.terminal.output).toContain('+ created') + await dispose(result) + }) + + it('rebuilds after a surface replacement and hides shadowed tool calls', async () => { + const result = await setup({ tools }) + appendUser(result.session, 'old prompt') + const assistant = result.session.append('assistant/message', { + turn: 1, + step: 0, + content: [{ type: 'tool-call', id: 'old-call' as never, name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + result.session.append('tool/call', { + turn: 1, step: 0, callId: 'old-call' as never, name: 'bash', arguments: '{}', + }) + const toolResult = result.session.append('tool/result', { + turn: 1, step: 0, callId: 'old-call' as never, content: [{ type: 'text', text: 'old output' }], isError: false, + }, { surfaceOp: 'append' }) + const start = result.session.surface.nodes[0]?.seq as number + result.session.append('context/message', { + content: [{ type: 'text', text: 'summary replacement' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start, end: toolResult.seq }, + sourceEventSeqs: [start, assistant.seq, toolResult.seq], + }) + await tick() + + result.terminal.resize(89) + await tick() + const lastFullRender = result.terminal.output.slice(result.terminal.output.lastIndexOf('\x1b[2J')) + expect(lastFullRender).toContain('summary replacement') + expect(lastFullRender).not.toContain('old output') + await dispose(result) + }) +}) + +describe('TUI user-interaction dialogs', () => { + it('answers single-select, multi-select, custom, and optionless questions', async () => { + const result = await setup({ config: { maxQuestionOptions: 1 } }) + + const single = result.ctx.userInteraction.ask({ + questions: [{ + id: 'mode', header: 'Mode', question: 'Choose a mode', + options: [{ label: 'Safe', description: 'Use checks' }, { label: 'Fast' }], + }], + }) + await tick() + expect(result.terminal.output).toContain('Choose a mode') + expect(result.terminal.output).toContain('1/2') + result.terminal.send('\x1b[B') + result.terminal.send('\r') + await expect(single).resolves.toEqual({ answers: [{ id: 'mode', selected: ['Fast'] }] }) + + const multi = result.ctx.userInteraction.ask({ + questions: [{ id: 'targets', question: 'Pick targets', multiSelect: true, options: [{ label: 'Code' }, { label: 'Docs' }] }], + }) + await tick() + result.terminal.send(' ') + result.terminal.send('\x1b[B') + result.terminal.send(' ') + result.terminal.send('\r') + await expect(multi).resolves.toEqual({ answers: [{ id: 'targets', selected: ['Code', 'Docs'] }] }) + + const custom = result.ctx.userInteraction.ask({ + questions: [{ id: 'other', question: 'Choose or type', options: [{ label: 'Default' }] }], + }) + await tick() + result.terminal.send('c') + result.terminal.send('my choice') + result.terminal.send('\r') + await expect(custom).resolves.toEqual({ answers: [{ id: 'other', selected: [], custom: 'my choice' }] }) + + const free = result.ctx.userInteraction.ask({ questions: [{ id: 'note', question: 'Add a note' }] }) + await tick() + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Enter an answer before submitting') + result.terminal.send('ship it') + result.terminal.send('\r') + await expect(free).resolves.toEqual({ answers: [{ id: 'note', selected: [], custom: 'ship it' }] }) + await dispose(result) + }) + + it('handles option wrapping, deselection errors, and returning from custom input', async () => { + const result = await setup({ config: { color: true } }) + const single = result.ctx.userInteraction.ask({ + questions: [{ id: 'single', question: 'Single options', options: [{ label: 'One' }, { label: 'Two' }] }], + }) + const singleRejected = expect(single).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + expect(result.terminal.output).toContain('Two') + result.terminal.send('\x03') + await singleRejected + + const answer = result.ctx.userInteraction.ask({ + questions: [{ + id: 'options', + question: 'Exercise options', + multiSelect: true, + options: [{ label: 'One', description: 'first' }, { label: 'Two' }], + }], + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b[A') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[B') + result.terminal.send('\x1b[A') + result.terminal.send(' ') + await tick() + result.terminal.send('x') + result.terminal.send(' ') + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Select at least one option') + result.terminal.send('c') + await tick() + result.terminal.send('\x1b') + await tick() + expect(result.terminal.output).toContain('Space toggle') + result.terminal.send('\x03') + await rejected + await dispose(result) + }) + + it('asks batches in order and rejects cancelled or aborted work', async () => { + const result = await setup() + const preAborted = new AbortController() + preAborted.abort() + await expect(result.ctx.userInteraction.ask({ + questions: [{ id: 'pre-aborted', question: 'Already cancelled?' }], + signal: preAborted.signal, + })).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + + const batch = result.ctx.userInteraction.ask({ + questions: [ + { id: 'first', question: 'First?', options: [{ label: 'Yes' }] }, + { id: 'second', question: 'Second?' }, + ], + }) + await tick() + result.terminal.send('\r') + await tick() + expect(result.terminal.output).toContain('Second?') + result.terminal.send('done') + result.terminal.send('\r') + await expect(batch).resolves.toEqual({ answers: [ + { id: 'first', selected: ['Yes'] }, + { id: 'second', selected: [], custom: 'done' }, + ] }) + + const cancelled = result.ctx.userInteraction.ask({ questions: [{ id: 'cancel', question: 'Cancel?' }] }) + const cancelledExpectation = expect(cancelled).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + result.terminal.send('\x1b') + await cancelledExpectation + + const controller = new AbortController() + const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }], signal: controller.signal }) + const queuedController = new AbortController() + const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }], signal: queuedController.signal }) + const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + queuedController.abort() + controller.abort() + await activeExpectation + await queuedExpectation + await dispose(result) + }) + + it('rejects active and queued dialogs on disposal', async () => { + const result = await setup() + const active = result.ctx.userInteraction.ask({ questions: [{ id: 'active', question: 'Active?' }] }) + const queued = result.ctx.userInteraction.ask({ questions: [{ id: 'queued', question: 'Queued?' }] }) + const activeExpectation = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + const queuedExpectation = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await tick() + await result.controller.dispose() + await activeExpectation + await queuedExpectation + await expect(result.ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + await result.ctx.fiber.dispose() + }) +}) + +describe('terminal mounting', () => { + it('starts immediately when the configured agent already exists', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const session = ctx.sessions.create(SessionId('mounted-session')) + ctx.agents.register({ + id: AgentId('main'), options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + const terminal = new FakeTerminal() + mountTui(ctx, { color: false }, { terminal, exit: vi.fn() }) + await tick() + expect(terminal.started).toBe(1) + await ctx.fiber.dispose() + }) + + it('waits for its configured agent before starting the TUI', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const terminal = new FakeTerminal() + mountTui(ctx, { agent: 'main', color: false }, { terminal, exit: vi.fn() }) + expect(terminal.started).toBe(0) + + const otherSession = ctx.sessions.create(SessionId('other-session')) + ctx.agents.register({ + id: AgentId('other'), options: {}, session: otherSession, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + expect(terminal.started).toBe(0) + + const session = ctx.sessions.create(SessionId('late-session')) + const agent = { + id: AgentId('main'), options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + } as Agent + ctx.agents.register(agent) + await tick() + expect(terminal.started).toBe(1) + await ctx.fiber.dispose() + }) + + it('rolls back providers, listeners, and terminal state when startup fails', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const session = ctx.sessions.create(SessionId('failed-start-session')) + ctx.agents.register({ + id: AgentId('main'), options: {}, session, status: 'running', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + const terminal = new FakeTerminal() + terminal.start = () => { throw new Error('terminal startup failed') } + + expect(() => createTuiChat(ctx, { color: false }, { terminal, exit: vi.fn() })) + .toThrow('terminal startup failed') + expect(terminal.stopped).toBe(1) + expect(terminal.progress).toEqual([false, true, false]) + await expect(ctx.userInteraction.ask({ questions: [{ id: 'late', question: 'Late?' }] })) + .rejects.toMatchObject({ code: 'NO_PROVIDER' }) + session.append('assistant/chunk', { + turn: 1, + step: 0, + chunk: { type: 'text-delta', index: 0, text: 'must not render' }, + }) + await tick() + expect(terminal.output).not.toContain('must not render') + await ctx.fiber.dispose() + }) + + it('throws when createTuiChat is called without the configured agent', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const runtime: TuiRuntime = { terminal: new FakeTerminal(), exit: vi.fn() } + expect(() => createTuiChat(ctx, { agent: 'missing' }, runtime)).toThrow('is not running') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/ui/tui/tsconfig.json b/packages/ui/tui/tsconfig.json new file mode 100644 index 0000000000..f3a85f76e4 --- /dev/null +++ b/packages/ui/tui/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + }, + { + "path": "../user-interaction" + } + ] +} diff --git a/packages/ui/user-interaction/README.md b/packages/ui/user-interaction/README.md index bd5c5281ca..b758ad09e0 100644 --- a/packages/ui/user-interaction/README.md +++ b/packages/ui/user-interaction/README.md @@ -21,7 +21,7 @@ When an answer includes `custom`, `selected` is empty; custom text is an overrid ## Role -This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the `stdio-agent` readline module and the `acp` bridge provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI front doors such as the interactive `dsh-tui`, line-oriented `dsh-stdio`, and structured `dsh-acp` channels provide the provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. ## Model Experience diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1a3a78030..e4a9019a7f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -583,6 +583,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-tui': + specifier: workspace:^ + version: link:../../ui/tui '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../ui/user-interaction @@ -1673,6 +1676,37 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/ui/tui: + dependencies: + '@earendil-works/pi-tui': + specifier: 0.80.7 + version: 0.80.7 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:^ + version: link:../user-interaction + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/user-approval: dependencies: schemastery: @@ -2519,6 +2553,10 @@ packages: engines: {node: '>=22.19.0'} hasBin: true + '@earendil-works/pi-tui@0.80.7': + resolution: {integrity: sha512-1B2++fLZfgI3XMzW2BTpuDuam2uyHnUUEmsOvi5R0Ne9RAt59WjFV0G8ozX6l1Xafa9P5Y3eT4aDtRr/v/CUTA==} + engines: {node: '>=22.19.0'} + '@emnapi/core@1.10.0': resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} @@ -4293,6 +4331,10 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -4773,6 +4815,11 @@ packages: engines: {node: '>= 20'} hasBin: true + marked@18.0.5: + resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -6157,6 +6204,11 @@ snapshots: - ws - zod + '@earendil-works/pi-tui@0.80.7': + dependencies: + get-east-asian-width: 1.6.0 + marked: 18.0.5 + '@emnapi/core@1.10.0': dependencies: '@emnapi/wasi-threads': 1.2.1 @@ -7861,6 +7913,8 @@ snapshots: transitivePeerDependencies: - supports-color + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8308,6 +8362,8 @@ snapshots: marked@16.4.2: {} + marked@18.0.5: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 31df485e1d..cbe9272ac1 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -407,7 +407,7 @@ const APP_EXAMPLES = [ title: 'Coding Agent App Composition', label: 'examples/coding-agent', config: 'examples/coding-agent/cordis.yml', - summary: 'The coding REPL demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', + summary: 'The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', }, { id: 'cordis', @@ -435,7 +435,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) if (pluginName === '@deepseek-ai/dsh-stdio-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["readline UI
console logger
pre-created main agent"]`) + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"]`) } diff --git a/tsconfig.build.json b/tsconfig.build.json index 3a57169005..7655069a29 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -61,6 +61,7 @@ { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, + { "path": "./packages/ui/tui" }, { "path": "./packages/ui/stdio" }, { "path": "./packages/examples/stdio-demo" }, { "path": "./packages/support/llm-replay" }, diff --git a/tsconfig.json b/tsconfig.json index 6585a987d6..1f18ea3a55 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -72,6 +72,7 @@ { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, + { "path": "./packages/ui/tui" }, { "path": "./packages/ui/stdio" }, { "path": "./packages/examples/stdio-demo" }, { "path": "./packages/support/llm-replay" }, From 12b7ac67bacd1b05e76b124163d195d9bbbde72a Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 17 Jul 2026 16:44:25 +0800 Subject: [PATCH 129/163] fix(ui): surface declarative startup failures --- docs/cordis-catalog/events.md | 32 ++++-- docs/cordis-catalog/services.md | 4 +- docs/event-producer-consumer.md | 25 +++-- examples/coding-agent/README.md | 2 +- .../tests/tui-keyless-smoke.e2e.ts | 33 ++++-- .../cordis/tool-cordis/src/api-catalog.ts | 8 ++ packages/core/agent-loop/README.md | 2 +- packages/core/agent-loop/src/index.ts | 17 ++- .../tests/config-session-id.spec.ts | 80 ++++++++++++- packages/core/agent/README.md | 7 +- packages/core/agent/src/index.ts | 95 ++++++++++++++++ packages/core/agent/src/types.ts | 9 ++ packages/core/agent/tests/agent.spec.ts | 106 +++++++++++++++++- packages/examples/stdio-demo/README.md | 4 +- packages/ui/stdio/README.md | 2 +- packages/ui/stdio/src/index.ts | 16 ++- packages/ui/stdio/tests/stdio.spec.ts | 38 ++++++- packages/ui/tui/README.md | 2 +- packages/ui/tui/src/index.ts | 29 +++-- packages/ui/tui/tests/tui.spec.ts | 45 ++++++++ 20 files changed, 484 insertions(+), 72 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index ede449cb1c..ed0a5554e5 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -47,7 +47,7 @@ A step or turn errored. The loop reports a failure here (plus the logger) even w Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:283`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:292`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — serial @@ -59,7 +59,7 @@ Awaited serial checkpoint for session-surface mutation after prompt assembly and Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:211`](../../packages/core/agent/src/types.ts) ### `agent/prompt-submit` — waterfall @@ -71,7 +71,7 @@ Allow, rewrite, or block one drained prompt before it becomes a user message. Ca Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:221`](../../packages/core/agent/src/types.ts) ### `agent/queued` — emit @@ -83,7 +83,7 @@ Detached, frozen content entered the agent's inbox. Source defaults have already Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:176`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -95,7 +95,7 @@ Replace the frozen call configuration. Model-visible content must use logged cha Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:224`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:233`](../../packages/core/agent/src/types.ts) ### `agent/session-prefix` — waterfall @@ -107,7 +107,7 @@ Compose request-only messages placed before derived history. The frozen result i Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:248`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -119,7 +119,17 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) + +### `agent/start-failed` — emit + +Declarative startup failed before an agent could be published. Programmatic `ctx.agents.create()` / `resume()` calls report failure through rejection; this event covers the fire-and-forget config path. + +```ts cordis-catalog +'agent/start-failed'(agentId: AgentId, error: Error): void +``` + +Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -131,7 +141,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does no Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:157`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:166`](../../packages/core/agent/src/types.ts) ### `agent/step-result` — waterfall @@ -143,7 +153,7 @@ Waterfall: post-process the assembled assistant Message before tool dispatch (va Types: [Agent](../core-data-structures/core.md) · [Message](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/turn-continuation` — waterfall @@ -155,7 +165,7 @@ Override whether the turn continues. The default continues after tool calls or s Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:260`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:269`](../../packages/core/agent/src/types.ts) ### `agent/turn-stop` — serial @@ -167,7 +177,7 @@ Monotonic terminal-stop checkpoint after continuation and steering are folded; a Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:270`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:279`](../../packages/core/agent/src/types.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 18e00b7904..aa2289ff0d 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -32,13 +32,15 @@ async resume(options: ResumeAgentOptions): Promise register(agent: Agent): () => void enter(agent: Agent): () => void announce(agent: Agent): void +reportStartFailure(id: AgentId, error: Error): () => void +getStartFailure(id: AgentId): Error | undefined get(id: AgentId): Agent | undefined list(): Agent[] ``` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:133`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:187`](../../packages/core/agent/src/index.ts) ## `ctx.approval` — `ApprovalService` diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index b67788ea6b..de932e0999 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,19 +7,20 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:139`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent`](../packages/core/agent), [`jsonrpc`](../packages/ui/jsonrpc), [`stdio`](../packages/ui/stdio) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:148`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:283`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | -| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:202`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | -| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | -| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:224`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:180`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | -| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:250`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | -| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:260`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:270`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:292`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`tui`](../packages/ui/tui) | +| `agent/pre-step` | `serial` | [`packages/core/agent/src/types.ts:211`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`user-approval`](../packages/ui/user-approval) | +| `agent/prompt-submit` | `waterfall` | [`packages/core/agent/src/types.ts:221`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`acp`](../packages/ui/acp), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | +| `agent/queued` | `emit` | [`packages/core/agent/src/types.ts:176`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | - | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:233`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/session-prefix` | `waterfall` | [`packages/core/agent/src/types.ts:248`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`tool-skill`](../packages/skill/tool-skill) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:189`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/start-failed` | `emit` | [`packages/core/agent/src/types.ts:157`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent`](../packages/core/agent) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:166`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`invariants`](../packages/support/invariants), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`stdio`](../packages/ui/stdio), [`tui`](../packages/ui/tui) | +| `agent/step-result` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | - | +| `agent/turn-continuation` | `waterfall` | [`packages/core/agent/src/types.ts:269`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/turn-stop` | `serial` | [`packages/core/agent/src/types.ts:279`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:31`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/ui/acp) | | `fs/edit-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:68`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index c1c4747a22..9fdadf809d 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -23,7 +23,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio RESUME_SESSION_ID= pnpm run demo:repl ``` -The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent, so unset it or choose an existing session id. +The id is wired through `cordis.yml` (`resumeSessionId: !!js process.env.RESUME_SESSION_ID`); unset, the agent starts a new session. A missing or unreadable id starts no `main` agent and emits `agent/start-failed`; both terminal front doors print the failure and exit nonzero, so unset it or choose an existing session id. ## Code Mode diff --git a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts index a1057ba02f..f5f9c5db36 100644 --- a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts @@ -13,7 +13,7 @@ const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PTY_DRIVER = String.raw` import errno, os, pty, select, signal, sys, time -node, tsx_loader, bin_script, config_path, tsconfig_path, cwd = sys.argv[1:] +node, tsx_loader, bin_script, config_path, tsconfig_path, cwd, resume_session_id = sys.argv[1:] env = os.environ.copy() env.update({ "DEEPSEEK_API_KEY": "keyless-tui-no-call", @@ -21,6 +21,8 @@ env.update({ "DSH_AGENTS_HOME": os.path.join(cwd, ".agents"), "TSX_TSCONFIG_PATH": tsconfig_path, }) +if resume_session_id: + env["RESUME_SESSION_ID"] = resume_session_id pid, fd = pty.fork() if pid == 0: os.chdir(cwd) @@ -53,15 +55,23 @@ if status is None: os.kill(pid, signal.SIGKILL) _, status = os.waitpid(pid, 0) sys.stdout.buffer.write(output) -if not sent_exit: - sys.stderr.write("TUI did not render its welcome marker before timeout\n") - sys.exit(124) -if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: - sys.stderr.write("TUI child did not exit cleanly\n") - sys.exit(125) +if resume_session_id: + if b'ui-tui: agent "main" failed to start:' not in output: + sys.stderr.write("TUI did not render the startup failure before timeout\n") + sys.exit(126) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: + sys.stderr.write("TUI startup failure did not exit with status 1\n") + sys.exit(127) +else: + if not sent_exit: + sys.stderr.write("TUI did not render its welcome marker before timeout\n") + sys.exit(124) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI child did not exit cleanly\n") + sys.exit(125) ` -async function runTuiLoaderSmoke(): Promise { +async function runTuiLoaderSmoke(resumeSessionId = ''): Promise { const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-')) try { return await new Promise((resolve, reject) => { @@ -74,6 +84,7 @@ async function runTuiLoaderSmoke(): Promise { configPath, tsconfigPath, cwd, + resumeSessionId, ], { stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' @@ -98,4 +109,10 @@ describe('coding-agent TUI keyless smoke (real Loader tree in a PTY)', () => { expect(output).toContain('DEEPSEEK') expect(output).toContain('agent REPL ready.') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { + const output = await runTuiLoaderSmoke('missing-session') + expect(output).toContain('ui-tui: agent "main" failed to start:') + expect(output).toContain('missing-session') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) }) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 51b4a5155f..1eef429d96 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -71,6 +71,8 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ 'register(agent: Agent): () => void', 'enter(agent: Agent): () => void', 'announce(agent: Agent): void', + 'reportStartFailure(id: AgentId, error: Error): () => void', + 'getStartFailure(id: AgentId): Error | undefined', 'get(id: AgentId): Agent | undefined', 'list(): Agent[]', ], @@ -317,6 +319,12 @@ export const EVENT_API: readonly EventApiEntry[] = [ signature: '\'agent/session-start\'(this: Scoped, agent: Agent, source: SessionStartSource): void', summary: 'The session lifecycle began, once before the first turn.', }, + { + name: 'agent/start-failed', + mode: 'emit', + signature: '\'agent/start-failed\'(agentId: AgentId, error: Error): void', + summary: 'Declarative startup failed before an agent could be published.', + }, { name: 'agent/status', mode: 'emit', diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index a07aa5fa70..d46aabac0d 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -79,6 +79,6 @@ Everything that goes beyond "call the model, run the tools, repeat" belongs to p ## Known Limitations and Deferred Work - **Tool calls within a step execute sequentially** — parallel execution waits on concurrency-safety metadata in the tool contract (see `dsh-tools`). -- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning and creates no agent. +- **No resume-or-create policy on the config path** — config-driven `create()` starts a fresh `${id}-session-` every run (`TODO(demo)`), and a config `resumeSessionId` whose resume fails logs a warning, emits and retains `agent/start-failed` while that declaration remains loaded, and creates no agent. - **Config agents have no per-agent persona field or setup hook** — they use the deployment persona; scoped persona/tool composition is available only through the programmatic `ctx.agents.create()` / `resume()` factory options. - **No built-in turn budget** — the default continuation is `continue` whenever a step had tool calls or steering; bounding a runaway turn requires an `agent/turn-continuation` force-stop plugin. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 6cd66f622c..6d3ef8851a 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -363,18 +363,29 @@ export class AgentLoop extends Service implements AgentFactory { this.create(id, options, cwd === undefined ? {} : { cwd }) continue } - ctx.effect(() => { + ctx.effect(function* (this: AgentLoop) { + let active = true + let releaseFailure = (): void => {} const fiber = ctx.inject(['sessionPersistence'], (childCtx: Context) => { void this.resumeWith(ctx, childCtx.sessionPersistence, { agentId: id, resumeSessionId, agentOptions: options, }).catch((error: unknown) => { + if (!active) return + const failure = new Error(error instanceof Error ? error.message : String(error), { cause: error }) ctx.logger.warn(`agent "${id}": config-driven resume of "${resumeSessionId}" failed: ${String(error)}`) + releaseFailure = ctx.agents.reportStartFailure(id, failure) }) }) - return fiber.dispose - }, `agentLoop.resume(${id})`) + yield fiber.dispose + // Yielded last, disposed first: suppress teardown rejection before the + // deferred persistence child wakes and clear any retained record. + yield () => { + active = false + releaseFailure() + } + }.bind(this), `agentLoop.resume(${id})`) } } diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 8a5d9ce264..f91c7f6585 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -4,10 +4,11 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import LlmService from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionStore, { SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' +import SessionPersistence from '@deepseek-ai/dsh-session-persistence' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import AgentLoop, { ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' @@ -42,6 +43,72 @@ describe('config-driven session id', () => { await loopFiber.dispose() }) + it('drops an in-flight declarative resume when its owner is disposed', async () => { + const loadStarted = Promise.withResolvers() + const pendingLoad = Promise.withResolvers<{ meta: SessionHeader; events: SessionEvent[] }>() + class DeferredSessionPersistence extends SessionPersistence { + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + loadStarted.resolve(undefined) + return pendingLoad.promise + } + list(): Promise { return Promise.resolve([]) } + } + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const failures: Error[] = [] + ctx.on('agent/start-failed', (_id, error) => { failures.push(error) }) + const loopFiber = await ctx.plugin(AgentLoop, { + agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('deferred') }], + }) + await ctx.plugin(DeferredSessionPersistence) + await loadStarted.promise + + await loopFiber.dispose() + await Promise.resolve() + expect(failures).toEqual([]) + expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('normalizes a non-Error declarative resume rejection without duplicating an Error prefix', async () => { + class RejectingSessionPersistence extends SessionPersistence { + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + load(_id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + // Third-party backends can reject arbitrary values; this exercises normalization. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + return Promise.reject('plain failure') + } + list(): Promise { return Promise.resolve([]) } + } + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + const failures: Error[] = [] + ctx.on('agent/start-failed', (_id, error) => { failures.push(error) }) + const loopFiber = await ctx.plugin(AgentLoop, { + agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('rejected') }], + }) + await ctx.plugin(RejectingSessionPersistence) + + await vi.waitFor(() => { expect(failures).toHaveLength(1) }) + expect(failures[0]?.message).toBe('plain failure') + expect(failures[0]?.cause).toBe('plain failure') + await loopFiber.dispose() + await ctx.fiber.dispose() + }) + it('config-driven create uses a fresh ${id}-session- per run (restart-safe)', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-cfg-session-')) dirs.push(root) @@ -137,7 +204,9 @@ describe('config-driven session id', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) + const failures: Array<{ id: string; error: Error }> = [] + ctx.on('agent/start-failed', (id, error) => { failures.push({ id, error }) }) + const loopFiber = await ctx.plugin(AgentLoop, { agents: [{ id: AgentId('main'), model: 'mock', resumeSessionId: SessionId('does-not-exist') }] }) const warn = vi.spyOn((ctx.agentLoop as unknown as { ctx: { logger: { warn: (...a: unknown[]) => void } } }).ctx.logger, 'warn') .mockImplementation(() => undefined) await ctx.plugin(SessionPersistenceJsonl, { root }) @@ -148,6 +217,13 @@ describe('config-driven session id', () => { await new Promise(r => setTimeout(r, 200)) expect(ctx.agents.get(AgentId('main'))).toBeUndefined() expect(warn).toHaveBeenCalledWith(expect.stringContaining('config-driven resume of "does-not-exist" failed')) + expect(failures).toHaveLength(1) + expect(failures[0]?.id).toBe('main') + expect(failures[0]?.error.message).toBe('session "does-not-exist" not found') + expect(failures[0]?.error.cause).toBeInstanceOf(Error) + expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(failures[0]?.error) + await loopFiber.dispose() + expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined() warn.mockRestore() await ctx.fiber.dispose() }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index 5639f30daf..ed91da24ce 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -14,6 +14,9 @@ Tracks live agents so UI, hook, and orchestrator plugins can find them without i - Advanced factory lifecycle: `enter(agent)` publishes without announcing and returns an entry-bound detach; `announce(agent)` emits creation once. Detach during creation dispatch is deferred. Ordinary plugins use `register()`. - `ctx.agents.get(id: AgentId): Agent | undefined` - `ctx.agents.list(): Agent[]` +- `ctx.agents.reportStartFailure(id, error): () => void` announces and retains a declarative startup failure; the disposer clears that exact record without deleting a newer replacement. +- `ctx.agents.getStartFailure(id: AgentId): Error | undefined` returns a retained declarative startup failure for a configured id that never became live. Successful publication clears it. +- `observeAgentStart(ctx, id, handlers)` observes publication or declarative startup failure, including the retained-failure race for late-mounted front doors; its listeners belong to `ctx` and the returned disposer cancels the observation. #### Factory seam (creation) @@ -29,7 +32,7 @@ The loop plugin registers `AgentFactory`, keeping consumers independent of its c `dsh-agent` declares the live `agent/*` coordination vocabulary so plugins do not depend on the concrete loop. Exact signatures, dispatch modes, scope-filtering rules, and payload contracts live in the generated [Cordis event catalog](../../../docs/cordis-catalog/events.md); the [architecture turn flow](../../../docs/architecture.md#turn-flow) shows their order relative to durable session events. -`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. +`agent/created` runs after setup and both registry entries; the following `agent/session-start` is the first supported startup injection point. `agent/disposed` means the exact entry left the registry. `agent/start-failed` reports a contained declarative startup failure before publication; programmatic factory calls report failures through rejection. The loop quiesces its driver first; directly registered custom agents own any stronger ordering. Most interception points are cooperative waterfalls returning seam-specific decisions. `agent/pre-step` is a serial surface-mutation checkpoint, while `agent/turn-stop` is the terminal serial fold: it runs after ordinary continuation and steering folding, and a returned stop remains in force through turn close and flush so later steering cannot create an extra step or turn. Ordinary queued prompts remain intact. The full rationale is in the [agent-scope runtime-design RFC](../../../docs/rfc/implemented/architecture/2026-07-12-agent-scope-runtime-design.md#three-execution-boundaries-are-deliberately-one-way). @@ -69,7 +72,7 @@ The handle every plugin programs against: ## Known Limitations and Deferred Work - **Inter-agent channels beyond delegation** — shared state, streaming child output, and background/poll semantics remain outside the current synchronous `ctx.subagents` seam. -- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. +- **`agent/session-start` cannot gate startup** — it remains a synchronous, veto-less notification; async composition that must finish before publication belongs in the factory's `setup(agentCtx)` transaction instead. Declarative startup failures are reported separately through `agent/start-failed`. - **No public step-only abort** — `cancel()` clears ALL pending work (queued + steering + in-flight); an abort that preserves queued prompts returns only with a named consumer ([stop-surface RFC](../../../docs/rfc/implemented/simplification/2026-06-20-public-agent-stop-surface.md)). - **`HookContext` carries exactly one `MessageSource`** — contributions from several plugins merged onto one tool call collapse under one source; mixed provenance is unrepresentable. - **`SessionStartSource` reserves `'clear'`/`'compact'` with no emitter yet** — only `'startup'`/`'resume'` occur until the driving subsystems land (`TODO(compaction)`). diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index fc5ad371b4..fc3a59b43f 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -80,6 +80,60 @@ export interface AgentHandle { dispose(): Promise } +/** Handlers for observing one configured agent's publication or startup failure. */ +export interface AgentStartHandlers { + /** + * Handle publication of the requested agent. + * @param agent - the live agent that was published. + */ + onStarted: (agent: Agent) => void + /** + * Handle a retained or live declarative startup failure. + * @param error - the contained startup failure. + */ + onFailed: (error: Error) => void +} + +/** + * Observe one configured agent until it is published or its declarative startup fails. + * Listeners are owned by the supplied context fiber and the returned disposer is + * idempotent through Cordis's event-disposer semantics. + * @param ctx - the context whose fiber owns the observation listeners. + * @param id - the configured agent id to observe. + * @param handlers - publication and failure callbacks. + * @returns a disposer for the observation listeners. + */ +export function observeAgentStart(ctx: Context, id: AgentId, handlers: AgentStartHandlers): () => void { + const stop = (): void => { + disposeCreated() + disposeFailure() + } + const handleStarted = (agent: Agent): void => { + if (agent.id !== id) return + stop() + handlers.onStarted(agent) + } + const handleFailed = (failedId: AgentId, error: Error): void => { + if (failedId !== id) return + stop() + handlers.onFailed(error) + } + const disposeCreated = ctx.on('agent/created', handleStarted) + const disposeFailure = ctx.on('agent/start-failed', handleFailed) + const agent = ctx.agents.get(id) + if (agent !== undefined) { + stop() + handlers.onStarted(agent) + } else { + const failure = ctx.agents.getStartFailure(id) + if (failure !== undefined) { + stop() + handlers.onFailed(failure) + } + } + return stop +} + /** * The agent-creation factory the loop implementation provides to the registry * via {@link AgentRegistry.setFactory}. Kept on the `dsh-agent` interface so @@ -136,6 +190,7 @@ export class AgentRegistry extends Service { // plus entry.agent identity; this WeakMap mirrors the authoritative id map. private entries = new WeakMap() private factory: FactorySlot | undefined + private startFailures = new Map() constructor(ctx: Context) { super(ctx, 'agents') @@ -224,6 +279,7 @@ export class AgentRegistry extends Service { const carrier = scopeTarget(agent, agent) // Prepared transactions arbitrate identity at this publication boundary. if (this.entries.has(agent) || this.store.has(id)) throw new Error(`agent "${id}" is already registered`) + this.startFailures.delete(id) const entry: AgentEntry = { id, agent, @@ -316,6 +372,45 @@ export class AgentRegistry extends Service { } } + /** + * Report a contained declarative startup failure and retain it for late UI observers. + * @param id - the configured agent id that failed before publication. + * @param error - the normalized startup error. + * @returns a disposer that clears this exact failure record. + */ + reportStartFailure(id: AgentId, error: Error): () => void { + const token = {} + this.startFailures.set(id, { error, token }) + this.emitStartFailed(id, error) + return () => { + if (this.startFailures.get(id)?.token === token) this.startFailures.delete(id) + } + } + + /** + * Return a retained declarative startup failure for an id that never became live. + * @param id - the configured agent id. + * @returns the startup error, or undefined when the id has no retained failure. + */ + getStartFailure(id: AgentId): Error | undefined { + return this.startFailures.get(id)?.error + } + + /** Emit an unscoped startup failure with the same listener containment as agent lifecycle events. */ + private emitStartFailed(id: AgentId, error: Error): void { + const args: unknown[] = ['agent/start-failed', id, error] + for (const callback of this.ctx.events.dispatch('emit', args)) { + try { + const returned: unknown = callback(id, error) + void Promise.resolve(returned).catch((listenerError: unknown) => { + this.ctx.logger.warn(`agent "${id}": agent/start-failed listener rejected: ${String(listenerError)}`) + }) + } catch (listenerError: unknown) { + this.ctx.logger.warn(`agent "${id}": agent/start-failed listener threw: ${String(listenerError)}`) + } + } + } + /** * Look up a live agent. * @param id - the agent id to look up. diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 3aad65a70e..a9c16dec49 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -146,6 +146,15 @@ declare module 'cordis' { * @mode emit */ 'agent/disposed'(this: Scoped, agent: Agent): void + /** + * Declarative startup failed before an agent could be published. Programmatic + * `ctx.agents.create()` / `resume()` calls report failure through rejection; + * this event covers the fire-and-forget config path. + * @param agentId - the configured id that could not be started. + * @param error - the contained startup failure. + * @mode emit + */ + 'agent/start-failed'(agentId: AgentId, error: Error): void /** * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does * not enter `running` synchronously; drive lifecycle from this event. diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 5d541d56a8..b9acf9ef4e 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context, Service, symbols } from 'cordis' import type { Events } from 'cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { AgentId, agentEvents } from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId, agentEvents, observeAgentStart } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory, ContinuationStop, CreateAgentOptions, ResumeAgentOptions } from '@deepseek-ai/dsh-agent' function stubAgent(rawId: string): Agent { @@ -85,6 +85,110 @@ describe('AgentRegistry', () => { ]) }) + it('retains declarative startup failures, contains listeners, and clears stale records', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const warnings: string[] = [] + const heard: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + ctx.on('agent/start-failed', () => { throw new Error('start sync') }) + ctx.on('agent/start-failed', () => Promise.reject(new Error('start async')) as never) + ctx.on('agent/start-failed', id => void heard.push(id)) + + const firstError = new Error('first failure') + const disposeFirst = ctx.agents.reportStartFailure(AgentId('main'), firstError) + expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(firstError) + await Promise.resolve() + expect(heard).toEqual(['main']) + expect(warnings).toEqual([ + 'agent "main": agent/start-failed listener threw: Error: start sync', + 'agent "main": agent/start-failed listener rejected: Error: start async', + ]) + disposeFirst() + expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined() + + const disposeSecond = ctx.agents.reportStartFailure(AgentId('main'), new Error('second failure')) + const thirdError = new Error('third failure') + const disposeThird = ctx.agents.reportStartFailure(AgentId('main'), thirdError) + expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(thirdError) + disposeSecond() + expect(ctx.agents.getStartFailure(AgentId('main'))).toBe(thirdError) + disposeThird() + + const disposeOccupiedAgent = ctx.agents.register(stubAgent('occupied')) + const occupiedError = new Error('occupied failure') + const disposeOccupiedFailure = ctx.agents.reportStartFailure(AgentId('occupied'), occupiedError) + expect(() => { ctx.agents.enter(stubAgent('occupied')) }).toThrow(/already registered/) + expect(ctx.agents.getStartFailure(AgentId('occupied'))).toBe(occupiedError) + disposeOccupiedFailure() + disposeOccupiedAgent() + + const disposeCleared = ctx.agents.reportStartFailure(AgentId('main'), new Error('cleared failure')) + ctx.agents.register(stubAgent('main'))() + expect(ctx.agents.getStartFailure(AgentId('main'))).toBeUndefined() + disposeCleared() + await ctx.fiber.dispose() + }) + + it('observes immediate, retained, live, unrelated, and cancelled startup outcomes', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const outcomes: string[] = [] + + const existing = stubAgent('existing') + const disposeExisting = ctx.agents.register(existing) + observeAgentStart(ctx, existing.id, { + onStarted: agent => void outcomes.push(`started:${agent.id}`), + onFailed: error => void outcomes.push(`failed:${error.message}`), + })() + + const retainedError = new Error('retained') + const disposeRetained = ctx.agents.reportStartFailure(AgentId('retained'), retainedError) + observeAgentStart(ctx, AgentId('retained'), { + onStarted: agent => void outcomes.push(`started:${agent.id}`), + onFailed: error => void outcomes.push(`failed:${error.message}`), + })() + + const stopLiveStart = observeAgentStart(ctx, AgentId('live-start'), { + onStarted: agent => void outcomes.push(`started:${agent.id}`), + onFailed: error => void outcomes.push(`failed:${error.message}`), + }) + const disposeUnrelatedAgent = ctx.agents.register(stubAgent('unrelated')) + const disposeUnrelatedFailure = ctx.agents.reportStartFailure(AgentId('unrelated'), new Error('unrelated')) + const disposeLiveAgent = ctx.agents.register(stubAgent('live-start')) + stopLiveStart() + + const stopLiveFailure = observeAgentStart(ctx, AgentId('live-failure'), { + onStarted: agent => void outcomes.push(`started:${agent.id}`), + onFailed: error => void outcomes.push(`failed:${error.message}`), + }) + const disposeLiveFailure = ctx.agents.reportStartFailure(AgentId('live-failure'), new Error('live')) + stopLiveFailure() + + const stopCancelled = observeAgentStart(ctx, AgentId('cancelled'), { + onStarted: agent => void outcomes.push(`started:${agent.id}`), + onFailed: error => void outcomes.push(`failed:${error.message}`), + }) + stopCancelled() + const disposeCancelledFailure = ctx.agents.reportStartFailure(AgentId('cancelled'), new Error('cancelled')) + + expect(outcomes).toEqual([ + 'started:existing', + 'failed:retained', + 'started:live-start', + 'failed:live', + ]) + + disposeCancelledFailure() + disposeLiveFailure() + disposeLiveAgent() + disposeUnrelatedFailure() + disposeUnrelatedAgent() + disposeRetained() + disposeExisting() + await ctx.fiber.dispose() + }) + it('separates entry from announcement and stale/idempotent detach cannot remove a replacement', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index 96704998c4..05dd4d34c2 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -15,8 +15,8 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-user-interaction` | the human question/answer seam used by confirmation tools | | `@deepseek-ai/dsh-tool-ask-user` | the model-facing `ask_user_question` tool | | `@cordisjs/plugin-logger-console` | readline diagnostics for non-TTY operation; omitted from the fullscreen TUI path | -| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation | -| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs | +| `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation; matching `agent/start-failed` errors print and exit nonzero | +| `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs; matching `agent/start-failed` startup errors are printed before fullscreen mode and exit nonzero | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. diff --git a/packages/ui/stdio/README.md b/packages/ui/stdio/README.md index b7d320880d..0ee46b3a3d 100644 --- a/packages/ui/stdio/README.md +++ b/packages/ui/stdio/README.md @@ -11,7 +11,7 @@ This package owns the terminal channel only. It injects `agents` and `userIntera | `welcome` | `ready.` | Banner printed before the first prompt | | `agent` | `main` | Agent id driven by stdin and observed for EOF shutdown | -The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. Disposal closes readline and unregisters every listener/provider through Cordis effects. +The plugin seeds display labels from the live agent registry, then tracks `agent/created` and `agent/disposed` so HMR and externally managed agents render consistently. While waiting for its configured agent, it also observes retained and live `agent/start-failed` notifications; a matching failure is printed and exits with status 1 instead of waiting forever. Disposal closes readline and unregisters every listener/provider through Cordis effects. ```yaml - id: stdio diff --git a/packages/ui/stdio/src/index.ts b/packages/ui/stdio/src/index.ts index 1e665381ce..3773a55e92 100644 --- a/packages/ui/stdio/src/index.ts +++ b/packages/ui/stdio/src/index.ts @@ -13,7 +13,7 @@ import { createInterface } from 'node:readline' import type { Readable, Writable } from 'node:stream' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId } from '@deepseek-ai/dsh-agent' +import { AgentId, observeAgentStart } from '@deepseek-ai/dsh-agent' import { UserInteractionError, type AskUserQuestionAnswer, @@ -363,14 +363,12 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt */ export function mountStdio(ctx: Context, config: Config, runtime: StdioRuntime): void { const agentId = AgentId(config.agent ?? 'main') - if (ctx.agents.get(agentId) !== undefined) { - createStdioChat(ctx, config, runtime) - return - } - const dispose = ctx.on('agent/created', (agent) => { - if (agent.id !== agentId) return - dispose() - createStdioChat(ctx, config, runtime) + observeAgentStart(ctx, agentId, { + onStarted: () => { createStdioChat(ctx, config, runtime) }, + onFailed: (error) => { + runtime.output.write(`ui-stdio: agent "${agentId}" failed to start: ${error.message}\n`) + runtime.exit(1) + }, }) } diff --git a/packages/ui/stdio/tests/stdio.spec.ts b/packages/ui/stdio/tests/stdio.spec.ts index 7bb6a6f245..60407df416 100644 --- a/packages/ui/stdio/tests/stdio.spec.ts +++ b/packages/ui/stdio/tests/stdio.spec.ts @@ -2,7 +2,7 @@ import { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { AgentId } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' @@ -111,6 +111,42 @@ describe('mountStdio readiness', () => { await fiber.dispose() }) + it('prints a matching live startup failure and exits instead of waiting forever', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + const { runtime, out, exit } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed')) + expect(out.text()).toBe('') + expect(exit).not.toHaveBeenCalled() + ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed')) + expect(out.text()).toBe('ui-stdio: agent "main" failed to start: resume failed\n') + expect(exit).toHaveBeenCalledWith(1) + + ctx.agents.register(makeAgent('main')) + expect(out.text()).toBe('ui-stdio: agent "main" failed to start: resume failed\n') + await fiber.dispose() + }) + + it('prints a retained startup failure for a late-mounted stdio channel', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed')) + const { runtime, out, exit } = makeRuntime() + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + mountStdio(inner, CONFIG, runtime) + }, { inject: ['agents', 'userInteraction'] })) + + expect(out.text()).toBe('ui-stdio: agent "main" failed to start: already failed\n') + expect(exit).toHaveBeenCalledWith(1) + await fiber.dispose() + }) + it('opens immediately when the configured agent already exists', async () => { const ctx = new Context() await ctx.plugin(AgentRegistry) diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 1e678beac8..7cc84cee3d 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -33,7 +33,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t maxToolOutputLines: 12 ``` -Startup fails before mounting when either process stream is not a TTY. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +Startup fails before mounting when either process stream is not a TTY. While waiting for its configured agent, the front door also observes retained and live `agent/start-failed` notifications; a matching failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. ## Model Experience diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 14f45242d7..13b5b3866c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -34,7 +34,7 @@ import { } from '@earendil-works/pi-tui' import type { Context } from 'cordis' import z from 'schemastery' -import { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import { AgentId, observeAgentStart, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, TodoItem } from '@deepseek-ai/dsh-session' import type { @@ -133,7 +133,7 @@ export interface ResolvedTuiConfig { export interface TuiRuntime { /** Terminal implementation; production uses pi-tui's `ProcessTerminal`. */ terminal: Terminal - /** Exit hook used by `/exit`, Ctrl+D, or Ctrl+C while idle. */ + /** Exit hook used by terminal shutdown or a target-agent startup failure. */ exit(code: number): void } @@ -1252,20 +1252,17 @@ export function createTuiChat( */ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): void { const agentId = AgentId(config.agent ?? 'main') - const start = (): void => { - ctx.effect(() => { - const controller = createTuiChat(ctx, config, runtime) - return () => controller.dispose() - }, 'ui-tui') - } - if (ctx.agents.get(agentId) !== undefined) { - start() - return - } - const dispose = ctx.on('agent/created', (agent) => { - if (agent.id !== agentId) return - dispose() - start() + observeAgentStart(ctx, agentId, { + onStarted: () => { + ctx.effect(() => { + const controller = createTuiChat(ctx, config, runtime) + return () => controller.dispose() + }, 'ui-tui') + }, + onFailed: (error) => { + runtime.terminal.write(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`) + runtime.exit(1) + }, }) } diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index e4ffe2efd0..c0d1d419b8 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -910,6 +910,51 @@ describe('terminal mounting', () => { await ctx.fiber.dispose() }) + it('prints a matching live startup failure and exits instead of waiting forever', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + const terminal = new FakeTerminal() + const exit = vi.fn() + mountTui(ctx, { agent: 'main', color: false }, { terminal, exit }) + + ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed')) + expect(terminal.output).toBe('') + expect(exit).not.toHaveBeenCalled() + ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed')) + expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume failed\n') + expect(exit).toHaveBeenCalledWith(1) + + const session = ctx.sessions.create(SessionId('must-not-start')) + ctx.agents.register({ + id: AgentId('main'), options: {}, session, status: 'idle', ctx, + send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(), + }) + await tick() + expect(terminal.started).toBe(0) + await ctx.fiber.dispose() + }) + + it('prints a retained startup failure for a late-mounted TUI', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + ctx.provide('tools', { get: () => undefined } as never) + ctx.agents.reportStartFailure(AgentId('main'), new Error('already failed')) + const terminal = new FakeTerminal() + const exit = vi.fn() + + mountTui(ctx, { agent: 'main', color: false }, { terminal, exit }) + + expect(terminal.started).toBe(0) + expect(terminal.output).toBe('ui-tui: agent "main" failed to start: already failed\n') + expect(exit).toHaveBeenCalledWith(1) + await ctx.fiber.dispose() + }) + it('rolls back providers, listeners, and terminal state when startup fails', async () => { const ctx = new Context() await ctx.plugin(SessionStore) From 87e25ef1fefe6b5614bf33cf5f2580f71ad2c5f0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 17 Jul 2026 16:50:53 +0800 Subject: [PATCH 130/163] fix(tui): make the color palette theme-agnostic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The palette used absolute 256-color indices (`38;5;252`, `48;5;236`, …) that terminals never remap to the active scheme: near-white body text vanished on light backgrounds and fixed dark bubble fills put default foreground text on dark blocks. Rebuild it from the standard 16-color ANSI foregrounds plus SGR attributes, keep body text on the terminal default foreground, replace filled background bubbles with foreground-only left-gutter bars, and use reverse video for the active selection so every grouping reads on any color scheme. --- docs/config-catalog.md | 2 +- packages/ui/tui/README.md | 6 ++- packages/ui/tui/src/index.ts | 89 ++++++++++++++++++++++++------------ 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 79aa6ce17a..8004a8fa43 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1122,7 +1122,7 @@ export interface TuiConfig { } ``` -Source: [`packages/ui/tui/src/index.ts:100`](../packages/ui/tui/src/index.ts) +Source: [`packages/ui/tui/src/index.ts:99`](../packages/ui/tui/src/index.ts) ## `@deepseek-ai/dsh-user-approval` diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 7cc84cee3d..865a20a469 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -20,7 +20,7 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t | `questionDialogWidth` | `72` | Question-overlay width in columns | | `questionDialogMaxHeight` | `20` | Question-overlay maximum rows | | `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker | -| `color` | `true` | Apply the built-in ANSI palette | +| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) | | `title` | `DeepSeek Harness` | Terminal window title | ```yaml @@ -35,6 +35,10 @@ While the agent is running, editor submissions call `agent.steer()`; otherwise t Startup fails before mounting when either process stream is not a TTY. While waiting for its configured agent, the front door also observes retained and live `agent/start-failed` notifications; a matching failure is written before fullscreen mode starts and exits with status 1 instead of leaving a blank terminal. Disposal stops loaders, rejects pending questions, drains terminal input, restores terminal state, unregisters event listeners and the user-interaction provider, and never exits a replacement process during HMR. +## Color + +The palette uses the standard 16-color ANSI foregrounds and SGR attributes, which every terminal remaps to its active color scheme, so it stays readable on light and dark backgrounds alike. Body text keeps the terminal's default foreground rather than a fixed shade. Grouped regions (user prompts, tool cards) use a colored left-gutter bar instead of a filled background block, and the question overlay's active row uses reverse video; both are foreground-only, so they never collide with the terminal background. Set `color: false` to strip all styling. + ## Model Experience ### Interactive prompt input diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 13b5b3866c..aa8a510a32 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -8,7 +8,6 @@ import { homedir } from 'node:os' import { relative, resolve, sep } from 'node:path' import { - Box, CombinedAutocompleteProvider, Container, Editor, @@ -172,39 +171,39 @@ interface Palette { italic: (text: string) => string underline: (text: string) => string strike: (text: string) => string - userBg: (text: string) => string - pendingBg: (text: string) => string - successBg: (text: string) => string - errorBg: (text: string) => string - selectedBg: (text: string) => string + /** Reverse video for the active selection; swaps the theme's own fg/bg so it reads on any scheme. */ + selected: (text: string) => string } function ansi(open: string, close: string, enabled: boolean): (text: string) => string { return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text } +/** + * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR + * attributes, which every terminal remaps to its active color scheme. Body + * `text` stays the terminal's default foreground so it reads on light and dark + * backgrounds alike; grouping uses foreground-only gutter bars and reverse + * video rather than fixed background fills. + */ function createPalette(enabled: boolean): Palette { return { - accent: ansi('38;5;39', '39', enabled), - accent2: ansi('38;5;141', '39', enabled), - text: ansi('38;5;252', '39', enabled), - muted: ansi('38;5;245', '39', enabled), - dim: ansi('38;5;240', '39', enabled), - success: ansi('38;5;78', '39', enabled), - warning: ansi('38;5;221', '39', enabled), - error: ansi('38;5;203', '39', enabled), - code: ansi('38;5;215', '39', enabled), - added: ansi('38;5;78', '39', enabled), - removed: ansi('38;5;203', '39', enabled), + accent: ansi('94', '39', enabled), + accent2: ansi('95', '39', enabled), + text: text => text, + muted: ansi('90', '39', enabled), + dim: ansi('2', '22', enabled), + success: ansi('32', '39', enabled), + warning: ansi('33', '39', enabled), + error: ansi('31', '39', enabled), + code: ansi('36', '39', enabled), + added: ansi('32', '39', enabled), + removed: ansi('31', '39', enabled), bold: ansi('1', '22', enabled), italic: ansi('3', '23', enabled), underline: ansi('4', '24', enabled), strike: ansi('9', '29', enabled), - userBg: ansi('48;5;236', '49', enabled), - pendingBg: ansi('48;5;235', '49', enabled), - successBg: ansi('48;5;22', '49', enabled), - errorBg: ansi('48;5;52', '49', enabled), - selectedBg: ansi('48;5;24', '49', enabled), + selected: ansi('7', '27', enabled), } } @@ -296,9 +295,41 @@ class HeaderComponent implements Component { } } -class UserMessageComponent extends Box { +/** + * Groups children behind a colored left-gutter bar (`▌`). Foreground-only, so + * it renders legibly on any terminal background — unlike a filled block whose + * body text would collide with the theme's default foreground. + */ +class GutterBox implements Component { + protected readonly children: Component[] = [] + + constructor(private readonly barFn: (text: string) => string, private readonly paddingY = 1) {} + + addChild(child: Component): void { + this.children.push(child) + } + + invalidate(): void { + for (const child of this.children) child.invalidate() + } + + render(width: number): string[] { + const inner = Math.max(1, width - 2) + const body: string[] = [] + for (const child of this.children) for (const line of child.render(inner)) body.push(line) + // Every caller adds a non-empty title/label child, so an all-empty box is unreachable; + // the guard preserves Box semantics (render nothing) rather than emitting stray gutter bars. + /* v8 ignore next */ + if (body.length === 0) return [] + const bar = this.barFn('▌') + const pad = Array.from({ length: this.paddingY }, () => '') + return [...pad, ...body, ...pad].map(line => `${bar} ${line}`) + } +} + +class UserMessageComponent extends GutterBox { constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { - super(1, 1, value => palette.userBg(value)) + super(value => palette.accent(value)) this.addChild(new Text(palette.bold(palette.accent(label)), 0, 0)) this.addChild(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }, { preserveOrderedListMarkers: true, @@ -465,10 +496,10 @@ class ToolCardComponent implements Component { const visibleBody = this.expanded || body.length <= this.maxOutputLines ? body : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] - const box = new Box(1, visibleBody.length > 0 ? 1 : 0, (value) => { - if (this.result === undefined) return this.palette.pendingBg(value) - return isError ? this.palette.errorBg(value) : this.palette.successBg(value) - }) + const barFn = this.result === undefined + ? this.palette.warning + : isError ? this.palette.error : this.palette.success + const box = new GutterBox(barFn, visibleBody.length > 0 ? 1 : 0) box.addChild(new Text(this.palette.bold(title), 0, 0)) if (visibleBody.length > 0) box.addChild(new Text(visibleBody.join('\n'), 0, 0)) return box.render(width) @@ -700,7 +731,7 @@ class QuestionDialog implements Component, Focusable { : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') const description = option.description ? this.palette.muted(` — ${option.description}`) : '' const line = `${cursor} ${mark} ${option.label}${description}` - push(index === this.selectedIndex ? this.palette.selectedBg(line) : line) + push(index === this.selectedIndex ? this.palette.selected(line) : line) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) push(this.palette.dim(this.question.multiSelect From 7f91a05ed772cee464cb450b0a69858c7fdd4cfc Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 17 Jul 2026 17:13:41 +0800 Subject: [PATCH 131/163] docs: add TUI screenshots --- docs/assets/tui/jokes-dark.png | Bin 0 -> 66598 bytes docs/assets/tui/jokes-dynamic.gif | Bin 0 -> 78162 bytes docs/assets/tui/jokes-light.png | Bin 0 -> 70844 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 docs/assets/tui/jokes-dark.png create mode 100644 docs/assets/tui/jokes-dynamic.gif create mode 100644 docs/assets/tui/jokes-light.png diff --git a/docs/assets/tui/jokes-dark.png b/docs/assets/tui/jokes-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..876ea9c24c9422e76aa839bdead1edd6cb6df22f GIT binary patch literal 66598 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 a`$p*P1=7cj$ra$88&Gk1u|iQjum1%LHms5W literal 0 HcmV?d00001 diff --git a/docs/assets/tui/jokes-dynamic.gif b/docs/assets/tui/jokes-dynamic.gif new file mode 100644 index 0000000000000000000000000000000000000000..f0036572e8a2eeb0721d6929cafe404e0f6099ad GIT binary patch literal 78162 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�%^@#dq}3j{IDaY(s{Y&2k(r-F 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 Date: Sat, 18 Jul 2026 12:54:45 +0800 Subject: [PATCH 132/163] test(agent-spine-demo): wait for exact agent identity --- .../examples/agent-spine-demo/tests/agent-core.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 6081fff1a1..fb74e11b7d 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -86,10 +86,10 @@ async function withIsolatedSkillHomes(run: () => Promise): Promise { } } -function waitForMainIdle(ctx: Context): Promise { +function waitForIdle(ctx: Context, target: Agent): Promise { return new Promise((resolve) => { const dispose = ctx.on('agent/status', (agent, status) => { - if (agent.id === 'main' && status === 'idle') { + if (agent === target && status === 'idle') { dispose() resolve() } @@ -177,7 +177,7 @@ describe('dsh-agent-spine-demo bundle', () => { const agent = handle.agent agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, agent) const sentText = adapter.requests[0]?.messages.map(messageText).join('\n') expect(sentText).toContain('hi') @@ -206,7 +206,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) handle.agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, handle.agent) expect(adapter.requests[0]?.messages).toEqual([{ role: 'user', content: [{ type: 'text', text: 'hi' }] }]) await handle.dispose() @@ -295,7 +295,7 @@ describe('dsh-agent-spine-demo bundle', () => { }) handle.agent.send([{ type: 'text', text: 'hi' }]) - await waitForMainIdle(ctx) + await waitForIdle(ctx, handle.agent) expect(messageText(adapter.requests[0]?.messages[0])).toContain('workspace rule before skills') expect(messageText(adapter.requests[0]?.messages[1])).toContain('prefix-order-skill') From e846a115f29fd4fdfe7d6fc7ac303688a093a715 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:51:44 +0800 Subject: [PATCH 133/163] test(acp-snapshot): cover pre-spawn launch failure --- .../support/acp-snapshot/tests/harness.spec.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/packages/support/acp-snapshot/tests/harness.spec.ts b/packages/support/acp-snapshot/tests/harness.spec.ts index 3436f32f96..2b9d6e032f 100644 --- a/packages/support/acp-snapshot/tests/harness.spec.ts +++ b/packages/support/acp-snapshot/tests/harness.spec.ts @@ -244,6 +244,23 @@ describe('runScenario', () => { )).rejects.toThrow(/agent stderr:\nfake agent requested startup failure/) }) + it('preserves launch-resolution errors when no child process exists', async () => { + const { dir, fixtureFile } = await scenario({}) + vi.stubEnv('DSH_EXAMPLE_MODE', 'lib') + try { + await expect(runScenario( + { steps: [] }, + { + agent: { ...AGENT, binScript: join(dir, 'outside-src.ts'), libBinScript: undefined }, + mode: 'replay', + fixtureFile, + }, + )).rejects.toThrow(/expected a "\/src\/" segment/) + } finally { + vi.unstubAllEnvs() + } + }) + it('drives a full turn: initialize (terminal caps), session, prompt, permission stub, harvest', { timeout: 20_000 }, async () => { const { fixtureFile } = await scenario({ permissionProbe: true, From 1815095579a28d5eb46ba7329f508d31b3a79d56 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:54:24 +0800 Subject: [PATCH 134/163] docs(api): refresh compact reference --- website/zh-CN/api/harness/compact.md | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/website/zh-CN/api/harness/compact.md b/website/zh-CN/api/harness/compact.md index 8be3b4287a..67f17cc941 100644 --- a/website/zh-CN/api/harness/compact.md +++ b/website/zh-CN/api/harness/compact.md @@ -25,20 +25,19 @@ Check token pressure and compact if the conversation is too large. Estimate the [Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L58) -### ctx.compact.compactRegion(session, start, end, agent, signal?) +### ctx.compact.compactRegion(start, end, agent, signal?) ```ts website-api -abstract compactRegion( session: Session, start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise +abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` -Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation. The agent must own the exact target session object; implementations reject an ownership mismatch before model resolution, lock acquisition, summarization, or log mutation, and reject active, missing, reversed, or unbalanced ranges. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks. +Forcibly compact a range of surface nodes into a single summary node. `start` and `end` name an inclusive span by surface position, not numeric seq order; replacements can make visible seqs non-monotonic. Both edges must be balanced so assistant tool calls remain paired with their results. A model- backed implementation forwards cancellation and rejects active, missing, reversed, or unbalanced ranges. The target session is `agent.session`. Use toolPairingBalancedBefore and toolPairingBalancedAfter for the edge checks. -- `session` — session to mutate; must be identical to `agent.session`. - `start` — first surface seq, inclusive. - `end` — last surface seq, inclusive. -- `agent` — owner of the target session and summarizer context. +- `agent` — context whose session is mutated and whose routing options guide summarization. - `signal` — optional cancellation; model-backed implementations must forward it. -**Returns** the replaced range and summary. +**Returns** the appended event seqs, summary, replaced range, and token accounting. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L85) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/compact/compact/src/index.ts#L82) From 9d0066440311c928a23e925fcdc34ba9da5bd184 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:54:48 +0800 Subject: [PATCH 135/163] docs(api): refresh agent reference --- website/zh-CN/api/harness/agents.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/website/zh-CN/api/harness/agents.md b/website/zh-CN/api/harness/agents.md index f6c9565d37..ef20c585d7 100644 --- a/website/zh-CN/api/harness/agents.md +++ b/website/zh-CN/api/harness/agents.md @@ -20,7 +20,7 @@ Register the effect-scoped creation factory, rejecting a duplicate. Service fact **Returns** the exact Cordis effect disposer. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L152) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L149) ### ctx.agents.create(options) @@ -34,7 +34,7 @@ Create and publish an owned agent and session through the active factory. Reject **Returns** the handle after setup, rollback-covered publication, and loop start complete. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L177) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L174) ### ctx.agents.resume(options) @@ -48,7 +48,7 @@ Load a persisted session and resume an agent on it through the registered factor **Returns** the handle after setup, rollback-covered publication, and loop start complete. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L193) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L190) ### ctx.agents.register(agent) @@ -62,7 +62,7 @@ Register a live agent in the calling effect scope, with scope-filtered creation **Returns** the exact Cordis effect disposer for nested teardown ordering. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L207) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L204) ### ctx.agents.enter(agent) @@ -76,7 +76,7 @@ Insert an unpublished agent for an ordered factory transaction. **Returns** an idempotent closure that removes this exact entry and emits the paired disposal edge; detachment during creation dispatch is deferred. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L222) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L219) ### ctx.agents.announce(agent) @@ -88,7 +88,7 @@ Announce an agent previously inserted with enter. - `agent` — the live inserted agent to announce. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L290) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L286) ### ctx.agents.get(id) @@ -102,7 +102,7 @@ Look up a live agent. **Returns** the agent, or undefined when no live agent has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L324) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L320) ### ctx.agents.list() @@ -114,4 +114,4 @@ All live agents, in registration order. **Returns** a fresh array; mutating it does not affect the registry. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L332) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/agent/src/index.ts#L328) From 0f85ae0a9df15ad0a356087715afebba12ff41b4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:26:14 +0800 Subject: [PATCH 136/163] fix(subagent): identify lifecycle runs explicitly --- docs/config-catalog.md | 2 +- docs/cordis-catalog/events.md | 8 +- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/subagent.md | 5 +- docs/event-producer-consumer.md | 8 +- docs/module-graph.md | 3 +- .../cordis/tool-cordis/src/api-catalog.ts | 2 +- .../hooks/hooks-claude/tests/bridge.spec.ts | 7 +- .../hooks-claude/tests/coverage-cases.ts | 9 +- packages/subagent/subagent-acp/src/run.ts | 1 + .../subagent/subagent-inprocess/src/index.ts | 1 + packages/subagent/subagent/README.md | 4 +- packages/subagent/subagent/package.json | 2 + packages/subagent/subagent/src/index.ts | 25 +++- packages/subagent/subagent/src/types.ts | 19 +++ .../subagent/subagent/tests/service.spec.ts | 25 +++- .../tool-subagent/tests/tool-subagent.spec.ts | 15 ++ packages/support/subagent-mock/src/index.ts | 1 + packages/ui/jsonrpc/README.md | 2 +- packages/ui/jsonrpc/src/server.ts | 45 +----- .../jsonrpc/tests/built-scope-carrier.e2e.ts | 1 + packages/ui/jsonrpc/tests/server.spec.ts | 130 ++++++++++++++++-- .../tests/workflow-workerthread.spec.ts | 8 ++ pnpm-lock.yaml | 3 + website/zh-CN/api/harness/events.md | 8 +- website/zh-CN/api/harness/subagents.md | 10 +- 26 files changed, 253 insertions(+), 93 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a0cbac6789..f07dfcf18a 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -918,7 +918,7 @@ export interface Config { Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) -Source: [`packages/support/subagent-mock/src/index.ts:86`](../packages/support/subagent-mock/src/index.ts) +Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) ## `@deepseek-ai/dsh-subagent-spawn` diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 820a453ceb..d0ff7002b0 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -301,7 +301,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c 'subagent/end'(this: Scoped, info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:109`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-added` — emit @@ -311,7 +311,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:83`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/subagent/src/index.ts) ### `subagent/provider-removed` — emit @@ -321,7 +321,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:89`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/subagent/src/index.ts) ### `subagent/start` — emit @@ -331,7 +331,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( 'subagent/start'(this: Scoped, info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:100`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) ## `system-prompt/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 52f9cdf131..ee953e37c7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -264,7 +264,7 @@ list(): string[] async start(name: string, request: SubagentStartRequest): Promise ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) ## `ctx.systemPrompt` — `SystemPrompt` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 18cb22c32d..815438f215 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -69,6 +69,7 @@ interface SubagentStopReasonMap { ```ts type-equiv interface SubagentRun { readonly id: SessionId + readonly localAgent: Agent | undefined readonly result: Promise dispose(): Promise sendMessage?(content: ContentBlock[]): void @@ -76,7 +77,7 @@ interface SubagentRun { } ``` -A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope; `parentSession` is the durable transport-neutral lineage. A remote provider instead returns a parent-scoped lifecycle id and does not publish a local child. +A local run MUST publish an ordinary child agent/session before `start()` fulfills, return that child session id as `SubagentRun.id`, expose the exact child as `localAgent`, and record `request.parent.session.id` in the child's `parentSession` header. Runtime ownership may place the child under the parent, provider, or root scope. A remote provider instead returns a parent-scoped lifecycle id and `localAgent: undefined`. ## The provider seam: `SubagentProvider` @@ -91,7 +92,7 @@ interface SubagentProvider { } ``` -`start()` fulfills only with a ready run. The service observes its result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. In-process children are discoverable through `ctx.agents`, while remote children need not be. `subagent/end` reports final output or infrastructure failure. Both events are observe-only and contain listener exceptions. +`start()` fulfills only with a ready run. The service mints a unique `runId`, snapshots `local` from the provider's exact `localAgent`, observes the result, emits `subagent/start`, and returns the same run; rejection implies provider cleanup and emits no lifecycle pair. The paired `subagent/end` carries the same identity and the final output or infrastructure failure. Both events are observe-only and contain listener exceptions. ## In-process backends: depth and seed diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6d75d54f3d..49e8d778cf 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -30,10 +30,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:109`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:83`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:89`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:100`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:103`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude) | | `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:27`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`acp`](../packages/ui/acp) | | `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:33`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:116`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 604aa7cf7f..f49092f99d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -279,6 +279,7 @@ flowchart TD pkg_tool_skill --> pkg_skill pkg_tool_skill --> pkg_tools pkg_subagent --> pkg_agent + pkg_subagent --> pkg_brand pkg_subagent --> pkg_llm pkg_subagent --> pkg_scope pkg_subagent --> pkg_session @@ -493,7 +494,7 @@ flowchart TD | [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`bash`](../packages/bash/bash), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-web`](../packages/web/tool-web) | `web` | [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/timeout/timeout-policy) | `timeout` | [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 71640498c9..ed5c0babe9 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1028,7 +1028,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SubagentRun', - declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', + declaration: 'export interface SubagentRun {\n readonly id: SessionId;\n readonly localAgent: Agent | undefined;\n readonly result: Promise;\n dispose(): Promise;\n sendMessage?(content: ContentBlock[]): void;\n resume?(content: ContentBlock[]): Promise;\n}', }, { name: 'SubagentStartRequest', diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 136ee893c6..7bb4c0b4ea 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -288,8 +289,8 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => // Drive the observe-only lifecycle events directly (no real child needed — the // bridge just listens). No child agent is registered, so SubagentStart's // child lookup yields undefined and it simply runs the hook. - ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) - ctx.emit('subagent/end', { provider: 'inproc', id: SessionId('child-1'), stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'done' }] }) // Both hooks run async (detached .then); poll for their marker files rather // than a fixed sleep that flakes under load. @@ -324,7 +325,7 @@ describe('hooks-claude bridge — SubagentStart / SubagentStop (observe)', () => const { ctx, hooks } = await harnessWithFiber(dir, new MockAdapter([])) const warn = vi.fn() ctx.logger.warn = warn as never - ctx.emit('subagent/start', { provider: 'inproc', id: SessionId('child-1') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-1'), provider: 'inproc', id: SessionId('child-1'), local: false }) await waitFor(() => existsSync(marker)) const pid = Number(readFileSync(pidFile, 'utf8').trim()) await hooks.dispose() diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index 9d4004c1ff..0431486d57 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -9,6 +9,7 @@ import { defineTool } from '@deepseek-ai/dsh-tools' import AgentLoop, { type ReactLoopAgent } from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import { SubagentRunId } from '@deepseek-ai/dsh-subagent' import * as HooksClaude from '@deepseek-ai/dsh-hooks-claude' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' @@ -231,7 +232,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const injected: string[] = [] const child = { id: SessionId('child-x'), inject: (content: { type: string; text?: string }[]) => { injected.push(content.map(b => b.text ?? '').join('')) }, session: { id: SessionId('child-x'), header: { id: 'child-x' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-x') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-x'), provider: 'p', id: SessionId('child-x'), local: true }) await waitFor(() => injected.includes('child guidance')) expect(injected).toContain('child guidance') }) @@ -247,7 +248,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const warn = vi.fn(); ctx.logger.warn = warn as never const child = { id: SessionId('child-y'), inject: () => { throw new Error('inject boom') }, session: { id: SessionId('child-y'), header: { id: 'child-y' } } } as unknown as Parameters[0] ctx.agents.register(child) - ctx.emit('subagent/start', { provider: 'p', id: SessionId('child-y') }) + ctx.emit('subagent/start', { runId: SubagentRunId('run-y'), provider: 'p', id: SessionId('child-y'), local: true }) await waitFor(() => warn.mock.calls.some(c => String(c[0]).includes('SubagentStart hook failed'))) expect(warn).toHaveBeenCalledWith(expect.stringContaining('SubagentStart hook failed')) }) @@ -291,7 +292,7 @@ export function defineCoverageCases(group: CoverageGroup): void { const s = sh(d, 'stop.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) const path = hooks(d, { SubagentStop: [{ hooks: [{ type: 'command', command: s }] }] }) const ctx = await harness(path, new MockAdapter([])) - ctx.emit('subagent/end', { provider: 'p', id: SessionId('child-z'), stopReason: 'completed' }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-z'), provider: 'p', id: SessionId('child-z'), local: false, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) }) @@ -691,7 +692,7 @@ export function defineCoverageCases(group: CoverageGroup): void { // Register a live child on its own session cwd; emit subagent/end with its id. const { SessionId } = await import('@deepseek-ai/dsh-session') const childHandle = await ctx.agents.create({ sessionId: SessionId('child-stop-session'), meta: { cwd: childDir }, agentOptions: { provider: 'mock', model: 'mock' } }) - ctx.emit('subagent/end', { provider: 'inproc', id: childHandle.agent.id, stopReason: 'completed' }) + ctx.emit('subagent/end', { runId: SubagentRunId('run-stop'), provider: 'inproc', id: childHandle.agent.id, local: true, stopReason: 'completed' }) await waitFor(() => existsSync(marker)) expect(existsSync(marker)).toBe(true) // the marker landed in the CHILD dir diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index b7b10ecb97..a10a87a940 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -294,6 +294,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let disposal: Promise | undefined return { id, + localAgent: undefined, result, dispose(): Promise { if (disposal !== undefined) return disposal diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index 018f93051f..c5c85ec232 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -175,6 +175,7 @@ export async function startInProcessRun( return { id: childId, + localAgent: child, result, dispose(): Promise { request.signal.removeEventListener('abort', onAbort) diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 45e0976ed7..17edd0e3c7 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -50,9 +50,9 @@ Runtime features are optional methods on `SubagentRun`: `sendMessage?` steers a `SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for the child resources to quiesce. -A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, and records `request.parent.session.id` in the child's `parentSession` header. The child may be owned by the parent scope or by a provider/root scope; durable lineage is the transport-neutral local-child relation. Remote providers instead mint a parent-scoped lifecycle id without publishing a local child. +A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, and records `request.parent.session.id` in the child's `parentSession` header. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`. -The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. In-process start observers can resolve the published child through `ctx.agents.get(info.id)`; remote providers need not publish a local agent. +The service emits `subagent/start` only after `start()` has fulfilled. It attaches the result observer before that synchronous notification, so even an already-settled child still produces `subagent/start` before `subagent/end`. The pair shares a service-minted `runId`; its `local` flag is snapshotted from the provider's exact `localAgent`, so observers never infer run identity or locality from reusable provider/session names. Run events are scoped to the delegating parent. Every listener is independently contained: a synchronous throw or rejected returned promise is logged without starving peer listeners or changing the run. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 84d2c963d5..aea05553e4 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -23,6 +23,7 @@ "license": "BSD-3-Clause", "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-scope": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", @@ -31,6 +32,7 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 00762cc413..8f3549ac7c 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -28,6 +28,7 @@ * @module @deepseek-ai/dsh-subagent */ +import { randomUUID } from 'node:crypto' import { Context, Service } from 'cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' @@ -43,7 +44,9 @@ import type { SubagentRun, SubagentStartRequest, } from './types.ts' +import { SubagentRunId } from './types.ts' +export { SubagentRunId } from './types.ts' export type { SubagentCapabilities, SubagentProvider, @@ -112,18 +115,26 @@ declare module 'cordis' { /** Observe-only identifying detail for a ready subagent run. */ export interface SubagentRunInfo { + /** Unique identity shared with the paired terminal event. */ + readonly runId: SubagentRunId /** The provider that established the run. */ readonly provider: string /** The child agent's id. */ readonly id: SessionId + /** Whether the provider exposed an exact published in-process child. */ + readonly local: boolean } /** Observe-only outcome detail for a settled subagent run. */ export interface SubagentRunEndInfo { + /** Unique identity shared with the paired start event. */ + readonly runId: SubagentRunId /** The provider that ran it. */ readonly provider: string /** The child agent's id. */ readonly id: SessionId + /** Whether the provider exposed an exact published in-process child. */ + readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] /** The child's final assistant output, absent on infrastructure rejection. */ @@ -208,22 +219,28 @@ export class SubagentService extends Service { const parent = request.parent const run = await provider.start(request) + const runId = SubagentRunId(randomUUID()) + const lifecycleIdentity = { + runId, + provider: name, + id: run.id, + local: run.localAgent !== undefined, + } // Attach the terminal observer before dispatching start. Promise reactions // still run after this synchronous start emission, preserving start → end. void run.result.then( (result) => { this.emitLifecycle('subagent/end', { - provider: name, - id: run.id, + ...lifecycleIdentity, stopReason: result.stopReason, lastAssistantMessage: result.output, }, parent) }, () => { - this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }, parent) + this.emitLifecycle('subagent/end', { ...lifecycleIdentity, stopReason: 'error' }, parent) }, ) - this.emitLifecycle('subagent/start', { provider: name, id: run.id }, parent) + this.emitLifecycle('subagent/start', lifecycleIdentity, parent) return run } diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index e5ba8fd84d..1b1645d89b 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -7,10 +7,23 @@ */ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools' +/** Identifies one accepted subagent run across its lifecycle event pair. */ +export type SubagentRunId = Branded<'SubagentRunId'> + +/** + * Brand a string as a {@link SubagentRunId}. + * @param id - the raw id string (the service mints UUIDs; tests may pass fixtures). + * @returns the same string, branded. + */ +export function SubagentRunId(id: string): SubagentRunId { + return id as SubagentRunId +} + /** * Which START-TIME features a provider supports. Checked by the service before delegating to * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks @@ -139,6 +152,12 @@ export interface SubagentRun { * remote provider mints an id unique in the parent namespace. */ readonly id: SessionId + /** + * The exact published in-process child, or `undefined` for a remote run. + * When present, its id is {@link id}; the provider retains no ownership + * implication beyond the run's ordinary {@link dispose} contract. + */ + readonly localAgent: Agent | undefined /** * Resolves with the child's terminal {@link SubagentResult} when the run * settles. Does NOT reject on a child-level failure — a model/transport diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index ad7dbaafff..66008a923b 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -48,6 +48,7 @@ class StubProvider implements SubagentProvider { this.startCount += 1 return { id: SessionId(`child:${this.name}:${request.parent.id}`), + localAgent: undefined, result: Promise.resolve(this.outcome), async dispose() {}, } @@ -137,13 +138,14 @@ describe('SubagentService', () => { const parent = fakeParent('delegator') const events: string[] = [] const keys: unknown[] = [] - ctx.on('subagent/start', function () { events.push('start'); keys.push(carrierKeyOf(this)) }) - ctx.on('subagent/end', function () { events.push('end'); keys.push(carrierKeyOf(this)) }) + const runIds: string[] = [] + ctx.on('subagent/start', function (info) { events.push('start'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) + ctx.on('subagent/end', function (info) { events.push('end'); keys.push(carrierKeyOf(this)); runIds.push(info.runId) }) const starting = subagents.start('deferred', baseRequest({ parent })) await Promise.resolve() expect(events).toEqual([]) - ready.resolve({ id: SessionId('child'), result: result.promise, async dispose() {} }) + ready.resolve({ id: SessionId('child'), localAgent: undefined, result: result.promise, async dispose() {} }) const run = await starting expect(events).toEqual(['start']) result.resolve({ output: [{ type: 'text', text: 'answer' }], stopReason: 'completed' }) @@ -151,6 +153,21 @@ describe('SubagentService', () => { await Promise.resolve() expect(events).toEqual(['start', 'end']) expect(keys).toEqual([parent, parent]) + expect(runIds[0]).toBe(runIds[1]) + }) + + it('mints distinct lifecycle identities when provider and child ids repeat', async () => { + const { ctx, subagents } = await service() + subagents.registerProvider(new StubProvider('reused')) + const runIds: string[] = [] + ctx.on('subagent/start', info => void runIds.push(info.runId)) + + const first = await subagents.start('reused', baseRequest()) + const second = await subagents.start('reused', baseRequest()) + await Promise.all([first.result, second.result]) + + expect(runIds).toHaveLength(2) + expect(new Set(runIds).size).toBe(2) }) it('emits no run lifecycle when provider startup rejects', async () => { @@ -192,7 +209,7 @@ describe('SubagentService', () => { capabilities: NO_CAPS, inheritsParentContext: false, async start() { - return { id: SessionId('infra-child'), result: failure.promise, async dispose() {} } + return { id: SessionId('infra-child'), localAgent: undefined, result: failure.promise, async dispose() {} } }, }) const failedRun = await subagents.start('infra', baseRequest()) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 27a603049e..dbb78812d4 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -144,6 +144,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('weird-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), dispose: async () => {}, }), @@ -171,6 +172,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -200,6 +202,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('bare-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -327,6 +330,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => void disposed(), }), @@ -349,6 +353,7 @@ describe('dsh-tool-subagent', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('spy-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'error' as const }), dispose: async () => void disposed(), }), @@ -380,6 +385,7 @@ describe('dsh-tool-subagent', () => { }, { once: true }) return { id: SessionId('spy-child'), + localAgent: undefined, result, dispose: async () => {}, } @@ -472,6 +478,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture2-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -529,6 +536,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture3-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -558,6 +566,7 @@ describe('dsh-tool-subagent', () => { seen = request return { id: SessionId('capture4-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), dispose: async () => {}, } @@ -732,6 +741,7 @@ describe('dsh-tool-subagent background mode', () => { }, { once: true }) return { id, + localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -771,6 +781,7 @@ describe('dsh-tool-subagent background mode', () => { const order: string[] = [] const completed = await settleRun({ id: SessionId('child-1'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text' as const, text: 'ok' }], stopReason: 'completed' as const }), dispose() { order.push('dispose'); return Promise.resolve() }, }) @@ -782,6 +793,7 @@ describe('dsh-tool-subagent background mode', () => { let disposed = false const failed = await settleRun({ id: SessionId('child-2'), + localAgent: undefined, result: Promise.reject(new Error('transport gone')), dispose() { disposed = true; return Promise.resolve() }, }) @@ -790,6 +802,7 @@ describe('dsh-tool-subagent background mode', () => { const disposeFailed = await settleRun({ id: SessionId('child-3'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' }), dispose: () => Promise.reject(new Error('reap failed')), }) @@ -797,6 +810,7 @@ describe('dsh-tool-subagent background mode', () => { const bothFailed = await settleRun({ id: SessionId('child-4'), + localAgent: undefined, result: Promise.reject(new Error('result failed')), dispose: () => Promise.reject(new Error('reap failed')), }) @@ -832,6 +846,7 @@ describe('background preflight failure (no orphaned child, by construction)', () starts += 1 return { id: SessionId('probe-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'completed' as const }), dispose: () => Promise.resolve(), } diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts index c2e94c521f..4ea30d166a 100644 --- a/packages/support/subagent-mock/src/index.ts +++ b/packages/support/subagent-mock/src/index.ts @@ -69,6 +69,7 @@ class MockSubagentProvider implements SubagentProvider { }) return { id, + localAgent: undefined, result, dispose(): Promise { flags.cancelled = true diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 6d9f007e66..3d19b601c4 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -4,7 +4,7 @@ The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC serve ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It classifies subagent completions through live parent ownership or durable parent lineage and retains parent-scoped provider/id counts after child disposal. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It forwards subagent completions only when the lifecycle payload's `local` flag was snapshotted from the provider's exact in-process child; reusable provider names, child ids, and durable lineage never establish locality. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. ## Config diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 91cc49571a..5246795a31 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -19,7 +19,7 @@ import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' import type SubagentService from '@deepseek-ai/dsh-subagent' -import type { SubagentRunEndInfo, SubagentRunInfo } from '@deepseek-ai/dsh-subagent' +import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' @@ -68,15 +68,6 @@ function subagentParentOf(carrier: Scoped): Agent { return carrierKeyOf(carrier) as Agent } -/** Whether the live id names a local child related to this exact delegating parent. */ -function isLocalChild(ctx: Context, id: SessionId, parent: Agent): boolean { - const child = ctx.agents.get(id) - return child !== undefined && ( - ctx.agents.isOwnedBy(id, parent) - || child.session.header.parentSession === parent.session.id - ) -} - /** * The SDK server over a booted harness context. Constructing it subscribes to * session and subagent lifecycle events, forwarding durable session @@ -92,7 +83,6 @@ export class HarnessSdkServer { private llmFiber: { dispose(): Promise } | undefined private readonly sessions = new Map() private readonly sessionCreations = new Map>() - private readonly localRuns = new Map>>() private readonly disposers: (() => void)[] = [] private shutdownTask: Promise> | undefined private shuttingDown = false @@ -116,36 +106,12 @@ export class HarnessSdkServer { childSessionId: String(session.id), }) })) - // In-process providers publish the child before start. Count starts related - // by exact runtime ownership or durable parent lineage so provider-owned - // roots remain local, completions survive child disposal, and reused ids - // need no settlement-order assumption. - const localRuns = this.localRuns - this.disposers.push(ctx.on('subagent/start', function (this: Scoped, info: SubagentRunInfo) { - const parent = subagentParentOf(this) - if (!isLocalChild(ctx, info.id, parent)) return - const providerRuns = localRuns.get(info.provider) ?? new Map>() - const parentRuns = providerRuns.get(info.id) ?? new Map() - parentRuns.set(parent, (parentRuns.get(parent) ?? 0) + 1) - providerRuns.set(info.id, parentRuns) - localRuns.set(info.provider, providerRuns) - })) this.disposers.push(ctx.on('subagent/end', function (this: Scoped, info: SubagentRunEndInfo) { const parent = subagentParentOf(this) - const providerRuns = localRuns.get(info.provider) - const parentRuns = providerRuns?.get(info.id) - const pendingCount = parentRuns?.get(parent) - if (pendingCount !== undefined) { - if (pendingCount === 1) parentRuns?.delete(parent) - else parentRuns?.set(parent, pendingCount - 1) - if (parentRuns?.size === 0) providerRuns?.delete(info.id) - if (providerRuns?.size === 0) localRuns.delete(info.provider) - } - // This protocol reports LOCAL child sessions. A lineage-bearing child - // has the session/created-driven start notification above. A remote run - // has neither a cached local start nor a live child related to this - // parent; an unrelated local agent with the same id never makes it local. - if (pendingCount === undefined && !isLocalChild(ctx, info.id, parent)) return + // This protocol reports only in-process child sessions. The service + // snapshots the provider's exact run provenance through child disposal; + // matching ids or parent lineage alone never establishes locality. + if (!info.local) return transport.notify('subagent.finished', { provider: info.provider, agentId: String(info.id), @@ -223,7 +189,6 @@ export class HarnessSdkServer { this.sessionCreations.clear() const records = [...this.sessions.values()] this.sessions.clear() - this.localRuns.clear() const failures: unknown[] = [] while (this.disposers.length > 0) { try { diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts index ed4dfbb9db..54727f571b 100644 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -71,6 +71,7 @@ try { start() { return Promise.resolve({ id: child.agent.id, + localAgent: child.agent, result: result.promise, dispose() { return Promise.resolve(); }, }); diff --git a/packages/ui/jsonrpc/tests/server.spec.ts b/packages/ui/jsonrpc/tests/server.spec.ts index 3fe73c904e..5b9259dd85 100644 --- a/packages/ui/jsonrpc/tests/server.spec.ts +++ b/packages/ui/jsonrpc/tests/server.spec.ts @@ -70,7 +70,7 @@ async function makeHarness(storageDir: string) { async function settleSubagent( ctx: Context, parent: Agent, - info: SubagentRunEndInfo, + info: Omit & { localAgent: Agent | undefined }, beforeSettle?: () => Promise, ): Promise { const result = Promise.withResolvers() @@ -81,6 +81,7 @@ async function settleSubagent( async start() { return { id: info.id, + localAgent: info.localAgent, result: result.promise, dispose: () => Promise.resolve(), } @@ -294,12 +295,14 @@ describe('HarnessSdkServer', () => { await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('child-session'), + localAgent: handle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'child done' }], }, () => handle.dispose()) await settleSubagent(ctx, parentHandle.agent, { provider: 'spawn', id: SessionId('parentless-child-session'), + localAgent: parentlessHandle.agent, stopReason: 'error', }, () => parentlessHandle.dispose()) @@ -335,7 +338,7 @@ describe('HarnessSdkServer', () => { } }) - it('ignores a remote run id that collides with an unrelated local agent', async () => { + it('ignores a remote run id that collides with a local child of the same parent', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-remote-collision-')) const ctx = await makeHarness(storageDir) try { @@ -346,24 +349,26 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir }, agentOptions: { model: 'deepseek' }, }) - const unrelatedHandle = await ctx.agents.create({ + const collidingChild = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('remote-run-id'), - meta: { cwd: storageDir }, + meta: { cwd: storageDir, parentSession: SessionId('collision-parent') }, agentOptions: { model: 'deepseek' }, }) await settleSubagent(ctx, parentHandle.agent, { provider: 'remote', id: SessionId('remote-run-id'), + localAgent: undefined, stopReason: 'completed', lastAssistantMessage: [], - }, () => unrelatedHandle.dispose()) + }) expect(transport.notifications.some(notification => notification.method === 'subagent.finished' && notification.params?.agentId === 'remote-run-id', )).toBe(false) + await collidingChild.dispose() await parentHandle.dispose() await server.shutdown() } finally { @@ -392,12 +397,14 @@ describe('HarnessSdkServer', () => { await settleSubagent(ctx, parentHandle.agent, { provider: 'continuation', id: SessionId('continuation-child'), + localAgent: childHandle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'first' }], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'continuation', id: SessionId('continuation-child'), + localAgent: childHandle.agent, stopReason: 'completed', lastAssistantMessage: [{ type: 'text', text: 'second' }], }, () => childHandle.dispose()) @@ -436,6 +443,7 @@ describe('HarnessSdkServer', () => { const replacement = Promise.withResolvers() const results = [first.promise, sameLifetime.promise, replacement.promise] let starts = 0 + let currentLocalAgent = oldChild.agent const disposeProvider = ctx.subagents.registerProvider({ name: 'reused', capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, @@ -444,7 +452,7 @@ describe('HarnessSdkServer', () => { const result = results[starts] starts += 1 if (result === undefined) throw new Error('unexpected fourth reused-id run') - return Promise.resolve({ id: SessionId('reused-child'), result, dispose: () => Promise.resolve() }) + return Promise.resolve({ id: SessionId('reused-child'), localAgent: currentLocalAgent, result, dispose: () => Promise.resolve() }) }, }) @@ -471,6 +479,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('new-parent') }, agentOptions: { model: 'deepseek' }, }) + currentLocalAgent = newChild.agent const secondRun = await ctx.subagents.start('reused', { parent: newParent.agent, prompt: [], @@ -512,7 +521,99 @@ describe('HarnessSdkServer', () => { } }) - it('falls back to live lineage and ignores runs without a local child session', async () => { + it('keeps locality bound to the accepted run across provider re-registration', async () => { + const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-provider-reuse-')) + const ctx = await makeHarness(storageDir) + try { + const transport = new FakeTransport() + const server = new HarnessSdkServer(ctx, transport) + const parent = await ctx.agents.create({ + sessionId: SessionId('provider-reuse-parent'), + meta: { cwd: storageDir }, + agentOptions: { model: 'deepseek' }, + }) + const child = await parent.agent.ctx.agents.create({ + sessionId: SessionId('provider-reuse-child'), + meta: { cwd: storageDir, parentSession: SessionId('provider-reuse-parent') }, + agentOptions: { model: 'deepseek' }, + }) + const localResult = Promise.withResolvers() + const remoteResult = Promise.withResolvers() + const unregisterLocal = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: child.agent, + result: localResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const localRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + unregisterLocal() + + const unregisterRemote = ctx.subagents.registerProvider({ + name: 'reused-provider', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.resolve({ + id: SessionId('provider-reuse-child'), + localAgent: undefined, + result: remoteResult.promise, + dispose: () => Promise.resolve(), + }), + }) + const remoteRun = await ctx.subagents.start('reused-provider', { + parent: parent.agent, + prompt: [], + signal: new AbortController().signal, + }) + + remoteResult.resolve({ output: [{ type: 'text', text: 'remote' }], stopReason: 'completed' }) + await remoteRun.result + await Promise.resolve() + expect(transport.notifications.some(notification => + notification.method === 'subagent.finished' + && notification.params?.lastAssistantMessage !== undefined, + )).toBe(false) + + await child.dispose() + localResult.resolve({ output: [{ type: 'text', text: 'local' }], stopReason: 'completed' }) + await localRun.result + await Promise.resolve() + expect(transport.notifications.filter(notification => + notification.method === 'subagent.finished' + && notification.params?.childSessionId === 'provider-reuse-child', + )).toEqual([{ + method: 'subagent.finished', + params: { + provider: 'reused-provider', + agentId: 'provider-reuse-child', + parentSessionId: 'provider-reuse-parent', + childSessionId: 'provider-reuse-child', + status: 'ok', + stopReason: 'completed', + lastAssistantMessage: [{ type: 'text', text: 'local' }], + }, + }]) + + await localRun.dispose() + await remoteRun.dispose() + unregisterRemote() + await parent.dispose() + await server.shutdown() + } finally { + await ctx.fiber.dispose() + await rm(storageDir, { recursive: true, force: true }) + } + }) + + it('uses explicit local provenance when start was missed and ignores remote runs', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-subagent-fallback-')) const ctx = await makeHarness(storageDir) let parentHandle: AgentHandle | undefined @@ -529,6 +630,7 @@ describe('HarnessSdkServer', () => { meta: { cwd: storageDir, parentSession: SessionId('fallback-parent') }, agentOptions: { provider: 'deepseek', model: 'deepseek' }, }) + const fallbackChild = handle.agent failedHandle = await parentHandle.agent.ctx.agents.create({ sessionId: SessionId('failed-child-session'), meta: { cwd: storageDir }, @@ -541,12 +643,13 @@ describe('HarnessSdkServer', () => { inheritsParentContext: true, start: () => Promise.resolve({ id: SessionId('fallback-child-session'), + localAgent: fallbackChild, result: missedStartResult.promise, dispose: () => Promise.resolve(), }), }) - // Start before the server subscribes, so the terminal fallback must use - // the still-live registry entry rather than a cached start record. + // Start before the server subscribes. The terminal payload still carries + // this run's exact local child without reconstructing it from ids. const missedStartRun = await ctx.subagents.start('fork', { parent: parentHandle.agent, prompt: [], @@ -560,22 +663,25 @@ describe('HarnessSdkServer', () => { await Promise.resolve() await missedStartRun.dispose() disposeMissedStartProvider() - // The server also missed this agent's creation, but observes the start; - // recover its lineage from the still-live registry entry. + // The server also missed this agent's creation but sees the exact child + // on the run lifecycle payload. await settleSubagent(ctx, parentHandle.agent, { provider: 'fork-live-fallback', id: SessionId('fallback-child-session'), + localAgent: fallbackChild, stopReason: 'completed', lastAssistantMessage: [], }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: SessionId('failed-child-session'), + localAgent: failedHandle.agent, stopReason: 'error', }) await settleSubagent(ctx, parentHandle.agent, { provider: 'fork', id: SessionId('missing-child-agent'), + localAgent: undefined, stopReason: 'error', }) @@ -800,6 +906,6 @@ describe('HarnessSdkServer', () => { const server = new HarnessSdkServer(ctx, new FakeTransport()) await expect(server.shutdown()).rejects.toBe(listenerFailure) - expect(on).toHaveBeenCalledTimes(4) + expect(on).toHaveBeenCalledTimes(3) }) }) diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index 5905161e6a..2f52cd7b6b 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -105,6 +105,7 @@ class StubProvider implements SubagentProvider { if (request.signal.aborted) throw new Error('child start aborted before publication') return { id: SessionId(`stub-child-${index}`), + localAgent: undefined, result: terminal.promise, dispose: () => { controlled.disposeCalls += 1 @@ -362,6 +363,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('reject-child'), + localAgent: undefined, result: Promise.reject(new Error('backend exploded')), dispose: () => Promise.resolve(), }), @@ -396,6 +398,7 @@ describe('dsh-workflow-workerthread', () => { } as unknown as SubagentResult const start = vi.spyOn(ctx.subagents, 'start').mockResolvedValue({ id: SessionId('raw-invalid-child'), + localAgent: undefined, result: Promise.resolve(invalid), dispose: () => Promise.resolve(), }) @@ -419,6 +422,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('bad-dispose-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, dispose: () => { throw new Error('dispose exploded') }, @@ -440,6 +444,7 @@ describe('dsh-workflow-workerthread', () => { inheritsParentContext: false, start: async () => ({ id: SessionId('trap-child'), + localAgent: undefined, result: Promise.resolve({ output: [{ type: 'text', text: 'fine' }], stopReason: 'completed' }), cancel: () => { /* settled already */ }, // The rejection VALUE's own coercion throws: a warn built with bare @@ -767,6 +772,7 @@ describe('dsh-workflow-workerthread', () => { }, { once: true }) return { id: SessionId('signal-only-child'), + localAgent: undefined, result, dispose: () => Promise.resolve(), } @@ -1088,6 +1094,7 @@ describe('dsh-workflow-workerthread', () => { ready.resolve({ id: SessionId('late-ready-child'), + localAgent: undefined, result: Promise.resolve({ output: [], stopReason: 'aborted' }), dispose: () => { disposeCalls += 1 @@ -1124,6 +1131,7 @@ describe('dsh-workflow-workerthread', () => { }, { once: true }) return { id: SessionId('doomed-child'), + localAgent: undefined, result: new Promise(() => { /* never settles; the reap is the teardown */ }), dispose: () => Promise.reject(new Error('dispose exploded during reap')), } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d7b3ab2b95..47d998fedc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1441,6 +1441,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index aafaf28484..1301a6a508 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -383,7 +383,7 @@ A ready child settled. Scope-filtered dispatch uses the same delegating parent c - `info` — the run identity and terminal outcome. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L109) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L112) ### subagent/provider-added @@ -397,7 +397,7 @@ A provider became resolvable in the registry. - `provider` — the registered provider. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L83) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L86) ### subagent/provider-removed @@ -411,7 +411,7 @@ A provider left the registry. Accepted runs remain holder-owned. - `name` — the provider name that no longer resolves. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L89) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L92) ### subagent/start @@ -425,7 +425,7 @@ A provider established a ready child. For in-process providers, `ctx.agents.get( - `info` — the provider and ready child identity. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L100) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L103) ## system-prompt/* diff --git a/website/zh-CN/api/harness/subagents.md b/website/zh-CN/api/harness/subagents.md index 54fe5aa76e..52632e820d 100644 --- a/website/zh-CN/api/harness/subagents.md +++ b/website/zh-CN/api/harness/subagents.md @@ -6,7 +6,7 @@ Named provider registry and capability-checked start surface. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L153) ### ctx.subagents.registerProvider(provider) @@ -20,7 +20,7 @@ Register a provider under its name. Registration is effect-scoped and HMR safe; **Returns** the exact Cordis effect disposer. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L156) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L167) ### ctx.subagents.getProvider(name) @@ -34,7 +34,7 @@ Look up a provider by name. **Returns** the provider, or undefined when absent. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L179) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L190) ### ctx.subagents.list() @@ -46,7 +46,7 @@ List registered provider names in insertion order. **Returns** the registered names. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L187) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L198) ### ctx.subagents.start(name, request) @@ -61,4 +61,4 @@ Establish a ready child on the named provider. Capability and semantic checks ru **Returns** the ready holder-owned run. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L200) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/subagent/subagent/src/index.ts#L211) From 1febeb8428d1c1dd4d24f812567b792d3d3f79ea Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:26:48 +0800 Subject: [PATCH 137/163] test(jsonrpc): configure built scope probe --- packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts index 54727f571b..051b0eef20 100644 --- a/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts +++ b/packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts @@ -43,7 +43,7 @@ const [ const storageRoot = await mkdtemp(join(tmpdir(), "jsonrpc-built-scope-")); const ctx = new Context(); try { - await ctx.plugin(agentCore); + await ctx.plugin(agentCore, { workspaceContext: false }); await ctx.plugin(SubagentService); await ctx.plugin(SessionPersistenceJsonl, { root: storageRoot }); await new Promise((ready) => setTimeout(ready, 50)); From 546143048c011b30380fc135efbbde12afc8b465 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:29:29 +0800 Subject: [PATCH 138/163] docs(api): refresh llm reference --- website/zh-CN/api/harness/llm.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/website/zh-CN/api/harness/llm.md b/website/zh-CN/api/harness/llm.md index 200e926d15..a70d5bd478 100644 --- a/website/zh-CN/api/harness/llm.md +++ b/website/zh-CN/api/harness/llm.md @@ -6,7 +6,7 @@ The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L96) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L94) ### ctx.llm.registerAdapter(providers, adapter) @@ -21,7 +21,7 @@ Register an adapter for the given provider routes. Throws `LlmError` with code ` **Returns** the disposer that unregisters all of them. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L111) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L109) ### ctx.llm.listProviders() @@ -33,7 +33,7 @@ Describe provider routes with a registered adapter. **Returns** detached provider metadata in registration order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L142) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L140) ### ctx.llm.listModels(provider) @@ -47,7 +47,7 @@ Discover models advertised by one registered provider. Catalog membership is adv **Returns** detached model metadata in adapter-preferred order. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L152) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L150) ### ctx.llm.stream(options) @@ -61,4 +61,4 @@ Stream one model call as raw chunks (token-level deltas). Throws `LlmError` with **Returns** the chunk stream, possibly wrapped by `llm/stream` listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L210) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/llm/llm/src/index.ts#L208) From 8e366c3071f88d4c66c0bfae0fcbea45bd61b78f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:31:04 +0800 Subject: [PATCH 139/163] test(tui): snapshot semantic terminal state --- docs/rfc/INDEX.md | 1 + ...-18-tui-terminal-state-snapshots.i18n.yaml | 6 + ...2026-07-18-tui-terminal-state-snapshots.md | 57 +++ ...6-07-18-tui-terminal-state-snapshots.zh.md | 57 +++ docs/testing.md | 4 +- .../tests/fixtures/tui-scripted-llm.ts | 60 +++ .../tests/fixtures/tui-scripted.cordis.yml | 27 + .../tests/tui-keyless-smoke.e2e.ts | 87 +++- knip.json | 5 + packages/ui/tui/package.json | 5 + packages/ui/tui/tests/harness.ts | 130 +++++ packages/ui/tui/tests/headless-terminal.ts | 318 ++++++++++++ .../advanced-cards-collapsed.golden.txt | 107 ++++ .../advanced-cards-expanded.golden.txt | 127 +++++ .../snapshots/code-mode-complete.golden.txt | 50 ++ .../snapshots/code-mode-pending.golden.txt | 52 ++ .../conversation-complete.golden.txt | 75 +++ .../snapshots/conversation-replay.golden.txt | 73 +++ .../conversation-streaming.golden.txt | 75 +++ .../cordis-tools-complete.golden.txt | 73 +++ .../snapshots/cordis-tools-pending.golden.txt | 59 +++ .../snapshots/disposed-terminal.golden.txt | 52 ++ .../dynamic-workflow-complete.golden.txt | 53 ++ .../dynamic-workflow-pending.golden.txt | 55 ++ .../snapshots/errors-and-help.golden.txt | 52 ++ .../question-dialog-validation.golden.txt | 69 +++ .../snapshots/question-dialog.golden.txt | 67 +++ ...surface-after-compaction-narrow.golden.txt | 45 ++ .../surface-after-compaction-wide.golden.txt | 37 ++ .../surface-before-compaction.golden.txt | 67 +++ packages/ui/tui/tests/tui.snapshot.ts | 472 ++++++++++++++++++ packages/ui/tui/tests/tui.spec.ts | 101 +--- pnpm-lock.yaml | 20 + vitest.snapshot.config.ts | 6 +- 34 files changed, 2437 insertions(+), 107 deletions(-) create mode 100644 docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml create mode 100644 docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md create mode 100644 docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md create mode 100644 examples/coding-agent/tests/fixtures/tui-scripted-llm.ts create mode 100644 examples/coding-agent/tests/fixtures/tui-scripted.cordis.yml create mode 100644 packages/ui/tui/tests/harness.ts create mode 100644 packages/ui/tui/tests/headless-terminal.ts create mode 100644 packages/ui/tui/tests/snapshots/advanced-cards-collapsed.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/advanced-cards-expanded.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/code-mode-pending.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/conversation-complete.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/conversation-replay.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/cordis-tools-pending.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/dynamic-workflow-pending.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/errors-and-help.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/question-dialog-validation.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/question-dialog.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/surface-after-compaction-wide.golden.txt create mode 100644 packages/ui/tui/tests/snapshots/surface-before-compaction.golden.txt create mode 100644 packages/ui/tui/tests/tui.snapshot.ts diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ecb66326b3..434df9f305 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -209,6 +209,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 | | [Pin request-header content in one snapshot scenario](implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md) | 2026-07-06 | | [Extract the ACP snapshot suite into a support package](implemented/testing/2026-07-08-shared-acp-snapshot-package.md) | 2026-07-08 | +| [Snapshot semantic terminal state for the TUI](implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) | 2026-07-18 | ## Rejected diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml new file mode 100644 index 0000000000..5b682b642b --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-18-tui-terminal-state-snapshots.md: 225efe8de95973ecae3f9bf73308e7ae879dddbb +2026-07-18-tui-terminal-state-snapshots.zh.md: b7399a31b0abf41905f495a1ea3b4ecd0c858526 diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md new file mode 100644 index 0000000000..225efe8de9 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -0,0 +1,57 @@ +# RFC: Snapshot semantic terminal state for the TUI + +Status: implemented + +English | [中文](2026-07-18-tui-terminal-state-snapshots.zh.md) + +## Problem + +The TUI is a stateful renderer. Its user-visible result depends on ANSI parsing, differential frames, wrapping, scrollback, viewport position, terminal width, focus, cursor state, and each tool's presentation intent. Unit tests that collect `Terminal.write()` fragments can prove event handling, but they cannot prove the final screen a terminal displays. The same screen may also be emitted through different write fragments, so pinning those fragments creates false regressions. + +Component-line snapshots stop before ANSI reaches a terminal and miss cursor movement, clearing, styling, overlay composition, and reflow. Raster screenshots include font and platform rendering noise that is unrelated to the TUI contract. The TUI therefore needs a deterministic, reviewable representation of terminal state plus a smaller test at the real process and PTY boundary. + +## Decision + +TUI coverage has three complementary layers: + +1. `tui.spec.ts` tests event mapping, input routing, disposal, and error behavior directly. +2. `tui.snapshot.ts` mounts the production TUI against a headless terminal emulator and compares semantic terminal-state goldens. +3. `tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a complete scripted conversation through streaming and `ask_user_question`, exits through `/exit`, and verifies terminal teardown. The production coding-agent configuration also retains its banner/exit and startup-failure PTY cases. + +The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. A snapshot waits for pi-tui's synchronized-output end marker before reading state. This makes a checkpoint represent a completed frame rather than a timer-dependent write prefix. + +Each golden projects terminal state into text: dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes. + +Every checkpoint also enforces theme independence across the complete terminal state: no RGB colors, no palette entries beyond ANSI 0–15, and no explicit background colors. Reverse video remains valid for selection because it uses terminal defaults. The suite owns a closed checkpoint list: its type rejects undeclared names, and its inventory checks reject missing checkpoints and orphaned `.golden.txt` files. + +### Required scenario matrix + +| Area | Representative checkpoints | Contract pinned | +|---|---|---| +| Conversation | replay, streaming, completion | Resumed Markdown and reasoning, live deltas, plans, token usage, and max-token completion | +| Code Mode | pending and completed `run_code` | The production Code Mode registry and presenter, source program, captured logs, and result | +| Dynamic workflows | pending and completed `workflow` | The production workflow presenter, metadata, phases, parallel agents, script, and structured result | +| Cordis tools | pending and completed inspect/mount/unmount | The production `cordis_inspect`, `cordis_mount`, and `cordis_unmount` presenters and lifecycle results | +| Advanced tool cards | collapsed and expanded | Terminal, diff, generic, subagent, background-task, and skill card shapes plus output truncation | +| Interaction | question and validation | Constrained multi-select overlay composition, focus, scrolling, selection, and validation errors | +| Surface and layout | before compaction, narrow replacement, wide replacement | Surface replacement removes retired content; resize reflows the surviving surface without resurrection | +| Failure and shutdown | errors/help and disposed terminal | Help and unknown commands, live/turn error de-duplication, interruption, cursor restoration, and terminal stop | + +The explicitly model-facing advanced cases use the real `ToolRegistry` configuration and the production Code Mode, workflow, and Cordis tool presenters. Synthetic presenter fixtures are limited to the generic card-shape matrix, where the TUI's input contract is the presenter view itself. Session events remain the driver so replay, streaming, result arrival, surface replacement, and lifecycle ordering exercise the same projection path as production. + +The TUI suite is included by `vitest.snapshot.config.ts`, so `pnpm run test:snapshot` compares it keylessly. `pnpm run test:snapshot:refresh` rewrites its derived terminal goldens without contacting a model; `test:snapshot:record` remains meaningful for suites whose transcript source requires recording. Both refresh paths still compare the resulting files in the same run. + +## Alternatives considered + +- **Snapshot raw terminal writes** — rejected because differential rendering may change write boundaries without changing the screen, while cursor and clear sequences are unreadable in review. +- **Snapshot component render lines before terminal output** — rejected because it does not test ANSI parsing, cursor movement, overlays, viewport behavior, or the interaction between independent components in one frame. +- **Commit raster screenshots** — rejected because fonts, glyph metrics, antialiasing, and host terminal themes make them platform-sensitive and make semantic style changes difficult to review. +- **Use only PTY end-to-end tests** — rejected because raw PTY output is a stream of historical drawing operations, not queryable final state. PTY tests retain the real Loader/input/teardown boundary, while the emulator owns broad state coverage. +- **Copy pi-tui's unpublished virtual-terminal test helper** — rejected because the installed package does not export that helper. A small adapter around the public `@xterm/headless` API keeps the dependency explicit and the projection owned by this package. + +## Consequences + +- TUI visual regressions produce readable cell-and-style diffs, and the required matrix makes advanced features first-class rather than incidental coverage. +- The test dependency is pinned to the xterm version used by pi-tui. The adapter uses xterm's proposed buffer API, so an xterm upgrade requires rerunning and reviewing the semantic projection. +- The emulator models ANSI terminal state but cannot prove behavior unique to every terminal implementation. The real PTY conversation covers process selection, keyboard input, user interaction, and teardown without duplicating the full matrix. +- Goldens deliberately encode wrapping and viewport behavior at fixed sizes. Intentional layout changes update them through the keyless refresh command and receive ordinary snapshot review. diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md new file mode 100644 index 0000000000..b7399a31b0 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -0,0 +1,57 @@ +# RFC: TUI 语义终端状态快照 + +Status: implemented + +[English](2026-07-18-tui-terminal-state-snapshots.md) | 中文 + +## 问题 + +TUI 是有状态的渲染器。用户最终看到的结果取决于 ANSI 解析、差分帧、换行、回滚缓冲、视口位置、终端宽度、焦点、光标状态,以及各工具的呈现意图。收集 `Terminal.write()` 片段的单元测试可以验证事件处理,却无法验证终端最终显示的画面。同一画面也可能由不同的写入片段产生,因此固定这些片段会制造误报。 + +组件行快照止于 ANSI 进入终端之前,无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。因此,TUI 既需要一种确定、便于评审的终端状态表示,也需要一项范围更小、覆盖真实进程与 PTY 边界的测试。 + +## 决策 + +TUI 覆盖分为三个互补层次: + +1. `tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。 +2. `tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器,并比较语义终端状态金标。 +3. `tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段完整的脚本化会话,使其依次经过流式输出和 `ask_user_question`,再通过 `/exit` 退出并验证终端清理。生产 coding-agent 配置还保留欢迎信息与退出,以及启动失败两类 PTY 场景。 + +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。快照会等待 pi-tui 的同步输出结束标记,再读取状态。因此,每个检查点表示已经完成的帧,而不是依赖计时的写入前缀。 + +每份金标把终端状态投影为文本:尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记,以及非默认样式区间。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 + +每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。测试套件拥有封闭的检查点清单:类型会拒绝未声明的名称,清单检查会拒绝缺失的检查点和遗留的 `.golden.txt` 文件。 + +### 必需场景矩阵 + +| 范围 | 代表性检查点 | 固定的契约 | +|---|---|---| +| 会话 | 回放、流式输出、完成 | 恢复后的 Markdown 与推理、实时增量、计划、token 用量,以及达到 token 上限时的完成状态 | +| Code Mode | `run_code` 待完成与已完成 | 生产 Code Mode 注册表与呈现器、源程序、捕获日志和结果 | +| 动态工作流 | `workflow` 待完成与已完成 | 生产工作流呈现器、元数据、阶段、并行 agent、脚本和结构化结果 | +| Cordis 工具 | inspect/mount/unmount 待完成与已完成 | 生产 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount` 呈现器及其生命周期结果 | +| 高级工具卡片 | 折叠与展开 | 终端、diff、通用、subagent、后台任务和 skill 卡片形态,以及输出截断 | +| 交互 | 问题与校验 | 受限多选浮层的组合、焦点、滚动、选择和校验错误 | +| 表层与布局 | 压缩前、窄幅替换、宽幅替换 | 表层替换会移除退役内容;调整尺寸只会重排保留的表层,不会让旧内容重新出现 | +| 失败与关闭 | 错误与帮助、终端已释放 | 帮助与未知命令、实时错误和轮次错误去重、中断、光标恢复及终端停止 | + +面向模型的高级场景明确使用真实 `ToolRegistry` 配置,以及生产 Code Mode、工作流和 Cordis 工具呈现器。只有通用卡片形态矩阵使用合成呈现器 fixture;在这里,呈现器视图本身就是 TUI 的输入契约。测试仍由会话事件驱动,因此回放、流式输出、结果到达、表层替换和生命周期顺序都会经过与生产环境相同的投影路径。 + +`vitest.snapshot.config.ts` 会包含 TUI 测试套件,因此 `pnpm run test:snapshot` 可以无密钥比较快照。`pnpm run test:snapshot:refresh` 会重写从终端状态派生的金标,而不会联系模型;对于 transcript(文本记录)来源需要录制的测试套件,`test:snapshot:record` 仍有其原有含义。两条刷新路径都会在同一次运行中继续比较生成后的文件。 + +## 曾考虑的替代方案 + +- **快照原始终端写入**:不予采纳,因为差分渲染可能在画面不变时改变写入边界,而且光标与清屏序列难以评审。 +- **快照进入终端输出之前的组件渲染行**:不予采纳,因为它无法测试 ANSI 解析、光标移动、浮层、视口行为,也无法测试独立组件在同一帧中的相互作用。 +- **提交栅格截图**:不予采纳,因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台,也会增加语义样式变更的评审难度。 +- **只使用 PTY 端到端测试**:不予采纳,因为原始 PTY 输出是一系列历史绘制操作,而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界,模拟器负责广泛的状态覆盖。 +- **复制 pi-tui 未发布的虚拟终端测试 helper**:不予采纳,因为已安装的包并未导出该 helper。围绕公开 `@xterm/headless` API 编写小型适配器,可以显式声明依赖,并让本包拥有状态投影。 + +## 后果 + +- TUI 视觉回归会产生便于阅读的单元格和样式 diff;必需场景矩阵也让高级功能成为一等测试对象,而不是偶然覆盖。 +- 测试依赖固定到 pi-tui 使用的 xterm 版本。适配器使用 xterm 的拟议缓冲区 API,因此升级 xterm 时必须重新运行并评审语义投影。 +- 模拟器可以建模 ANSI 终端状态,但无法证明每种终端实现独有的行为。真实 PTY 会话覆盖进程选择、键盘输入、用户交互和清理,无需复制完整矩阵。 +- 金标有意固定指定尺寸下的换行与视口行为。布局的预期变更通过无密钥刷新命令更新,并接受常规快照评审。 diff --git a/docs/testing.md b/docs/testing.md index 8d4f0f554f..5f10e1741c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): boots the real example subprocess, replays a recorded session keyless, diffs normalized stdout + the re-persisted log against committed goldens ([snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). Use `pnpm run test:snapshot:record` when the model transcript should change; use `pnpm run test:snapshot:refresh` when the committed transcript is still the right mock LLM input and replay goldens need keyless rewrite. Review the golden diff. System-prompt/tool-schema content is pinned by ONE scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized stdout plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the TUI suite parses real ANSI output into semantic terminal-state goldens and retains a real PTY conversation at the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when the committed transcript or scripted events remain correct; review every golden diff. System-prompt/tool-schema content is pinned by ONE ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here @@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Any change affecting the editor-facing transcript or end-to-end agent UX — the ACP bridge, the loop's observable output, tool presentation — adds or updates a scenario in the owning example's snapshot suite (`examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory; `examples/acp-agent` is the primary suite), or states in the PR why none applies. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. +Any change affecting an editor-facing transcript or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); interactive-terminal presentation uses the semantic TUI matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. diff --git a/examples/coding-agent/tests/fixtures/tui-scripted-llm.ts b/examples/coding-agent/tests/fixtures/tui-scripted-llm.ts new file mode 100644 index 0000000000..2147dfb222 --- /dev/null +++ b/examples/coding-agent/tests/fixtures/tui-scripted-llm.ts @@ -0,0 +1,60 @@ +import type { Context } from 'cordis' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' +import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' + +const INITIAL_TEXT = 'I need one decision before I continue.' +const FINAL_TEXT = 'Decision received. Scripted TUI run complete.' + +function textChunks(text: string): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'text' }, + ...Array.from(text, (char): StreamChunk => ({ type: 'text-delta', index: 0, text: char })), + { type: 'block-end', index: 0, block: { type: 'text', text } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: text.length } }, + { type: 'finish', reason: { kind: 'stop' } }, + ] +} + +/** Keyless two-step adapter for the real-PTY TUI conversation test. */ +class ScriptedTuiAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const hasToolResult = options.messages.at(-1)?.content.some(block => block.type === 'tool-result') ?? false + if (hasToolResult) { + for (const chunk of textChunks(FINAL_TEXT)) yield chunk + return + } + + const args = JSON.stringify({ + questions: [{ + id: 'mode', + header: 'Execution mode', + question: 'How should the scripted run proceed?', + options: [ + { label: 'Safe', description: 'Use the guarded path.' }, + { label: 'Fast', description: 'Use the shorter path.' }, + ], + }], + }) + const callId = CallId('call-ask-mode') + yield { type: 'block-start', index: 0, blockType: 'text' } + for (const char of INITIAL_TEXT) yield { type: 'text-delta', index: 0, text: char } + yield { type: 'block-end', index: 0, block: { type: 'text', text: INITIAL_TEXT } } + yield { type: 'block-start', index: 1, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 1, id: callId, name: 'ask_user_question', argumentsDelta: args } + yield { + type: 'block-end', + index: 1, + block: { type: 'tool-call', id: callId, name: 'ask_user_question', arguments: args }, + } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + } +} + +export const name = 'tui-scripted-llm' +export const inject = ['llm'] + +/** Register the network-free adapter used by the PTY fixture. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tui-scripted'], new ScriptedTuiAdapter()) +} diff --git a/examples/coding-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/coding-agent/tests/fixtures/tui-scripted.cordis.yml new file mode 100644 index 0000000000..e40405524e --- /dev/null +++ b/examples/coding-agent/tests/fixtures/tui-scripted.cordis.yml @@ -0,0 +1,27 @@ +# Real Loader composition for the keyless conversational PTY test. The app +# bundle supplies the production agent/TUI/user-question stack; only the model +# is scripted so the terminal interaction is deterministic and network-free. +- id: scripted-llm + name: './tui-scripted-llm.ts' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + +- id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: tui-scripted + model: tui-scripted-model + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + welcome: 'scripted TUI ready.' + ui: + mode: tui + tui: + showReasoning: true diff --git a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts index f5f9c5db36..cc2397c069 100644 --- a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts @@ -4,31 +4,32 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/src/bin.ts', import.meta.url)) const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const PTY_DRIVER = String.raw` -import errno, os, pty, select, signal, sys, time -node, tsx_loader, bin_script, config_path, tsconfig_path, cwd, resume_session_id = sys.argv[1:] +import errno, json, os, pty, select, signal, sys, time +node, launch_args_json, launch_env_json, cwd, resume_session_id, scenario = sys.argv[1:] env = os.environ.copy() +env.update(json.loads(launch_env_json)) env.update({ - "DEEPSEEK_API_KEY": "keyless-tui-no-call", - "DSH_HOME": os.path.join(cwd, ".dsh"), - "DSH_AGENTS_HOME": os.path.join(cwd, ".agents"), - "TSX_TSCONFIG_PATH": tsconfig_path, + "COLUMNS": "100", + "LINES": "30", }) if resume_session_id: env["RESUME_SESSION_ID"] = resume_session_id pid, fd = pty.fork() if pid == 0: os.chdir(cwd) - os.execvpe(node, [node, "--expose-internals", "--import", tsx_loader, bin_script, config_path], env) + os.execvpe(node, [node, *json.loads(launch_args_json)], env) output = bytearray() +answered_question = False +sent_prompt = False sent_exit = False deadline = time.monotonic() + 25 status = None @@ -43,7 +44,16 @@ while time.monotonic() < deadline: chunk = b"" if chunk: output.extend(chunk) - if not sent_exit and b"agent REPL ready." in output: + if scenario == "conversation" and not sent_prompt and b"scripted TUI ready." in output: + os.write(fd, b"exercise the TUI\r") + sent_prompt = True + if scenario == "conversation" and sent_prompt and not answered_question and b"How should the scripted run proceed?" in output: + os.write(fd, b"\r") + answered_question = True + if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: + os.write(fd, b"/exit\r") + sent_exit = True + if scenario == "boot" and not sent_exit and b"agent REPL ready." in output: os.write(fd, b"/exit\r") sent_exit = True waited, candidate = os.waitpid(pid, os.WNOHANG) @@ -55,13 +65,26 @@ if status is None: os.kill(pid, signal.SIGKILL) _, status = os.waitpid(pid, 0) sys.stdout.buffer.write(output) -if resume_session_id: +if scenario == "resume-failure": if b'ui-tui: agent "main" failed to start:' not in output: sys.stderr.write("TUI did not render the startup failure before timeout\n") sys.exit(126) if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 1: sys.stderr.write("TUI startup failure did not exit with status 1\n") sys.exit(127) +elif scenario == "conversation": + if not sent_prompt: + sys.stderr.write("TUI did not render the scripted welcome marker before timeout\n") + sys.exit(128) + if not answered_question: + sys.stderr.write("TUI did not render the user-question dialog before timeout\n") + sys.exit(129) + if not sent_exit: + sys.stderr.write("TUI did not finish the scripted tool round-trip before timeout\n") + sys.exit(130) + if not os.WIFEXITED(status) or os.WEXITSTATUS(status) != 0: + sys.stderr.write("TUI scripted conversation did not exit cleanly\n") + sys.exit(131) else: if not sent_exit: sys.stderr.write("TUI did not render its welcome marker before timeout\n") @@ -71,20 +94,36 @@ else: sys.exit(125) ` -async function runTuiLoaderSmoke(resumeSessionId = ''): Promise { +interface TuiLoaderSmokeOptions { + config?: string + resumeSessionId?: string + scenario?: 'boot' | 'conversation' | 'resume-failure' +} + +async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-')) try { + const launch = resolveExampleLaunch({ + srcBin: binScript, + configArgs: [options.config ?? configPath], + tsconfigPath, + exposeInternals: true, + env: { + DEEPSEEK_API_KEY: 'keyless-tui-no-call', + DSH_HOME: join(cwd, '.dsh'), + DSH_AGENTS_HOME: join(cwd, '.agents'), + }, + }) return await new Promise((resolve, reject) => { const child = spawn('python3', [ '-c', PTY_DRIVER, - process.execPath, - tsxLoader, - binScript, - configPath, - tsconfigPath, + launch.command, + JSON.stringify(launch.args), + JSON.stringify(launch.env), cwd, - resumeSessionId, + options.resumeSessionId ?? '', + options.scenario ?? 'boot', ], { stdio: ['ignore', 'pipe', 'pipe'] }) let stdout = '' let stderr = '' @@ -108,10 +147,20 @@ describe('coding-agent TUI keyless smoke (real Loader tree in a PTY)', () => { const output = await runTuiLoaderSmoke() expect(output).toContain('DEEPSEEK') expect(output).toContain('agent REPL ready.') + expect(output).toContain('\u001B[?2004l') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { + const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) + expect(output).toContain('I need one decision before I continue.') + expect(output).toContain('How should the scripted run proceed?') + expect(output).toContain('Safe') + expect(output).toContain('Decision received. Scripted TUI run complete.') + expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints a config-resume failure and exits instead of leaving a blank terminal', async () => { - const output = await runTuiLoaderSmoke('missing-session') + const output = await runTuiLoaderSmoke({ resumeSessionId: 'missing-session', scenario: 'resume-failure' }) expect(output).toContain('ui-tui: agent "main" failed to start:') expect(output).toContain('missing-session') }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/knip.json b/knip.json index 16f83b918b..03fae14896 100644 --- a/knip.json +++ b/knip.json @@ -10,6 +10,7 @@ "examples": { "entry": [ "echo-agent/src/*.ts", + "coding-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], @@ -113,6 +114,10 @@ "entry": ["tests/**/*.spec.ts"], "project": ["src/**/*.ts", "tests/**/*.ts"] }, + "packages/ui/tui": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.snapshot.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] + }, "packages/examples/jsonrpc-demo": { "project": ["src/**/*.ts"] }, diff --git a/packages/ui/tui/package.json b/packages/ui/tui/package.json index e960f33e65..a7ed458ca5 100644 --- a/packages/ui/tui/package.json +++ b/packages/ui/tui/package.json @@ -38,8 +38,13 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-cordis": "workspace:^", + "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@xterm/headless": "5.5.0", "cordis": "^4.0.0-rc.6" } } diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts new file mode 100644 index 0000000000..52f4159b72 --- /dev/null +++ b/packages/ui/tui/tests/harness.ts @@ -0,0 +1,130 @@ +import { Context } from 'cordis' +import type { Terminal } from '@earendil-works/pi-tui' +import AgentRegistry, { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import type { ToolDefinition } from '@deepseek-ai/dsh-tools' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import { createTuiChat, type Config } from '../src/index.ts' + +interface FakeAgent extends Agent { + status: AgentStatus + sent: ContentBlock[][] + steered: ContentBlock[][] + cancelled: string[] +} + +export interface TuiHarnessOptions { + status?: AgentStatus + config?: Config + tools?: Record + configureContext?: (ctx: Context) => Promise + beforeMount?: (session: Session) => void + cwd?: string | null +} + +export interface TuiHarness void> { + ctx: Context + session: Session + agent: FakeAgent + terminal: TerminalType + exit: Exit + controller: ReturnType +} + +/** + * Compose the production TUI around an in-memory session and controllable agent. + * @param terminal - Terminal boundary driven by the test. + * @param exit - Process-exit observer. + * @param options - Initial session, agent, tool, and TUI configuration. + * @returns The mounted TUI and every boundary the test may drive or inspect. + */ +export async function createTuiTestHarness void>( + terminal: TerminalType, + exit: Exit, + options: TuiHarnessOptions = {}, +): Promise> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + await ctx.plugin(UserInteractionService) + if (options.configureContext === undefined) { + const tools = options.tools ?? {} + ctx.provide('tools', { + get(name: string) { + return tools[name] + }, + } as never) + } else { + await options.configureContext(ctx) + } + const session = ctx.sessions.create( + SessionId('main-session'), + options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } }, + ) + options.beforeMount?.(session) + const sent: ContentBlock[][] = [] + const steered: ContentBlock[][] = [] + const cancelled: string[] = [] + const agent: FakeAgent = { + id: AgentId('main'), + options: { model: 'deepseek-v4-flash' }, + session, + status: options.status ?? 'idle', + ctx, + sent, + steered, + cancelled, + send(content) { + sent.push(content) + }, + steer(content) { + steered.push(content) + }, + inject() {}, + cancel(reason) { + cancelled.push(reason ?? '') + }, + whenIdle() { + return Promise.resolve() + }, + } + ctx.agents.register(agent) + const controller = createTuiChat(ctx, Object.assign({ + welcome: 'Coding agent ready.', + agent: 'main', + color: false, + }, options.config), { terminal, exit }) + return { ctx, session, agent, terminal, exit, controller } +} + +/** Dispose the mounted TUI before its owning Cordis context. */ +export async function disposeTuiTestHarness( + setup: Pick void>, 'controller' | 'ctx'>, +): Promise { + await setup.controller.dispose() + await setup.ctx.fiber.dispose() +} + +/** Append a production-shaped user message to the active session surface. */ +export function appendUser(session: Session, text: string): void { + session.append('user/message', { + content: [{ type: 'text', text }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) +} + +/** Append a production-shaped assistant message to the active session surface. */ +export function appendAssistant( + session: Session, + content: ContentBlock[], + usage?: { inputTokens: number; outputTokens: number }, +): void { + session.append('assistant/message', { + turn: 1, + step: 0, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content, + ...usage === undefined ? {} : { usage }, + }, { surfaceOp: 'append' }) +} diff --git a/packages/ui/tui/tests/headless-terminal.ts b/packages/ui/tui/tests/headless-terminal.ts new file mode 100644 index 0000000000..1155016ab7 --- /dev/null +++ b/packages/ui/tui/tests/headless-terminal.ts @@ -0,0 +1,318 @@ +import type { Terminal } from '@earendil-works/pi-tui' +import { Terminal as XtermTerminal, type IBufferCell } from '@xterm/headless' + +const FRAME_END = '\x1b[?2026l' +const FRAME_TIMEOUT_MS = 2_000 + +const ANSI_COLORS = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'bright-black', + 'bright-red', + 'bright-green', + 'bright-yellow', + 'bright-blue', + 'bright-magenta', + 'bright-cyan', + 'bright-white', +] as const + +interface FrameWaiter { + target: number + resolve: () => void + reject: (error: Error) => void + timer: ReturnType +} + +interface RowSnapshot { + text: string + wrapped: boolean + styles: string[] +} + +export interface TerminalSnapshotOptions { + /** Include the whole active buffer instead of only the visible viewport. */ + includeScrollback?: boolean +} + +function occurrenceCount(value: string, needle: string): number { + let count = 0 + let offset = 0 + while (true) { + const match = value.indexOf(needle, offset) + if (match < 0) return count + count += 1 + offset = match + needle.length + } +} + +function colorLabel(cell: IBufferCell, kind: 'fg' | 'bg'): string | undefined { + const isDefault = kind === 'fg' ? cell.isFgDefault() : cell.isBgDefault() + if (isDefault) return undefined + const isRgb = kind === 'fg' ? cell.isFgRGB() : cell.isBgRGB() + const value = kind === 'fg' ? cell.getFgColor() : cell.getBgColor() + if (isRgb) return `${kind}=#${value.toString(16).padStart(6, '0')}` + const name = ANSI_COLORS[value] + return `${kind}=${name ?? `ansi-${value}`}` +} + +function styleLabel(cell: IBufferCell): string { + const labels = [ + colorLabel(cell, 'fg'), + colorLabel(cell, 'bg'), + cell.isBold() !== 0 ? 'bold' : undefined, + cell.isDim() !== 0 ? 'dim' : undefined, + cell.isItalic() !== 0 ? 'italic' : undefined, + cell.isUnderline() !== 0 ? 'underline' : undefined, + cell.isBlink() !== 0 ? 'blink' : undefined, + cell.isInverse() !== 0 ? 'inverse' : undefined, + cell.isInvisible() !== 0 ? 'invisible' : undefined, + cell.isStrikethrough() !== 0 ? 'strike' : undefined, + cell.isOverline() !== 0 ? 'overline' : undefined, + ].filter((label): label is string => label !== undefined) + return labels.join(' ') +} + +function snapshotRow(terminal: XtermTerminal, row: number): RowSnapshot { + const line = terminal.buffer.active.getLine(row) + if (line === undefined) return { text: '', wrapped: false, styles: [] } + const styles: string[] = [] + let activeStyle = '' + let activeStart = 0 + for (let column = 0; column <= terminal.cols; column++) { + const cell = column < terminal.cols ? line.getCell(column) : undefined + const style = cell === undefined ? '' : styleLabel(cell) + if (style === activeStyle) continue + if (activeStyle !== '') styles.push(`${activeStart}-${column - 1} ${activeStyle}`) + activeStyle = style + activeStart = column + } + return { + text: line.translateToString(true), + wrapped: line.isWrapped, + styles, + } +} + +function renderRows(rows: readonly RowSnapshot[], firstRow: number): string[] { + const rendered: string[] = [] + let blankStart: number | undefined + const flushBlanks = (end: number): void => { + if (blankStart === undefined) return + rendered.push(blankStart === end ? `${blankStart}| ` : `${blankStart}-${end}| `) + blankStart = undefined + } + for (let index = 0; index < rows.length; index++) { + const absoluteRow = firstRow + index + const row = rows[index] as RowSnapshot + if (row.text === '' && row.styles.length === 0 && !row.wrapped) { + blankStart ??= absoluteRow + continue + } + flushBlanks(absoluteRow - 1) + rendered.push(`${absoluteRow}${row.wrapped ? '~' : ''}| ${JSON.stringify(row.text)}`) + for (const style of row.styles) rendered.push(` style ${style}`) + } + flushBlanks(firstRow + rows.length - 1) + return rendered +} + +/** + * Terminal emulator used by TUI snapshots. It consumes the same ANSI stream as + * a real terminal and exposes completed synchronized frames as an awaitable boundary. + */ +export class HeadlessTerminal implements Terminal { + readonly kittyProtocolActive = false + readonly drainInput = (): Promise => Promise.resolve() + started = 0 + stopped = 0 + title = '' + progress = false + cursorVisible = true + frames = 0 + private readonly emulator: XtermTerminal + private onInput: (data: string) => void = () => {} + private onResize: () => void = () => {} + private pendingWrite: Promise = Promise.resolve() + private readonly frameWaiters = new Set() + + constructor(columns = 80, rows = 24) { + this.emulator = new XtermTerminal({ + cols: columns, + rows, + scrollback: 1_000, + allowProposedApi: true, + drawBoldTextInBrightColors: false, + logLevel: 'off', + }) + } + + get columns(): number { + return this.emulator.cols + } + + get rows(): number { + return this.emulator.rows + } + + start(onInput: (data: string) => void, onResize: () => void): void { + this.started += 1 + this.onInput = onInput + this.onResize = onResize + } + + stop(): void { + this.stopped += 1 + } + + write(data: string): void { + const completedFrames = occurrenceCount(data, FRAME_END) + this.pendingWrite = new Promise((resolve) => { + this.emulator.write(data, () => { + this.frames += completedFrames + for (const waiter of this.frameWaiters) { + if (this.frames < waiter.target) continue + clearTimeout(waiter.timer) + this.frameWaiters.delete(waiter) + waiter.resolve() + } + resolve() + }) + }) + } + + moveBy(lines: number): void { + if (lines > 0) this.write(`\x1b[${lines}B`) + if (lines < 0) this.write(`\x1b[${-lines}A`) + } + + hideCursor(): void { + this.cursorVisible = false + this.write('\x1b[?25l') + } + + showCursor(): void { + this.cursorVisible = true + this.write('\x1b[?25h') + } + + clearLine(): void { + this.write('\x1b[K') + } + + clearFromCursor(): void { + this.write('\x1b[J') + } + + clearScreen(): void { + this.write('\x1b[2J\x1b[H') + } + + setTitle(title: string): void { + this.title = title + this.write(`\x1b]0;${title}\x07`) + } + + setProgress(active: boolean): void { + this.progress = active + } + + send(data: string): void { + this.onInput(data) + } + + resize(columns: number, rows = this.rows): void { + this.emulator.resize(columns, rows) + this.onResize() + } + + /** Wait until pi-tui completes a synchronized frame newer than `after`. */ + async waitForFrame(after = this.frames): Promise { + if (this.frames <= after) { + await new Promise((resolve, reject) => { + const waiter: FrameWaiter = { + target: after + 1, + resolve, + reject, + timer: setTimeout(() => { + this.frameWaiters.delete(waiter) + reject(new Error(`TUI did not complete frame ${after + 1} within ${FRAME_TIMEOUT_MS}ms`)) + }, FRAME_TIMEOUT_MS), + } + this.frameWaiters.add(waiter) + }) + } + await this.flush() + } + + /** Await every terminal write queued through the current task. */ + async flush(): Promise { + let pending: Promise + do { + pending = this.pendingWrite + await pending + } while (pending !== this.pendingWrite) + } + + /** + * Reject palette output that would become theme-specific in a user's terminal. + * @returns One location per RGB, extended-palette, or explicit-background cell. + */ + themeViolations(): string[] { + const violations: string[] = [] + const buffer = this.emulator.buffer.active + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row) + if (line === undefined) continue + for (let column = 0; column < this.columns; column++) { + const cell = line.getCell(column) + if (cell === undefined) continue + const reasons = [ + cell.isFgRGB() ? 'rgb-fg' : undefined, + cell.isBgRGB() ? 'rgb-bg' : undefined, + cell.isFgPalette() && cell.getFgColor() > 15 ? `extended-fg-${cell.getFgColor()}` : undefined, + cell.isBgPalette() && cell.getBgColor() > 15 ? `extended-bg-${cell.getBgColor()}` : undefined, + !cell.isBgDefault() ? 'explicit-bg' : undefined, + ].filter((reason): reason is string => reason !== undefined) + if (reasons.length > 0) violations.push(`${row}:${column} ${reasons.join(',')}`) + } + } + return violations + } + + /** Serialize terminal cells and metadata into a stable, reviewable golden. */ + async snapshot(options: TerminalSnapshotOptions = {}): Promise { + await this.flush() + const buffer = this.emulator.buffer.active + const firstRow = options.includeScrollback === true ? 0 : buffer.viewportY + const rowCount = options.includeScrollback === true ? buffer.length : this.rows + const rows = Array.from({ length: rowCount }, (_, index) => snapshotRow(this.emulator, firstRow + index)) + const cursorBufferRow = buffer.baseY + buffer.cursorY + const cursorViewportRow = cursorBufferRow - buffer.viewportY + return [ + `terminal ${this.columns}x${this.rows} buffer=${buffer.type} length=${buffer.length} base=${buffer.baseY} viewport=${buffer.viewportY}`, + `lifecycle started=${this.started} stopped=${this.stopped} progress=${this.progress ? 'active' : 'inactive'}`, + `title ${JSON.stringify(this.title)}`, + `cursor ${this.cursorVisible ? 'visible' : 'hidden'} column=${buffer.cursorX} viewportRow=${cursorViewportRow} bufferRow=${cursorBufferRow}`, + options.includeScrollback === true ? 'buffer' : 'viewport', + ...renderRows(rows, firstRow), + '', + ].join('\n') + } + + async dispose(): Promise { + await this.flush() + for (const waiter of this.frameWaiters) { + clearTimeout(waiter.timer) + waiter.reject(new Error('terminal disposed before the requested frame completed')) + } + this.frameWaiters.clear() + this.emulator.dispose() + } +} diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.golden.txt b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.golden.txt new file mode 100644 index 0000000000..10e6e250b4 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/advanced-cards-collapsed.golden.txt @@ -0,0 +1,107 @@ +terminal 100x40 buffer=normal length=41 base=1 viewport=1 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=37 bufferRow=38 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=green +7| "▌ ✓ pnpm run test:coverage " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-25 bold +8| "▌ Run the coverage gate " + style 0-0 fg=green + style 2-22 fg=bright-black +9| "▌ /workspace/project " + style 0-0 fg=green + style 2-19 dim +10| "▌ packages/ui/tui 100% " + style 0-0 fg=green +11| "▌ … 4 more lines (Ctrl+O to expand) " + style 0-0 fg=green + style 2-34 dim +12| "▌ " + style 0-0 fg=green +13| +14| "▌ " + style 0-0 fg=green +15| "▌ ✓ Edit renderer " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-16 bold +16| "▌ src/view.ts " + style 0-0 fg=green + style 2-12 bold +17| "▌ - old line " + style 0-0 fg=green + style 2-11 fg=red +18| "▌ - keep " + style 0-0 fg=green + style 2-7 fg=red +19| "▌ … 5 more lines (Ctrl+O to expand) " + style 0-0 fg=green + style 2-34 dim +20| "▌ " + style 0-0 fg=green +21| +22| "▌ " + style 0-0 fg=green +23| "▌ ✓ Delegate renderer audit " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-26 bold +24| "▌ The renderer has explicit lifecycle ownership. " + style 0-0 fg=green +25| "▌ " + style 0-0 fg=green +26| +27| "▌ " + style 0-0 fg=green +28| "▌ ✓ Read output from background task subagent-7 " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-46 bold +29| "▌ audit complete " + style 0-0 fg=green +30| "▌ [status: completed] " + style 0-0 fg=green +31| "▌ " + style 0-0 fg=green +32| +33| "▌ " + style 0-0 fg=green +34| "▌ ✓ Load skill dsh-code-review " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-29 bold +35| "▌ Loaded review instructions. " + style 0-0 fg=green +36| "▌ " + style 0-0 fg=green +37| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +38| " " + style 1-1 inverse +39| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +40| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 67-99 dim diff --git a/packages/ui/tui/tests/snapshots/advanced-cards-expanded.golden.txt b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.golden.txt new file mode 100644 index 0000000000..42822d3720 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/advanced-cards-expanded.golden.txt @@ -0,0 +1,127 @@ +terminal 100x40 buffer=normal length=50 base=10 viewport=10 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=37 bufferRow=47 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=green +7| "▌ ✓ pnpm run test:coverage " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-25 bold +8| "▌ Run the coverage gate " + style 0-0 fg=green + style 2-22 fg=bright-black +9| "▌ /workspace/project " + style 0-0 fg=green + style 2-19 dim +10| "▌ packages/ui/tui 100% " + style 0-0 fg=green +11| "▌ 4016 tests passed " + style 0-0 fg=green +12| "▌ 1 test skipped " + style 0-0 fg=green +13| "▌ coverage complete " + style 0-0 fg=green +14| "▌ [exit 0] " + style 0-0 fg=green + style 2-9 dim +15| "▌ " + style 0-0 fg=green +16| +17| "▌ " + style 0-0 fg=green +18| "▌ ✓ Edit renderer " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-16 bold +19| "▌ src/view.ts " + style 0-0 fg=green + style 2-12 bold +20| "▌ - old line " + style 0-0 fg=green + style 2-11 fg=red +21| "▌ - keep " + style 0-0 fg=green + style 2-7 fg=red +22| "▌ + new line " + style 0-0 fg=green + style 2-11 fg=green +23| "▌ + keep " + style 0-0 fg=green + style 2-7 fg=green +24| "▌ " + style 0-0 fg=green +25| "▌ tests/view.spec.ts " + style 0-0 fg=green + style 2-19 bold +26| "▌ + expect(screen).toMatchSnapshot() " + style 0-0 fg=green + style 2-35 fg=green +27| "▌ " + style 0-0 fg=green +28| +29| "▌ " + style 0-0 fg=green +30| "▌ ✓ Delegate renderer audit " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-26 bold +31| "▌ The renderer has explicit lifecycle ownership. " + style 0-0 fg=green +32| "▌ " + style 0-0 fg=green +33| +34| "▌ " + style 0-0 fg=green +35| "▌ ✓ Read output from background task subagent-7 " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-46 bold +36| "▌ audit complete " + style 0-0 fg=green +37| "▌ [status: completed] " + style 0-0 fg=green +38| "▌ " + style 0-0 fg=green +39| +40| "▌ " + style 0-0 fg=green +41| "▌ ✓ Load skill dsh-code-review " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-29 bold +42| "▌ Loaded review instructions. " + style 0-0 fg=green +43| "▌ " + style 0-0 fg=green +44| +45| " Tool cards expanded. " + style 1-20 fg=bright-black +46| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +47| " " + style 1-1 inverse +48| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +49| "/workspace/project ↑0 ↓0 idle reasoning:on tools:expanded" + style 0-24 dim + style 66-99 dim diff --git a/packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt b/packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt new file mode 100644 index 0000000000..72f7fce352 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt @@ -0,0 +1,50 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=14 bufferRow=14 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=green +7| "▌ ✓ const first = await tools.bash({ command: 'echo CODE_ONE' }) " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-95 bold +8| "▌ const second = await tools.bas " + style 0-0 fg=green + style 2-31 bold +9| "▌ CODE_ONE " + style 0-0 fg=green +10| "▌ CODE_TWO " + style 0-0 fg=green +11| "▌ combined: CODE_ONE+CODE_TWO " + style 0-0 fg=green +12| "▌ " + style 0-0 fg=green +13| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +14| " " + style 1-1 inverse +15| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +16| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +17-35| diff --git a/packages/ui/tui/tests/snapshots/code-mode-pending.golden.txt b/packages/ui/tui/tests/snapshots/code-mode-pending.golden.txt new file mode 100644 index 0000000000..6908b69890 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/code-mode-pending.golden.txt @@ -0,0 +1,52 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=15 bufferRow=15 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=yellow +7| "▌ ◌ const first = await tools.bash({ command: 'echo CODE_ONE' }) " + style 0-0 fg=yellow + style 2-2 fg=yellow bold + style 3-95 bold +8| "▌ const second = await tools.bas " + style 0-0 fg=yellow + style 2-31 bold +9| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) " + style 0-0 fg=yellow +10| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) " + style 0-0 fg=yellow +11| "▌ console.log(first, second) " + style 0-0 fg=yellow +12| "▌ return `${first}+${second}` " + style 0-0 fg=yellow +13| "▌ " + style 0-0 fg=yellow +14| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +15| " " + style 1-1 inverse +16| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +17| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +18-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-complete.golden.txt b/packages/ui/tui/tests/snapshots/conversation-complete.golden.txt new file mode 100644 index 0000000000..8642d2ffa1 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/conversation-complete.golden.txt @@ -0,0 +1,75 @@ +terminal 96x36 buffer=normal length=43 base=7 viewport=7 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=40 +viewport +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Explain snapshot fidelity with cells. " + style 0-0 fg=bright-blue + style 10-26 bold + style 33-37 fg=cyan +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " Compare the terminal state, not write fragments. " + style 1-48 fg=bright-black italic +13| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " Result " + style 1-6 fg=bright-blue bold +16| " " +17| " - final viewport " + style 1-2 fg=bright-blue +18| " - semantic styles " + style 1-2 fg=bright-blue +19| " " +20| " │ deterministic and reviewable " + style 1-2 fg=bright-magenta + style 3-30 fg=bright-black italic +21| +22| "▌ " + style 0-0 fg=bright-blue +23| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +24| "▌ Show the live update. " + style 0-0 fg=bright-blue +25| "▌ " + style 0-0 fg=bright-blue +26| +27| " Reasoning " + style 1-9 fg=bright-black italic +28| " Inspecting width and styles. " + style 1-28 fg=bright-black italic +29| +30| " Assistant " + style 1-9 fg=bright-magenta bold +31| " Streaming visible state is complete. " + style 11-23 bold +32| +33| " The model reached its output-token limit. " + style 1-41 fg=yellow +34| +35| "Plan" + style 0-3 fg=bright-blue bold +36| " ✓ model the terminal" + style 2-2 fg=green + style 4-21 fg=bright-black +37| " ● capture advanced states" + style 2-2 fg=yellow +38| " ○ verify PTY cleanup" + style 2-2 dim +39| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +40| " " + style 1-1 inverse +41| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +42| "/workspace/project ↑13k ↓760 idle reasoning:on tools:compact" + style 0-28 dim + style 63-95 dim diff --git a/packages/ui/tui/tests/snapshots/conversation-replay.golden.txt b/packages/ui/tui/tests/snapshots/conversation-replay.golden.txt new file mode 100644 index 0000000000..2395db555b --- /dev/null +++ b/packages/ui/tui/tests/snapshots/conversation-replay.golden.txt @@ -0,0 +1,73 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=27 bufferRow=27 +viewport +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Explain snapshot fidelity with cells. " + style 0-0 fg=bright-blue + style 10-26 bold + style 33-37 fg=cyan +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " Compare the terminal state, not write fragments. " + style 1-48 fg=bright-black italic +13| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " Result " + style 1-6 fg=bright-blue bold +16| " " +17| " - final viewport " + style 1-2 fg=bright-blue +18| " - semantic styles " + style 1-2 fg=bright-blue +19| " " +20| " │ deterministic and reviewable " + style 1-2 fg=bright-magenta + style 3-30 fg=bright-black italic +21| +22| "Plan" + style 0-3 fg=bright-blue bold +23| " ✓ model the terminal" + style 2-2 fg=green + style 4-21 fg=bright-black +24| " ● capture advanced states" + style 2-2 fg=yellow +25| " ○ verify PTY cleanup" + style 2-2 dim +26| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +27| " " + style 1-1 inverse +28| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +29| "/workspace/project ↑13k ↓640 idle reasoning:on tools:compact" + style 0-28 dim + style 63-95 dim +30-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt new file mode 100644 index 0000000000..55044a44f9 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt @@ -0,0 +1,75 @@ +terminal 96x36 buffer=normal length=41 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=38 +viewport +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Explain snapshot fidelity with cells. " + style 0-0 fg=bright-blue + style 10-26 bold + style 33-37 fg=cyan +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " Compare the terminal state, not write fragments. " + style 1-48 fg=bright-black italic +13| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " Result " + style 1-6 fg=bright-blue bold +16| " " +17| " - final viewport " + style 1-2 fg=bright-blue +18| " - semantic styles " + style 1-2 fg=bright-blue +19| " " +20| " │ deterministic and reviewable " + style 1-2 fg=bright-magenta + style 3-30 fg=bright-black italic +21| +22| "▌ " + style 0-0 fg=bright-blue +23| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +24| "▌ Show the live update. " + style 0-0 fg=bright-blue +25| "▌ " + style 0-0 fg=bright-blue +26| +27| " Reasoning " + style 1-9 fg=bright-black italic +28| " Inspecting width and styles. " + style 1-28 fg=bright-black italic +29| +30| " Assistant " + style 1-9 fg=bright-magenta bold +31| " Streaming visible state… " + style 11-23 bold +32| +33| "Plan" + style 0-3 fg=bright-blue bold +34| " ✓ model the terminal" + style 2-2 fg=green + style 4-21 fg=bright-black +35| " ● capture advanced states" + style 2-2 fg=yellow +36| " ○ verify PTY cleanup" + style 2-2 dim +37| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +38| " " + style 1-1 inverse +39| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +40| "/workspace/project ↑13k ↓640 idle reasoning:on tools:compact" + style 0-28 dim + style 63-95 dim diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt b/packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt new file mode 100644 index 0000000000..d33b56d7ce --- /dev/null +++ b/packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt @@ -0,0 +1,73 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=25 bufferRow=25 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=green +7| "▌ ✓ Inspect cordis runtime: tools " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-32 bold +8| "▌ ## tools " + style 0-0 fg=green +9| "▌ run_code " + style 0-0 fg=green +10| "▌ workflow " + style 0-0 fg=green +11| "▌ cordis_mount " + style 0-0 fg=green +12| "▌ cordis_unmount " + style 0-0 fg=green +13| "▌ " + style 0-0 fg=green +14| +15| "▌ " + style 0-0 fg=green +16| "▌ ✓ Mount plugin into live cordis runtime " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-40 bold +17| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " + style 0-0 fg=green +18| "▌ " + style 0-0 fg=green +19| +20| "▌ " + style 0-0 fg=green +21| "▌ ✓ Unmount dyn-1 " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-16 bold +22| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " + style 0-0 fg=green +23| "▌ " + style 0-0 fg=green +24| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +25| " " + style 1-1 inverse +26| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +28-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-pending.golden.txt b/packages/ui/tui/tests/snapshots/cordis-tools-pending.golden.txt new file mode 100644 index 0000000000..eb44cb6b94 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/cordis-tools-pending.golden.txt @@ -0,0 +1,59 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=18 bufferRow=18 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ ◌ Inspect cordis runtime: tools " + style 0-0 fg=yellow + style 2-2 fg=yellow bold + style 3-32 bold +7| +8| "▌ " + style 0-0 fg=yellow +9| "▌ ◌ Mount plugin into live cordis runtime " + style 0-0 fg=yellow + style 2-2 fg=yellow bold + style 3-40 bold +10| "▌ { " + style 0-0 fg=yellow +11| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { " + style 0-0 fg=yellow +12| "▌ ready: true }) } }\" " + style 0-0 fg=yellow +13| "▌ } " + style 0-0 fg=yellow +14| "▌ " + style 0-0 fg=yellow +15| +16| "▌ ◌ Unmount dyn-1 " + style 0-0 fg=yellow + style 2-2 fg=yellow bold + style 3-16 bold +17| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +18| " " + style 1-1 inverse +19| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +21-35| diff --git a/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt b/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt new file mode 100644 index 0000000000..6a008f4032 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/disposed-terminal.golden.txt @@ -0,0 +1,52 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=1 progress=inactive +title "DSH snapshot" +cursor visible column=0 viewportRow=22 bufferRow=22 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| +6| " Keyboard shortcuts " + style 1-18 fg=bright-blue bold +7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 1-61 fg=bright-black +8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " + style 1-75 fg=bright-black +9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 1-73 fg=bright-black +10| " /help /clear /cancel /reasoning /tools /redraw /exit " + style 1-52 fg=bright-black +11| +12| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +13| +14| " provider stream failed after partial output " + style 1-43 fg=red +15| +16| " The previous process ended during this turn. " + style 1-44 fg=yellow +17| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +18| " " + style 1-1 inverse +19| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 59-91 dim +21-31| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt new file mode 100644 index 0000000000..00da73a956 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt @@ -0,0 +1,53 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=16 bufferRow=16 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=green +7| "▌ ✓ workflow: tui-matrix " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-23 bold +8| "▌ workflow \"tui-matrix\" completed (2 agents). " + style 0-0 fg=green +9| "▌ Return value: " + style 0-0 fg=green +10| "▌ { " + style 0-0 fg=green +11| "▌ \"reports\": [\"layout ok\", \"lifecycle ok\"], " + style 0-0 fg=green +12| "▌ \"verdict\": \"covered\" " + style 0-0 fg=green +13| "▌ } " + style 0-0 fg=green +14| "▌ " + style 0-0 fg=green +15| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +16| " " + style 1-1 inverse +17| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +19-35| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.golden.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.golden.txt new file mode 100644 index 0000000000..0e9849bc76 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/dynamic-workflow-pending.golden.txt @@ -0,0 +1,55 @@ +terminal 96x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=17 bufferRow=17 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=yellow +7| "▌ ◌ workflow: tui-matrix " + style 0-0 fg=yellow + style 2-2 fg=yellow bold + style 3-23 bold +8| "▌ phase('Inspect') " + style 0-0 fg=yellow +9| "▌ const reports = await parallel([ " + style 0-0 fg=yellow +10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), " + style 0-0 fg=yellow +11| "▌ () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }), " + style 0-0 fg=yellow +12| "▌ ]) " + style 0-0 fg=yellow +13| "▌ phase('Verify') " + style 0-0 fg=yellow +14| "▌ return { reports, verdict: 'covered' } " + style 0-0 fg=yellow +15| "▌ " + style 0-0 fg=yellow +16| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +17| " " + style 1-1 inverse +18| "────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-95 dim +19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 63-95 dim +20-35| diff --git a/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt b/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt new file mode 100644 index 0000000000..fb7a119735 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/errors-and-help.golden.txt @@ -0,0 +1,52 @@ +terminal 92x32 buffer=normal length=32 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=18 bufferRow=18 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-91 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 91-91 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 91-91 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 91-91 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-91 fg=bright-blue +5| +6| " Keyboard shortcuts " + style 1-18 fg=bright-blue bold +7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history " + style 1-61 fg=bright-black +8| " Esc cancel active turn • Ctrl+O expand tool cards • Ctrl+R toggle reasoning " + style 1-75 fg=bright-black +9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit " + style 1-73 fg=bright-black +10| " /help /clear /cancel /reasoning /tools /redraw /exit " + style 1-52 fg=bright-black +11| +12| " Unknown command: /unknown-advanced-command " + style 1-42 fg=yellow +13| +14| " provider stream failed after partial output " + style 1-43 fg=red +15| +16| " The previous process ended during this turn. " + style 1-44 fg=yellow +17| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +18| " " + style 1-1 inverse +19| "────────────────────────────────────────────────────────────────────────────────────────────" + style 0-91 dim +20| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 59-91 dim +21-31| diff --git a/packages/ui/tui/tests/snapshots/question-dialog-validation.golden.txt b/packages/ui/tui/tests/snapshots/question-dialog-validation.golden.txt new file mode 100644 index 0000000000..3704b8e510 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog-validation.golden.txt @@ -0,0 +1,69 @@ +terminal 56x20 buffer=normal length=20 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=56 viewportRow=13 bufferRow=13 +viewport +0| "╭──────────────────────────────────────────────────────╮" + style 0-55 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 55-55 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 55-55 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 55-55 fg=bright-blue +4| "╰───╭ Coverage ────────────────────────────────────╮───╯" + style 0-55 fg=bright-blue +5| "────│ Which advanced TUI states belong in the │────" + style 0-3 dim + style 4-4 fg=bright-blue + style 6-50 bold + style 51-51 fg=bright-blue bold + style 52-55 dim +6| " │ required matrix? │ " + style 1-1 inverse + style 4-4 fg=bright-blue + style 6-21 bold + style 51-51 fg=bright-blue +7| "────│ │────" + style 0-3 dim + style 4-4 fg=bright-blue + style 51-51 fg=bright-blue + style 52-55 dim +8| "/wor│ › [ ] Code Mode — run_code programs and capt │:com" + style 0-3 dim + style 4-4 fg=bright-blue + style 6-6 fg=bright-blue inverse + style 7-20 inverse + style 21-49 fg=bright-black inverse + style 51-51 fg=bright-blue + style 52-55 dim +9| " │ [ ] Workflows — phases and parallel agents │ " + style 4-4 fg=bright-blue + style 21-49 fg=bright-black + style 51-51 fg=bright-blue +10| " │ [ ] Cordis tools — inspect, mount, and unm │ " + style 4-4 fg=bright-blue + style 24-49 fg=bright-black + style 51-51 fg=bright-blue +11| " │ 1/4 │ " + style 4-4 fg=bright-blue + style 6-8 dim + style 51-51 fg=bright-blue +12| " │ ↑↓ navigate • Space toggle • Enter submit • │ " + style 4-4 fg=bright-blue + style 6-49 dim + style 51-51 fg=bright-blue +13| " │ Select at least one option, or press C for a │ " + style 4-4 fg=bright-blue + style 6-49 fg=red + style 51-51 fg=bright-blue +14| " ╰──────────────────────────────────────────────╯ " + style 4-51 fg=bright-blue +15-19| diff --git a/packages/ui/tui/tests/snapshots/question-dialog.golden.txt b/packages/ui/tui/tests/snapshots/question-dialog.golden.txt new file mode 100644 index 0000000000..8c1565fdda --- /dev/null +++ b/packages/ui/tui/tests/snapshots/question-dialog.golden.txt @@ -0,0 +1,67 @@ +terminal 56x20 buffer=normal length=20 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=0 viewportRow=19 bufferRow=19 +viewport +0| "╭──────────────────────────────────────────────────────╮" + style 0-55 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 55-55 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 55-55 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 55-55 fg=bright-blue +4| "╰──────────────────────────────────────────────────────╯" + style 0-55 fg=bright-blue +5| "────╭ Coverage ────────────────────────────────────╮────" + style 0-3 dim + style 4-51 fg=bright-blue + style 52-55 dim +6| " │ Which advanced TUI states belong in the │ " + style 1-1 inverse + style 4-4 fg=bright-blue + style 6-50 bold + style 51-51 fg=bright-blue bold +7| "────│ required matrix? │────" + style 0-3 dim + style 4-4 fg=bright-blue + style 6-21 bold + style 51-51 fg=bright-blue + style 52-55 dim +8| "/wor│ │:com" + style 0-3 dim + style 4-4 fg=bright-blue + style 51-51 fg=bright-blue + style 52-55 dim +9| " │ › [ ] Code Mode — run_code programs and capt │ " + style 4-4 fg=bright-blue + style 6-6 fg=bright-blue inverse + style 7-20 inverse + style 21-49 fg=bright-black inverse + style 51-51 fg=bright-blue +10| " │ [ ] Workflows — phases and parallel agents │ " + style 4-4 fg=bright-blue + style 21-49 fg=bright-black + style 51-51 fg=bright-blue +11| " │ [ ] Cordis tools — inspect, mount, and unm │ " + style 4-4 fg=bright-blue + style 24-49 fg=bright-black + style 51-51 fg=bright-blue +12| " │ 1/4 │ " + style 4-4 fg=bright-blue + style 6-8 dim + style 51-51 fg=bright-blue +13| " │ ↑↓ navigate • Space toggle • Enter submit • │ " + style 4-4 fg=bright-blue + style 6-49 dim + style 51-51 fg=bright-blue +14| " ╰──────────────────────────────────────────────╯ " + style 4-51 fg=bright-blue +15-19| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.golden.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.golden.txt new file mode 100644 index 0000000000..429cebfb84 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-narrow.golden.txt @@ -0,0 +1,45 @@ +terminal 44x18 buffer=normal length=18 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=12 bufferRow=12 +buffer +0| "╭──────────────────────────────────────────╮" + style 0-43 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 43-43 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 43-43 fg=bright-blue +3| "│ main • deepseek-v4-flash • │" + style 0-0 fg=bright-blue + style 2-42 dim + style 43-43 fg=bright-blue dim +4| "│ main-session │" + style 0-0 fg=bright-blue + style 2-13 dim + style 43-43 fg=bright-blue +5| "╰──────────────────────────────────────────╯" + style 0-43 fg=bright-blue +6| +7| " Context · compact " + style 1-17 dim +8| " Compacted summary: the prior command " + style 1-43 fg=bright-black +9| " completed and its details were retired " + style 1-43 fg=bright-black +10| " from the active surface. " + style 1-24 fg=bright-black +11| "────────────────────────────────────────────" + style 0-43 dim +12| " " + style 1-1 inverse +13| "────────────────────────────────────────────" + style 0-43 dim +14| "/workspace/project ↑0 ↓0 idle reasoning:o" + style 0-24 dim + style 27-43 dim +15-17| diff --git a/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.golden.txt b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.golden.txt new file mode 100644 index 0000000000..06380259af --- /dev/null +++ b/packages/ui/tui/tests/snapshots/surface-after-compaction-wide.golden.txt @@ -0,0 +1,37 @@ +terminal 104x30 buffer=normal length=30 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=9 bufferRow=9 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-103 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 103-103 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 103-103 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 103-103 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-103 fg=bright-blue +5| +6| " Context · compact " + style 1-17 dim +7| " Compacted summary: the prior command completed and its details were retired from the active surface. " + style 1-100 fg=bright-black +8| "────────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-103 dim +9| " " + style 1-1 inverse +10| "────────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-103 dim +11| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 71-103 dim +12-29| diff --git a/packages/ui/tui/tests/snapshots/surface-before-compaction.golden.txt b/packages/ui/tui/tests/snapshots/surface-before-compaction.golden.txt new file mode 100644 index 0000000000..eaad66daf8 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/surface-before-compaction.golden.txt @@ -0,0 +1,67 @@ +terminal 80x24 buffer=normal length=25 base=1 viewport=1 +lifecycle started=1 stopped=0 progress=inactive +title "DSH snapshot" +cursor hidden column=1 viewportRow=21 bufferRow=22 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────╮" + style 0-79 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 79-79 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 79-79 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 79-79 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────╯" + style 0-79 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Old prompt with a long line that exercises wrapping before compaction. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| "▌ " + style 0-0 fg=green +12| "▌ ✓ pnpm run test:coverage " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-25 bold +13| "▌ Run the coverage gate " + style 0-0 fg=green + style 2-22 fg=bright-black +14| "▌ /workspace/project " + style 0-0 fg=green + style 2-19 dim +15| "▌ packages/ui/tui 100% " + style 0-0 fg=green +16| "▌ 4016 tests passed " + style 0-0 fg=green +17| "▌ 1 test skipped " + style 0-0 fg=green +18| "▌ coverage complete " + style 0-0 fg=green +19| "▌ [exit 0] " + style 0-0 fg=green + style 2-9 dim +20| "▌ " + style 0-0 fg=green +21| "────────────────────────────────────────────────────────────────────────────────" + style 0-79 dim +22| " " + style 1-1 inverse +23| "────────────────────────────────────────────────────────────────────────────────" + style 0-79 dim +24| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 47-79 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts new file mode 100644 index 0000000000..eb6d5e9965 --- /dev/null +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -0,0 +1,472 @@ +import { mkdir, readdir, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import type { Context } from 'cordis' +import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' +import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +import { + appendAssistant, + appendUser, + createTuiTestHarness, + disposeTuiTestHarness, + type TuiHarness, + type TuiHarnessOptions, +} from './harness.ts' +import { HeadlessTerminal, type TerminalSnapshotOptions } from './headless-terminal.ts' + +const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' + +const CHECKPOINTS = [ + 'conversation-replay', + 'conversation-streaming', + 'conversation-complete', + 'code-mode-pending', + 'code-mode-complete', + 'dynamic-workflow-pending', + 'dynamic-workflow-complete', + 'cordis-tools-pending', + 'cordis-tools-complete', + 'advanced-cards-collapsed', + 'advanced-cards-expanded', + 'question-dialog', + 'question-dialog-validation', + 'surface-before-compaction', + 'surface-after-compaction-narrow', + 'surface-after-compaction-wide', + 'errors-and-help', + 'disposed-terminal', +] as const + +type Checkpoint = typeof CHECKPOINTS[number] +type SnapshotHarness = TuiHarness void> + +const observedCheckpoints = new Set() + +async function checkpoint( + name: Checkpoint, + terminal: HeadlessTerminal, + options: TerminalSnapshotOptions = {}, +): Promise { + observedCheckpoints.add(name) + expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([]) + const snapshot = await terminal.snapshot(options) + const path = join(SNAPSHOTS_DIR, `${name}.golden.txt`) + if (REFRESHING) { + await mkdir(SNAPSHOTS_DIR, { recursive: true }) + await writeFile(path, snapshot) + } + await expect(snapshot).toMatchFileSnapshot(path) +} + +async function setupSnapshot( + options: TuiHarnessOptions = {}, + size: { columns?: number; rows?: number } = {}, +): Promise { + const terminal = new HeadlessTerminal(size.columns ?? 96, size.rows ?? 36) + const before = terminal.frames + const result = await createTuiTestHarness(terminal, () => {}, { + ...options, + cwd: options.cwd === undefined ? '/workspace/project' : options.cwd, + config: Object.assign({ + welcome: 'Snapshot agent ready.', + color: true, + title: 'DSH snapshot', + }, options.config), + }) + await terminal.waitForFrame(before) + return result +} + +async function renderAfter(harness: SnapshotHarness, action: () => void): Promise { + const before = harness.terminal.frames + action() + await harness.terminal.waitForFrame(before) +} + +async function disposeSnapshot(harness: SnapshotHarness): Promise { + await disposeTuiTestHarness(harness) + await harness.terminal.dispose() +} + +async function configureAdvancedTools(ctx: Context): Promise { + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'code' }) + ctx.provide('workflows', {} as never) + await ctx.plugin(ToolWorkflow, { toolName: 'workflow', maxResultChars: 50_000 }) + await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) +} + +interface ToolCallFixture { + id: string + name: string + arguments: unknown +} + +function appendToolCalls(session: Session, calls: readonly ToolCallFixture[]): void { + appendAssistant(session, calls.map(call => ({ + type: 'tool-call', + id: CallId(call.id), + name: call.name, + arguments: JSON.stringify(call.arguments), + }))) + for (const call of calls) { + session.append('tool/call', { + turn: 1, + step: 0, + callId: CallId(call.id), + name: call.name, + arguments: JSON.stringify(call.arguments), + }) + } +} + +function appendToolResult( + session: Session, + id: string, + content: ContentBlock[], + options: { isError?: boolean; meta?: unknown } = {}, +): void { + session.append('tool/result', { + turn: 1, + step: 0, + callId: CallId(id), + content, + isError: options.isError ?? false, + ...options.meta === undefined ? {} : { meta: options.meta }, + }, { surfaceOp: 'append' }) +} + +function visualTool( + name: string, + call: NonNullable, + result?: NonNullable, +): ToolDefinition { + return { + name, + description: `${name} snapshot fixture`, + parameters: {}, + execute: () => Promise.resolve([]), + presentCall: call, + ...result === undefined ? {} : { presentResult: result }, + } +} + +const ADVANCED_CARD_TOOLS: Record = { + bash: visualTool( + 'bash', + () => ({ card: 'terminal', title: 'pnpm run test:coverage', description: 'Run the coverage gate', cwd: '/workspace/project' }), + () => ({ card: 'terminal', output: 'packages/ui/tui 100%\n4016 tests passed\n1 test skipped\ncoverage complete', exitCode: 0 }), + ), + edit: visualTool( + 'edit', + () => ({ card: 'diff', title: 'Edit renderer', diffs: [{ path: 'src/view.ts', oldText: 'old line', newText: 'new line' }] }), + (): ToolResultView => ({ + card: 'diff', + diffs: [ + { path: 'src/view.ts', oldText: 'old line\nkeep', newText: 'new line\nkeep' }, + { path: 'tests/view.spec.ts', oldText: null, newText: 'expect(screen).toMatchSnapshot()' }, + ], + }), + ), + subagent: visualTool('subagent', args => ({ + card: 'generic', + title: 'Delegate renderer audit', + rawInput: (args as { prompt: string }).prompt, + })), + task_output: visualTool('task_output', args => ({ + card: 'generic', + kind: 'read', + title: `Read output from background task ${(args as { task_id: string }).task_id}`, + rawInput: (args as { task_id: string }).task_id, + })), + skill: visualTool('skill', args => ({ + card: 'generic', + kind: 'read', + title: `Load skill ${(args as { name: string }).name}`, + rawInput: (args as { name: string }).name, + })), +} + +describe('TUI terminal-state snapshots', () => { + it('pins resumed conversation, streaming, completion, plans, tokens, and Markdown', async () => { + const harness = await setupSnapshot({ + beforeMount(session) { + appendUser(session, 'Explain **snapshot fidelity** with `cells`.') + appendAssistant(session, [ + { type: 'reasoning', text: 'Compare the terminal state, not write fragments.' }, + { type: 'text', text: '## Result\n\n- final viewport\n- semantic styles\n\n> deterministic and reviewable' }, + ], { inputTokens: 12_500, outputTokens: 640 }) + session.append('todo/write', { + todos: [ + { content: 'model the terminal', status: 'completed' }, + { content: 'capture advanced states', status: 'in_progress' }, + { content: 'verify PTY cleanup', status: 'pending' }, + ], + }) + }, + }) + await checkpoint('conversation-replay', harness.terminal) + + await renderAfter(harness, () => { + appendUser(harness.session, 'Show the live update.') + harness.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + }) + harness.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'reasoning-delta', index: 0, text: 'Inspecting width and styles.' }, + }) + harness.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'block-start', index: 1, blockType: 'text' }, + }) + harness.session.append('assistant/chunk', { + turn: 2, + step: 0, + chunk: { type: 'text-delta', index: 1, text: 'Streaming **visible state**…' }, + }) + }) + await checkpoint('conversation-streaming', harness.terminal) + + await renderAfter(harness, () => { + appendAssistant(harness.session, [ + { type: 'reasoning', text: 'Inspecting width and styles.' }, + { type: 'text', text: 'Streaming **visible state** is complete.' }, + ], { inputTokens: 800, outputTokens: 120 }) + harness.session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) + }) + await checkpoint('conversation-complete', harness.terminal) + await disposeSnapshot(harness) + }) + + it('pins Code Mode run_code with its production presenter', async () => { + const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) + const call = { + id: 'code-1', + name: 'run_code', + arguments: { + code: "const first = await tools.bash({ command: 'echo CODE_ONE' })\nconst second = await tools.bash({ command: 'echo CODE_TWO' })\nconsole.log(first, second)\nreturn `${first}+${second}`", + }, + } + await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) }) + await checkpoint('code-mode-pending', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { + appendToolResult(harness.session, call.id, [{ type: 'text', text: 'CODE_ONE\n+CODE_TWO' }], { + meta: { logs: ['CODE_ONE', 'CODE_TWO', 'combined: CODE_ONE+CODE_TWO'] }, + }) + }) + await checkpoint('code-mode-complete', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins a dynamic workflow with phases, parallel agents, and structured output', async () => { + const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) + const call = { + id: 'workflow-1', + name: 'workflow', + arguments: { + meta: { + name: 'tui-matrix', + description: 'Audit terminal states from independent angles', + phases: [ + { title: 'Inspect', detail: 'Map renderer branches' }, + { title: 'Verify', detail: 'Challenge missing states', provider: 'deepseek', model: 'deepseek-v4-flash' }, + ], + }, + args: { packages: ['ui/tui', 'workflow/tool-workflow'] }, + script: "phase('Inspect')\nconst reports = await parallel([\n () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }),\n () => agent('Audit lifecycle', { label: 'lifecycle', phase: 'Inspect' }),\n])\nphase('Verify')\nreturn { reports, verdict: 'covered' }", + }, + } + await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) }) + await checkpoint('dynamic-workflow-pending', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { + appendToolResult(harness.session, call.id, [{ + type: 'text', + text: 'workflow "tui-matrix" completed (2 agents).\nReturn value:\n{\n "reports": ["layout ok", "lifecycle ok"],\n "verdict": "covered"\n}', + }]) + }) + await checkpoint('dynamic-workflow-complete', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins cordis inspect, dynamic mount, and unmount cards with production presenters', async () => { + const harness = await setupSnapshot({ configureContext: configureAdvancedTools }) + const calls = [ + { id: 'cordis-1', name: 'cordis_inspect', arguments: { what: 'tools' } }, + { + id: 'cordis-2', + name: 'cordis_mount', + arguments: { code: "return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { ready: true }) } }" }, + }, + { id: 'cordis-3', name: 'cordis_unmount', arguments: { id: 'dyn-1' } }, + ] + await renderAfter(harness, () => { appendToolCalls(harness.session, calls) }) + await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { + appendToolResult(harness.session, 'cordis-1', [{ type: 'text', text: '## tools\nrun_code\nworkflow\ncordis_mount\ncordis_unmount' }]) + appendToolResult(harness.session, 'cordis-2', [{ type: 'text', text: 'mounted dyn-1 (plugin "snapshot-marker", state: active)' }]) + appendToolResult(harness.session, 'cordis-3', [{ type: 'text', text: 'unmounted dyn-1 (plugin "snapshot-marker")' }]) + }) + await checkpoint('cordis-tools-complete', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins terminal, diff, subagent, task, skill, collapsed, and expanded cards', async () => { + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + config: { maxToolOutputLines: 3 }, + }, { columns: 100, rows: 40 }) + const calls = [ + { id: 'advanced-1', name: 'bash', arguments: { command: 'pnpm run test:coverage' } }, + { id: 'advanced-2', name: 'edit', arguments: { file_path: 'src/view.ts' } }, + { id: 'advanced-3', name: 'subagent', arguments: { prompt: 'Review renderer ownership and report only gaps.' } }, + { id: 'advanced-4', name: 'task_output', arguments: { task_id: 'subagent-7', wait: true } }, + { id: 'advanced-5', name: 'skill', arguments: { name: 'dsh-code-review' } }, + ] + await renderAfter(harness, () => { + appendToolCalls(harness.session, calls) + appendToolResult(harness.session, 'advanced-1', [{ type: 'text', text: 'raw process output' }]) + appendToolResult(harness.session, 'advanced-2', [{ type: 'text', text: 'edit complete' }]) + appendToolResult(harness.session, 'advanced-3', [{ type: 'text', text: 'The renderer has explicit lifecycle ownership.' }]) + appendToolResult(harness.session, 'advanced-4', [{ type: 'text', text: 'audit complete\n[status: completed]' }]) + appendToolResult(harness.session, 'advanced-5', [{ type: 'text', text: 'Loaded review instructions.' }]) + }) + await checkpoint('advanced-cards-collapsed', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { harness.terminal.send('\x0f') }) + await checkpoint('advanced-cards-expanded', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins a constrained multi-select question and its validation state', async () => { + const harness = await setupSnapshot({ + config: { + maxQuestionOptions: 3, + questionDialogWidth: 48, + questionDialogMaxHeight: 16, + }, + }, { columns: 56, rows: 20 }) + const controller = new AbortController() + const beforeQuestion = harness.terminal.frames + const answer = harness.ctx.userInteraction.ask({ + questions: [{ + id: 'coverage', + header: 'Coverage', + question: 'Which advanced TUI states belong in the required matrix?', + multiSelect: true, + options: [ + { label: 'Code Mode', description: 'run_code programs and captured output' }, + { label: 'Workflows', description: 'phases and parallel agents' }, + { label: 'Cordis tools', description: 'inspect, mount, and unmount' }, + { label: 'Compaction', description: 'surface replacement and reflow' }, + ], + }], + signal: controller.signal, + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await harness.terminal.waitForFrame(beforeQuestion) + await checkpoint('question-dialog', harness.terminal) + + await renderAfter(harness, () => { harness.terminal.send('\r') }) + await checkpoint('question-dialog-validation', harness.terminal) + controller.abort() + await rejected + await disposeSnapshot(harness) + }) + + it('pins compaction surface replacement and narrow-to-wide reflow', async () => { + let replacementStart = 0 + let replacementEnd = 0 + let replacementSources: number[] = [] + const harness = await setupSnapshot({ + tools: ADVANCED_CARD_TOOLS, + beforeMount(session) { + const user = session.append('user/message', { + content: [{ type: 'text', text: 'Old prompt with a long line that exercises wrapping before compaction.' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const assistant = session.append('assistant/message', { + turn: 1, + step: 0, + provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, + content: [{ type: 'tool-call', id: CallId('old-tool'), name: 'bash', arguments: '{}' }], + }, { surfaceOp: 'append' }) + session.append('tool/call', { turn: 1, step: 0, callId: CallId('old-tool'), name: 'bash', arguments: '{}' }) + const result = session.append('tool/result', { + turn: 1, + step: 0, + callId: CallId('old-tool'), + content: [{ type: 'text', text: 'obsolete output that must disappear' }], + isError: false, + }, { surfaceOp: 'append' }) + replacementStart = user.seq + replacementEnd = result.seq + replacementSources = [user.seq, assistant.seq, result.seq] + }, + }, { columns: 80, rows: 24 }) + await checkpoint('surface-before-compaction', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { + harness.session.append('context/message', { + content: [{ type: 'text', text: 'Compacted summary: the prior command completed and its details were retired from the active surface.' }], + source: { kind: 'plugin', plugin: 'compact' }, + }, { + surfaceOp: { op: 'replace', start: replacementStart, end: replacementEnd }, + sourceEventSeqs: replacementSources, + }) + harness.terminal.resize(44, 18) + }) + await checkpoint('surface-after-compaction-narrow', harness.terminal, { includeScrollback: true }) + + await renderAfter(harness, () => { harness.terminal.resize(104, 30) }) + await checkpoint('surface-after-compaction-wide', harness.terminal, { includeScrollback: true }) + await disposeSnapshot(harness) + }) + + it('pins help, unknown commands, live errors, turn failures, and terminal restoration', async () => { + const harness = await setupSnapshot({}, { columns: 92, rows: 32 }) + await renderAfter(harness, () => { + harness.terminal.send('/help') + harness.terminal.send('\r') + harness.terminal.send('/unknown-advanced-command') + harness.terminal.send('\r') + harness.ctx.emit('agent/error', harness.agent, 3, 1, new Error('provider stream failed after partial output')) + harness.session.append('turn/end', { + turn: 3, + reason: { kind: 'error', step: 1, message: 'provider stream failed after partial output' }, + }) + harness.session.append('turn/end', { + turn: 4, + reason: { kind: 'interrupted' }, + }) + }) + await checkpoint('errors-and-help', harness.terminal, { includeScrollback: true }) + + await harness.controller.dispose() + await harness.terminal.flush() + await checkpoint('disposed-terminal', harness.terminal, { includeScrollback: true }) + await harness.ctx.fiber.dispose() + await harness.terminal.dispose() + }) +}) + +afterAll(async () => { + expect([...observedCheckpoints].sort()).toEqual([...CHECKPOINTS].sort()) + const files = (await readdir(SNAPSHOTS_DIR)) + .filter(file => file.endsWith('.golden.txt')) + .sort() + expect(files).toEqual(CHECKPOINTS.map(name => `${name}.golden.txt`).sort()) +}) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 6cc8e60243..5fc11cbe40 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,18 +1,23 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' -import AgentRegistry, { AgentId, type Agent, type AgentStatus } from '@deepseek-ai/dsh-agent' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' +import AgentRegistry, { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import { createTuiChat, mountTui, resolveTuiConfig, - type Config, type TuiRuntime, } from '../src/index.ts' +import { + appendAssistant, + appendUser, + createTuiTestHarness, + disposeTuiTestHarness, + type TuiHarnessOptions, +} from './harness.ts' class FakeTerminal implements Terminal { columns = 88 @@ -84,97 +89,23 @@ class FakeTerminal implements Terminal { } } -interface FakeAgent extends Agent { - status: AgentStatus - sent: ContentBlock[][] - steered: ContentBlock[][] - cancelled: string[] -} - async function tick(): Promise { await new Promise(resolve => setTimeout(resolve, 25)) } -async function setup(options: { - status?: AgentStatus - config?: Config - tools?: Record - beforeMount?: (session: Session) => void - cwd?: string | null -} = {}) { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - await ctx.plugin(UserInteractionService) - const tools = options.tools ?? {} - ctx.provide('tools', { - get(name: string) { - return tools[name] - }, - } as never) - const session = ctx.sessions.create( - SessionId('main-session'), - options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? process.cwd() } }, - ) - options.beforeMount?.(session) - const sent: ContentBlock[][] = [] - const steered: ContentBlock[][] = [] - const cancelled: string[] = [] - const agent: FakeAgent = { - id: AgentId('main'), - options: { model: 'deepseek-v4-flash' }, - session, - status: options.status ?? 'idle', - ctx, - sent, - steered, - cancelled, - send(content) { - sent.push(content) - }, - steer(content) { - steered.push(content) - }, - inject() {}, - cancel(reason) { - cancelled.push(reason ?? '') - }, - whenIdle() { - return Promise.resolve() - }, - } - ctx.agents.register(agent) +async function setup(options: TuiHarnessOptions = {}) { const terminal = new FakeTerminal() const exit = vi.fn() - const controller = createTuiChat(ctx, Object.assign({ - welcome: 'Coding agent ready.', - agent: 'main', - color: false, - }, options.config), { terminal, exit }) + const result = await createTuiTestHarness(terminal, exit, { + ...options, + cwd: options.cwd === undefined ? process.cwd() : options.cwd, + }) await tick() - return { ctx, session, agent, terminal, exit, controller } + return result } async function dispose(setupResult: Awaited>): Promise { - await setupResult.controller.dispose() - await setupResult.ctx.fiber.dispose() -} - -function appendUser(session: Session, text: string): void { - session.append('user/message', { - content: [{ type: 'text', text }], - source: { kind: 'user' }, - }, { surfaceOp: 'append' }) -} - -function appendAssistant(session: Session, content: ContentBlock[], usage?: { inputTokens: number; outputTokens: number }): void { - session.append('assistant/message', { - turn: 1, - step: 0, - provenance: { provider: 'mock', model: 'deepseek-v4-flash' }, - content, - ...usage === undefined ? {} : { usage }, - }, { surfaceOp: 'append' }) + await disposeTuiTestHarness(setupResult) } describe('TUI config', () => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dccd293c9..164fbed86f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2069,12 +2069,27 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-cordis': + specifier: workspace:^ + version: link:../../cordis/tool-cordis + '@deepseek-ai/dsh-tool-workflow': + specifier: workspace:^ + version: link:../../workflow/tool-workflow '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction + '@deepseek-ai/dsh-workflow': + specifier: workspace:^ + version: link:../../workflow/workflow + '@xterm/headless': + specifier: 5.5.0 + version: 5.5.0 cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) @@ -4575,6 +4590,9 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + '@xterm/headless@5.5.0': + resolution: {integrity: sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -8867,6 +8885,8 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + '@xterm/headless@5.5.0': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 9753176f9d..18127b55ee 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -39,7 +39,11 @@ export default defineConfig({ // through the root tsconfig paths map; the native option cannot do this. plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { - include: ['examples/*/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts'], + include: [ + 'examples/*/tests/**/*.snapshot.ts', + 'packages/sdk/*/tests/**/*.snapshot.ts', + 'packages/ui/tui/tests/**/*.snapshot.ts', + ], // Each test boots a subprocess; give it room and keep the worker file singular. Replay tests // opt into bounded in-file concurrency, while record/refresh stay serial because they write // fixtures. The environment knob restores serial replay with value 1 on constrained machines. From bea6a74ea0f44d1d9e0f0b5dde331a23f4c552ff Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:09:20 +0800 Subject: [PATCH 140/163] refactor(examples): give TUI its own agent leaf --- docs/graph-atlas.md | 1 + examples/README.md | 8 ++++- examples/coding-agent/README.md | 8 ++--- examples/coding-agent/code-mode.cordis.yml | 2 ++ examples/coding-agent/composition.md | 2 +- examples/coding-agent/cordis.yml | 12 +++----- examples/tui-agent/README.md | 19 ++++++++++++ examples/tui-agent/code-mode.cordis.yml | 30 +++++++++++++++++++ examples/tui-agent/composition.md | 28 +++++++++++++++++ examples/tui-agent/cordis.yml | 28 +++++++++++++++++ examples/tui-agent/package.json | 7 +++++ .../tests/fixtures/tui-scripted-llm.ts | 0 .../tests/fixtures/tui-scripted.cordis.yml | 0 .../tests/tui-keyless-smoke.e2e.ts | 8 ++--- knip.json | 2 +- package.json | 1 + scripts/demo-code-mode.mjs | 7 +++-- scripts/gen-doc-graphs.ts | 21 +++++++++++-- 18 files changed, 159 insertions(+), 25 deletions(-) create mode 100644 examples/tui-agent/README.md create mode 100644 examples/tui-agent/code-mode.cordis.yml create mode 100644 examples/tui-agent/composition.md create mode 100644 examples/tui-agent/cordis.yml create mode 100644 examples/tui-agent/package.json rename examples/{coding-agent => tui-agent}/tests/fixtures/tui-scripted-llm.ts (100%) rename examples/{coding-agent => tui-agent}/tests/fixtures/tui-scripted.cordis.yml (100%) rename examples/{coding-agent => tui-agent}/tests/tui-keyless-smoke.e2e.ts (95%) diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 60de01ef81..5220cfa81f 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -14,6 +14,7 @@ The process decision behind this index is recorded in [the documentation graph R | [capability seams and core services](capability-seams.md) | `hybrid generated` | | [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | | [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` | +| [tui-agent app composition](../examples/tui-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | | [event producer/consumer matrix](event-producer-consumer.md) | `hybrid generated` | diff --git a/examples/README.md b/examples/README.md index 25c017824f..03b798e164 100644 --- a/examples/README.md +++ b/examples/README.md @@ -15,12 +15,18 @@ Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigge ## coding-agent -A coding-agent demo: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the same `@deepseek-ai/dsh-stdio-demo` app. Interactive runs use the pi-tui coding interface; pipes use readline. +A coding-agent REPL: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door. Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task. +## tui-agent + +The full-screen terminal sibling of `coding-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios. + +Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. + ## cordis-agent The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the live cordis runtime it runs inside, mount model-written plugins into it (an event listener, a brand-new tool for itself, or a service another mount injects), and dispose them again — all dynamic mounts grouped under one `cordis-dynamic` fiber subtree. The `ctx.fs`/`ctx.web` services ride along provider-only, as the capabilities those plugins build on. diff --git a/examples/coding-agent/README.md b/examples/coding-agent/README.md index 9fdadf809d..f94cd31adb 100644 --- a/examples/coding-agent/README.md +++ b/examples/coding-agent/README.md @@ -1,6 +1,6 @@ # coding-agent -The coding-agent demo wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + terminal chat + JSONL persistence, loaded from `cordis.yml`. Interactive runs use the pi-tui coding interface; piped runs use readline. +The coding-agent REPL wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. ## Run it @@ -13,7 +13,7 @@ pnpm run demo:repl Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork` and track multi-step work with `todo_write`. -The TUI renders resumed Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay rather than taking over the editor. +The REPL renders reasoning, tool calls/results, and the latest todo list as line-oriented output suitable for terminals and pipes. Use `pnpm run demo:tui` for the interactive Markdown/card interface. ### Resuming a prior session @@ -42,14 +42,14 @@ and watch the transcript: one `run_code` call, a program looping over tools, and ## What each leaf entry demonstrates -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, TTY-selected `dsh-tui`/`dsh-stdio` channels, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: +This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the selected terminal channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: | Entry | Demonstrates | |---|---| | `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it is Loader-only and needs `node --expose-internals`, which `demo:repl` passes | | `llm-deepseek` | real `LlmAdapter` via config (`!!js process.env.…` secrets); swap one line to `@deepseek-ai/dsh-llm-pi-ai` for the library-backed twin | | `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + TTY-selected `dsh-tui`/`dsh-stdio` channels + a pre-created `main` agent. Its config carries the model, system prompt, `persistenceRoot` (`./.sessions`), `resumeSessionId`, and optional `ui` presentation settings | +| `stdio-agent` (`@deepseek-ai/dsh-stdio-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the configured terminal channel + a pre-created `main` agent. This leaf fixes `ui.mode` to `readline`; `tui-agent` owns the corresponding TUI leaf | | `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | | `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | | `tool-todo` | the model-facing `todo_write` tool; writes the whole task list to the session log and renders as a persistent TUI plan or readline checklist | diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/coding-agent/code-mode.cordis.yml index 3fec40e0f3..b1f62829bc 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/coding-agent/code-mode.cordis.yml @@ -20,6 +20,8 @@ tools: mode: code welcome: 'code-mode agent ready. Give it a multi-tool task.' + ui: + mode: readline persona: | You are coding-agent, a coding assistant powered by the {{model}} model. diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md index d0411e43d9..2956291dc8 100644 --- a/examples/coding-agent/composition.md +++ b/examples/coding-agent/composition.md @@ -18,7 +18,7 @@ flowchart LR cfg --> plugin_coding_stdio_agent plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_coding_stdio_agent --> frontdoor_stdio["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"] + plugin_coding_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio
pre-created main agent"] bundle_agent_core --> spine_llm["ctx.llm"] bundle_agent_core --> spine_sessions["ctx.sessions"] bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index d3da67ce29..497e2a80c8 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -1,7 +1,6 @@ -# Coding agent with swappable DeepSeek and local-bash backends. `dsh-stdio-demo` -# supplies the agent spine, workspace instructions, generic task controls, -# JSONL persistence, TTY-selected `dsh-tui`/`dsh-stdio` terminal front doors, -# readline logging, and `main` agent. +# Readline coding REPL with swappable DeepSeek and local-bash backends. +# `dsh-stdio-demo` supplies the agent spine, workspace instructions, generic +# task controls, JSONL persistence, the line-oriented front door, and `main`. # HMR remains a leaf because it requires Loader internals; `demo:repl` passes # `--expose-internals`. The app bin loads the gitignored root `.env`; this file # reads `DEEPSEEK_API_KEY` and optional `DEEPSEEK_BASE_URL` through `!!js`. @@ -39,10 +38,7 @@ maxBytes: 65536 welcome: 'agent REPL ready. Give it a coding task.' ui: - mode: auto - tui: - showReasoning: true - maxToolOutputLines: 12 + mode: readline # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} from this agent's configuration. persona: | diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md new file mode 100644 index 0000000000..909ccf700d --- /dev/null +++ b/examples/tui-agent/README.md @@ -0,0 +1,19 @@ +# tui-agent + +The full-screen terminal counterpart to the [`coding-agent`](../coding-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door. + +## Run it + +```sh +pnpm run demo:tui +``` + +The command needs `DEEPSEEK_API_KEY` in the environment or the gitignored repository-root `.env`. Set `RESUME_SESSION_ID` to reopen a persisted conversation under `./.sessions`. + +The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and the latest todo list. Enter submits or steers while the agent is running; Ctrl+O expands cards, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `ask_user_question` opens a keyboard-driven overlay. + +Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay. + +## Composition + +[`cordis.yml`](cordis.yml) includes the readline coding-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the coding agent's Code Mode overlay. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml new file mode 100644 index 0000000000..7bb7932ba0 --- /dev/null +++ b/examples/tui-agent/code-mode.cordis.yml @@ -0,0 +1,30 @@ +# Code Mode keeps the TUI front door while reusing the coding-agent overlay's +# worker runtime and one-tool registry composition. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../coding-agent/code-mode.cordis.yml + patches: + - id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: deepseek + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + tools: + mode: code + welcome: 'TUI Code Mode ready. Give it a multi-tool task.' + ui: + mode: tui + tui: + showReasoning: true + maxToolOutputLines: 12 + persona: | + You are coding-agent, a coding assistant powered by the {{model}} model. + + You work by writing TypeScript programs for run_code: batch related + tool work into one program, loop and branch where it helps, and print + or return ONLY the findings that matter. diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md new file mode 100644 index 0000000000..511f76d41e --- /dev/null +++ b/examples/tui-agent/composition.md @@ -0,0 +1,28 @@ + + +# TUI Agent App Composition + +The TUI agent reuses the coding-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door. + +```mermaid +flowchart LR + cfg["examples/tui-agent
cordis.yml"] + plugin_tui_base["base
@deepseek-ai/dsh-stdio-demo"] + cfg --> plugin_tui_base + plugin_tui_base --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_tui_base --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_tui_base --> frontdoor_stdio["@deepseek-ai/dsh-tui
pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] +``` + +| Plugin id | Package / module | +| --- | --- | +| `base` | `@deepseek-ai/dsh-stdio-demo` | + +Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml new file mode 100644 index 0000000000..0f329c38e2 --- /dev/null +++ b/examples/tui-agent/cordis.yml @@ -0,0 +1,28 @@ +# Full-screen TUI front door over the same coding-agent composition used by the +# readline REPL. The include keeps backends and optional tools aligned; the +# patch owns only the terminal-specific app config. +- id: base + name: '@cordisjs/plugin-include' + config: + path: ../coding-agent/cordis.yml + patches: + - id: stdio-agent + name: '@deepseek-ai/dsh-stdio-demo' + config: + provider: deepseek + model: deepseek-v4-flash + resumeSessionId: !!js process.env.RESUME_SESSION_ID + persistenceRoot: './.sessions' + workspaceContext: + maxBytes: 65536 + welcome: 'TUI agent ready. Give it a coding task.' + ui: + mode: tui + tui: + showReasoning: true + maxToolOutputLines: 12 + persona: | + You are coding-agent, a coding assistant powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. diff --git a/examples/tui-agent/package.json b/examples/tui-agent/package.json new file mode 100644 index 0000000000..f45e6746a3 --- /dev/null +++ b/examples/tui-agent/package.json @@ -0,0 +1,7 @@ +{ + "name": "tui-agent-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable demo: the coding agent through the full-screen terminal UI" +} diff --git a/examples/coding-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts similarity index 100% rename from examples/coding-agent/tests/fixtures/tui-scripted-llm.ts rename to examples/tui-agent/tests/fixtures/tui-scripted-llm.ts diff --git a/examples/coding-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml similarity index 100% rename from examples/coding-agent/tests/fixtures/tui-scripted.cordis.yml rename to examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml diff --git a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts similarity index 95% rename from examples/coding-agent/tests/tui-keyless-smoke.e2e.ts rename to examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index cc2397c069..26cee8aea2 100644 --- a/examples/coding-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -53,7 +53,7 @@ while time.monotonic() < deadline: if scenario == "conversation" and answered_question and not sent_exit and b"Decision received. Scripted TUI run complete." in output: os.write(fd, b"/exit\r") sent_exit = True - if scenario == "boot" and not sent_exit and b"agent REPL ready." in output: + if scenario == "boot" and not sent_exit and b"TUI agent ready." in output: os.write(fd, b"/exit\r") sent_exit = True waited, candidate = os.waitpid(pid, os.WNOHANG) @@ -101,7 +101,7 @@ interface TuiLoaderSmokeOptions { } async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { - const cwd = await mkdtemp(join(tmpdir(), 'coding-tui-smoke-')) + const cwd = await mkdtemp(join(tmpdir(), 'tui-agent-smoke-')) try { const launch = resolveExampleLaunch({ srcBin: binScript, @@ -142,11 +142,11 @@ async function runTuiLoaderSmoke(options: TuiLoaderSmokeOptions = {}): Promise { +describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, renders the configured banner, accepts /exit, and restores the terminal', async () => { const output = await runTuiLoaderSmoke() expect(output).toContain('DEEPSEEK') - expect(output).toContain('agent REPL ready.') + expect(output).toContain('TUI agent ready.') expect(output).toContain('\u001B[?2004l') }, LOADER_SMOKE_TEST_TIMEOUT_MS) diff --git a/knip.json b/knip.json index 03fae14896..db77587b48 100644 --- a/knip.json +++ b/knip.json @@ -10,7 +10,7 @@ "examples": { "entry": [ "echo-agent/src/*.ts", - "coding-agent/tests/fixtures/tui-scripted-llm.ts", + "tui-agent/tests/fixtures/tui-scripted-llm.ts", "*/tests/**/*.e2e.ts", "*/tests/**/*.snapshot.ts" ], diff --git a/package.json b/package.json index 39d6a53161..ca85fd32d2 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/coding-agent/cordis.yml", + "demo:tui": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index edfc26b4d5..7a5e054cc0 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -1,7 +1,7 @@ /** - * Boot the REPL or ACP Code Mode overlay, defaulting to REPL. Each overlay + * Boot the REPL, TUI, or ACP Code Mode overlay, defaulting to REPL. Each overlay * includes its base example, selects Code Mode, and adds the worker runtime. - * Both require a DeepSeek API key; unsupported arguments fail with usage. + * All require a DeepSeek API key; unsupported arguments fail with usage. */ import { spawn } from 'node:child_process' @@ -10,13 +10,14 @@ import { spawn } from 'node:child_process' // Loader's HMR path). const UIS = new Map([ ['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']], + ['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) const ui = process.argv[2] ?? 'repl' const args = UIS.get(ui) if (!args || process.argv.length > 3) { - console.error('usage: pnpm run demo:code-mode [repl|acp]') + console.error('usage: pnpm run demo:code-mode [repl|tui|acp]') process.exit(2) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 5bf0a9456c..29cea3f9a0 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -434,6 +434,14 @@ const APP_EXAMPLES = [ config: 'examples/coding-agent/cordis.yml', summary: 'The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', }, + { + id: 'tui', + rel: 'examples/tui-agent/composition.md', + title: 'TUI Agent App Composition', + label: 'examples/tui-agent', + config: 'examples/tui-agent/cordis.yml', + summary: 'The TUI agent reuses the coding-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.', + }, { id: 'cordis', rel: 'examples/cordis-agent/composition.md', @@ -454,13 +462,18 @@ const APP_EXAMPLES = [ type AppExample = typeof APP_EXAMPLES[number] -function renderAppExpansion(lines: string[], appNode: string, pluginName: string): void { +function renderAppExpansion(lines: string[], appNode: string, pluginName: string, exampleId: string): void { const agentCore = nodeId('bundle', 'agent_core') const jsonl = nodeId('bundle', 'jsonl') lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) if (pluginName === '@deepseek-ai/dsh-stdio-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent"]`) + const frontDoor = exampleId === 'tui' + ? '@deepseek-ai/dsh-tui
pre-created main agent' + : exampleId === 'coding' + ? '@deepseek-ai/dsh-stdio
pre-created main agent' + : 'dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent' + lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
JSON-RPC stdio bridge
sessions created by client"]`) } @@ -488,7 +501,7 @@ function renderAppComposition(example: AppExample): string { lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${escLabel(plugin.name)}"]`) lines.push(` cfg --> ${pluginNode}`) if (plugin.name === '@deepseek-ai/dsh-stdio-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { - renderAppExpansion(lines, pluginNode, plugin.name) + renderAppExpansion(lines, pluginNode, plugin.name, example.id) } } lines.push( @@ -978,6 +991,7 @@ function renderIndex(docs: GraphDoc[]): string { 'docs/capability-seams.md': 'capability seams and core services', 'examples/echo-agent/composition.md': 'echo-agent app composition', 'examples/coding-agent/composition.md': 'coding-agent app composition', + 'examples/tui-agent/composition.md': 'tui-agent app composition', 'examples/cordis-agent/composition.md': 'cordis-agent app composition', 'examples/acp-agent/composition.md': 'acp-agent app composition', 'docs/event-producer-consumer.md': 'event producer/consumer matrix', @@ -989,6 +1003,7 @@ function renderIndex(docs: GraphDoc[]): string { 'docs/capability-seams.md': 'hybrid generated', 'examples/echo-agent/composition.md': 'hybrid generated', 'examples/coding-agent/composition.md': 'hybrid generated', + 'examples/tui-agent/composition.md': 'hybrid generated', 'examples/cordis-agent/composition.md': 'hybrid generated', 'examples/acp-agent/composition.md': 'hybrid generated', 'docs/event-producer-consumer.md': 'hybrid generated', From e68cef1be1dff8cde8a04556969af400ecb964fa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:31:55 +0800 Subject: [PATCH 141/163] test(tui): replay recorded sessions through real tools --- ...-18-tui-terminal-state-snapshots.i18n.yaml | 4 +- ...2026-07-18-tui-terminal-state-snapshots.md | 67 ++-- ...6-07-18-tui-terminal-state-snapshots.zh.md | 65 ++-- docs/testing.md | 4 +- examples/tui-agent/README.md | 4 + .../bash-terminal-card/session.jsonl | 98 ++++++ .../bash-terminal-card/terminal.golden.txt | 72 ++++ .../tests/snapshots/code-mode/session.jsonl | 150 ++++++++ .../snapshots/code-mode/terminal.golden.txt | 79 +++++ .../cordis-dynamic-toolchain/session.1.jsonl | 13 + .../cordis-dynamic-toolchain/session.2.jsonl | 13 + .../cordis-dynamic-toolchain/session.jsonl | 64 ++++ .../terminal.golden.txt | 115 ++++++ .../dynamic-workflow/session.1.jsonl | 36 ++ .../snapshots/dynamic-workflow/session.jsonl | 209 +++++++++++ .../dynamic-workflow/terminal.golden.txt | 105 ++++++ .../multi-turn-conversation/session.jsonl | 65 ++++ .../terminal.golden.txt | 69 ++++ .../parallel-file-reads/session.jsonl | 28 ++ .../parallel-file-reads/terminal.golden.txt | 94 +++++ .../parallel-file-reads/workspace/a.txt | 1 + .../parallel-file-reads/workspace/b.txt | 1 + .../tests/snapshots/todo-plan/session.jsonl | 134 +++++++ .../snapshots/todo-plan/terminal.golden.txt | 81 +++++ examples/tui-agent/tests/tui.snapshot.ts | 329 ++++++++++++++++++ .../snapshots/code-mode-complete.golden.txt | 50 --- .../conversation-complete.golden.txt | 75 ---- .../snapshots/conversation-replay.golden.txt | 73 ---- .../conversation-streaming.golden.txt | 81 ++--- .../cordis-tools-complete.golden.txt | 73 ---- .../dynamic-workflow-complete.golden.txt | 53 --- packages/ui/tui/tests/tui.snapshot.ts | 57 +-- 32 files changed, 1874 insertions(+), 488 deletions(-) create mode 100644 examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt create mode 100644 examples/tui-agent/tests/snapshots/code-mode/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt create mode 100644 examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl create mode 100644 examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl create mode 100644 examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt create mode 100644 examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl create mode 100644 examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt create mode 100644 examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt create mode 100644 examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt create mode 100644 examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt create mode 100644 examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt create mode 100644 examples/tui-agent/tests/snapshots/todo-plan/session.jsonl create mode 100644 examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt create mode 100644 examples/tui-agent/tests/tui.snapshot.ts delete mode 100644 packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt delete mode 100644 packages/ui/tui/tests/snapshots/conversation-complete.golden.txt delete mode 100644 packages/ui/tui/tests/snapshots/conversation-replay.golden.txt delete mode 100644 packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt delete mode 100644 packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index 5b682b642b..b68f7e7e78 100644 --- a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-18-tui-terminal-state-snapshots.md: 225efe8de95973ecae3f9bf73308e7ae879dddbb -2026-07-18-tui-terminal-state-snapshots.zh.md: b7399a31b0abf41905f495a1ea3b4ecd0c858526 +2026-07-18-tui-terminal-state-snapshots.md: 280c2b4faec3bd5e14a24a5df7bc901ad31718cd +2026-07-18-tui-terminal-state-snapshots.zh.md: d1c609c6ce511bba1f498b72444af662b4444578 diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 225efe8de9..280c2b4fae 100644 --- a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -8,50 +8,63 @@ English | [中文](2026-07-18-tui-terminal-state-snapshots.zh.md) The TUI is a stateful renderer. Its user-visible result depends on ANSI parsing, differential frames, wrapping, scrollback, viewport position, terminal width, focus, cursor state, and each tool's presentation intent. Unit tests that collect `Terminal.write()` fragments can prove event handling, but they cannot prove the final screen a terminal displays. The same screen may also be emitted through different write fragments, so pinning those fragments creates false regressions. -Component-line snapshots stop before ANSI reaches a terminal and miss cursor movement, clearing, styling, overlay composition, and reflow. Raster screenshots include font and platform rendering noise that is unrelated to the TUI contract. The TUI therefore needs a deterministic, reviewable representation of terminal state plus a smaller test at the real process and PTY boundary. +Component-line snapshots stop before ANSI reaches a terminal and miss cursor movement, clearing, styling, overlay composition, and reflow. Raster screenshots include font and platform rendering noise that is unrelated to the TUI contract. A completed flow built by directly appending plausible session events has another blind spot: it proves the renderer accepts those shapes, not that the production agent loop and tool implementations produce them. + +The TUI therefore needs a deterministic, reviewable representation of terminal state, recorded model journeys that execute the real downstream stack, and a smaller test at the real process and PTY boundary. ## Decision -TUI coverage has three complementary layers: +TUI coverage has four complementary layers: -1. `tui.spec.ts` tests event mapping, input routing, disposal, and error behavior directly. -2. `tui.snapshot.ts` mounts the production TUI against a headless terminal emulator and compares semantic terminal-state goldens. -3. `tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a complete scripted conversation through streaming and `ask_user_question`, exits through `/exit`, and verifies terminal teardown. The production coding-agent configuration also retains its banner/exit and startup-failure PTY cases. +1. `packages/ui/tui/tests/tui.spec.ts` tests event mapping, input routing, disposal, and error behavior directly. +2. `packages/ui/tui/tests/tui.snapshot.ts` mounts the production TUI against a headless terminal emulator for transient states that a completed session log cannot retain: in-flight streaming, pending tool calls, overlays, expansion, compaction reflow, errors, and shutdown. +3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. +4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. A snapshot waits for pi-tui's synchronized-output end marker before reading state. This makes a checkpoint represent a completed frame rather than a timer-dependent write prefix. +The runnable TUI has its own `examples/tui-agent` leaf beside the readline `coding-agent` and `acp-agent` leaves. It reuses the coding agent's backend and tool composition through an asserted include patch while fixing the shared terminal app to `ui.mode: tui`; TUI snapshots and PTY tests live with that leaf. -Each golden projects terminal state into text: dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes. +### Recorded-session replay -Every checkpoint also enforces theme independence across the complete terminal state: no RGB colors, no palette entries beyond ANSI 0–15, and no explicit background colors. Reverse video remains valid for selection because it uses terminal defaults. The suite owns a closed checkpoint list: its type rejects undeclared names, and its inventory checks reject missing checkpoints and orphaned `.golden.txt` files. +Each example-level scenario directory owns `session.jsonl`, optional child logs `session..jsonl`, and `terminal.golden.txt`. The primary log supplies user-authored `user/message` prompts and the recorded `assistant/chunk` sequence. `dsh-llm-replay` derives one model-call script per session, binds child logs to fresh child sessions, and is the only mocked boundary. The agent loop, bash and filesystem implementations, Code Mode worker, subagent provider, workflow worker, Cordis tools, presenters, and TUI are production implementations. + +The suite rejects a journey when its tool-call sequence differs, an expected event count is missing, a tool result is an error, a turn ends in error, a workflow lifecycle is incomplete, or the live child-session count differs from the fixture set. These assertions prevent an attractive terminal golden from hiding a failed or bypassed production path. + +The live-model fixtures use `DSH_SNAPSHOT=record`; record mode rewrites their primary and child JSONL logs and terminal goldens. The deterministic Cordis toolchain keeps an authored complete JSONL script because reliably coercing a live model through five exact tool boundaries and two children is not a stable recording contract. `DSH_SNAPSHOT=refresh` replays every committed script keylessly and rewrites only derived terminal goldens. Plain replay compares without writing, and unknown mode values fail loud. + +### Semantic terminal projection + +The package-local `HeadlessTerminal` implements the same pi-tui `Terminal` interface as the process terminal and feeds every ANSI write into the pinned `@xterm/headless` parser. Snapshot code waits for synchronized frames to quiesce before reading state, so a checkpoint represents a completed screen rather than a timer-dependent write prefix. + +Each golden projects dimensions, active-buffer and viewport coordinates, lifecycle and cursor state, rows, wrap markers, and non-default style ranges into text. Scroll-heavy cards capture the used buffer; overlays capture the visible viewport. Text and style remain separate so a reviewer can distinguish content changes from presentation changes without decoding ANSI bytes. + +Every checkpoint enforces theme independence across the complete terminal state: no RGB colors, no palette entries beyond ANSI 0–15, and no explicit background colors. Reverse video remains valid for selection because it uses terminal defaults. Both suites own closed inventories that reject missing scenarios, missing checkpoints, and orphaned golden files. ### Required scenario matrix -| Area | Representative checkpoints | Contract pinned | +| Layer | Scenario | Contract pinned | |---|---|---| -| Conversation | replay, streaming, completion | Resumed Markdown and reasoning, live deltas, plans, token usage, and max-token completion | -| Code Mode | pending and completed `run_code` | The production Code Mode registry and presenter, source program, captured logs, and result | -| Dynamic workflows | pending and completed `workflow` | The production workflow presenter, metadata, phases, parallel agents, script, and structured result | -| Cordis tools | pending and completed inspect/mount/unmount | The production `cordis_inspect`, `cordis_mount`, and `cordis_unmount` presenters and lifecycle results | -| Advanced tool cards | collapsed and expanded | Terminal, diff, generic, subagent, background-task, and skill card shapes plus output truncation | -| Interaction | question and validation | Constrained multi-select overlay composition, focus, scrolling, selection, and validation errors | -| Surface and layout | before compaction, narrow replacement, wide replacement | Surface replacement removes retired content; resize reflows the surviving surface without resurrection | -| Failure and shutdown | errors/help and disposed terminal | Help and unknown commands, live/turn error de-duplication, interruption, cursor restoration, and terminal stop | - -The explicitly model-facing advanced cases use the real `ToolRegistry` configuration and the production Code Mode, workflow, and Cordis tool presenters. Synthetic presenter fixtures are limited to the generic card-shape matrix, where the TUI's input contract is the presenter view itself. Session events remain the driver so replay, streaming, result arrival, surface replacement, and lifecycle ordering exercise the same projection path as production. - -The TUI suite is included by `vitest.snapshot.config.ts`, so `pnpm run test:snapshot` compares it keylessly. `pnpm run test:snapshot:refresh` rewrites its derived terminal goldens without contacting a model; `test:snapshot:record` remains meaningful for suites whose transcript source requires recording. Both refresh paths still compare the resulting files in the same run. +| Recorded journey | Multi-turn conversation | Recorded reasoning/text chunks, two input turns, retained history, token totals, and idle editor state | +| Recorded journey | Todo plan | Real `todo_write` execution, result card, and persistent plan rendering | +| Recorded journey | Bash terminal card | Real local executor output, description, exit status, and completed terminal card | +| Recorded journey | Parallel filesystem reads | Two calls from one assistant message, real file contents, ordering, and separate completed cards | +| Recorded journey | Code Mode | Real `run_code` worker execution, two `tool/code-dispatch` events, captured program output, and completed card | +| Recorded journey | Dynamic workflow | Real workflow worker, phase lifecycle, replayed child session, structured return value, and completed card | +| Recorded journey | Cordis dynamic toolchain | Real mount, Code Mode inspect, direct subagent, workflow child, unmount, and all production presenters | +| Transient state | Streaming and pending advanced calls | In-flight reasoning/text plus pending Code Mode, workflow, and Cordis cards that disappear from completed logs | +| Transient state | Cards, interaction, layout, failure, and shutdown | Collapsed/expanded card families, question validation, compaction replacement, resize reflow, help/errors, cursor restoration, and terminal stop | ## Alternatives considered - **Snapshot raw terminal writes** — rejected because differential rendering may change write boundaries without changing the screen, while cursor and clear sequences are unreadable in review. -- **Snapshot component render lines before terminal output** — rejected because it does not test ANSI parsing, cursor movement, overlays, viewport behavior, or the interaction between independent components in one frame. +- **Snapshot component render lines before terminal output** — rejected because it does not test ANSI parsing, cursor movement, overlays, viewport behavior, or independent components in one frame. +- **Build every completed flow by appending session events** — rejected because a hand-authored event sequence can drift from the agent loop, tool execution, child-session binding, or worker behavior while its presentation test stays green. Direct event construction remains limited to transient renderer states. +- **Reuse ACP stdout goldens as the TUI oracle** — rejected because a recorded model journey is transport-neutral but its presentation is not. TUI scenarios own terminal goldens while using the same JSONL replay vocabulary. - **Commit raster screenshots** — rejected because fonts, glyph metrics, antialiasing, and host terminal themes make them platform-sensitive and make semantic style changes difficult to review. - **Use only PTY end-to-end tests** — rejected because raw PTY output is a stream of historical drawing operations, not queryable final state. PTY tests retain the real Loader/input/teardown boundary, while the emulator owns broad state coverage. -- **Copy pi-tui's unpublished virtual-terminal test helper** — rejected because the installed package does not export that helper. A small adapter around the public `@xterm/headless` API keeps the dependency explicit and the projection owned by this package. ## Consequences -- TUI visual regressions produce readable cell-and-style diffs, and the required matrix makes advanced features first-class rather than incidental coverage. -- The test dependency is pinned to the xterm version used by pi-tui. The adapter uses xterm's proposed buffer API, so an xterm upgrade requires rerunning and reviewing the semantic projection. -- The emulator models ANSI terminal state but cannot prove behavior unique to every terminal implementation. The real PTY conversation covers process selection, keyboard input, user interaction, and teardown without duplicating the full matrix. -- Goldens deliberately encode wrapping and viewport behavior at fixed sizes. Intentional layout changes update them through the keyless refresh command and receive ordinary snapshot review. +- Completed advanced snapshots now fail when the real Code Mode, workflow, subagent, filesystem, bash, or Cordis path breaks, rather than accepting a fabricated result event. +- TUI visual regressions produce readable cell-and-style diffs, while JSONL fixtures retain the exact model chunks that made the production path execute. +- The emulator uses xterm's proposed buffer API. An xterm upgrade requires rerunning and reviewing the semantic projection; terminal-specific behavior still needs the PTY smoke. +- Goldens deliberately encode wrapping and viewport behavior at fixed sizes. Intentional layout changes use keyless refresh, while model-journey changes use record mode and review both JSONL and terminal diffs. diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index b7399a31b0..d1c609c6ce 100644 --- a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -8,50 +8,63 @@ Status: implemented TUI 是有状态的渲染器。用户最终看到的结果取决于 ANSI 解析、差分帧、换行、回滚缓冲、视口位置、终端宽度、焦点、光标状态,以及各工具的呈现意图。收集 `Terminal.write()` 片段的单元测试可以验证事件处理,却无法验证终端最终显示的画面。同一画面也可能由不同的写入片段产生,因此固定这些片段会制造误报。 -组件行快照止于 ANSI 进入终端之前,无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。因此,TUI 既需要一种确定、便于评审的终端状态表示,也需要一项范围更小、覆盖真实进程与 PTY 边界的测试。 +组件行快照止于 ANSI 进入终端之前,无法覆盖光标移动、清屏、样式、浮层组合和重排。栅格截图会带入与 TUI 契约无关的字体和平台渲染噪声。直接追加看似合理的会话事件来构造完整流程还存在另一处盲区:这种测试只能证明渲染器接受这些数据形态,无法证明生产环境的 agent loop(智能体循环)和工具实现会生成这些事件。 + +因此,TUI 既需要确定、便于评审的终端状态表示,也需要通过已录制模型流程执行真实下游组件,并保留一项范围更小、覆盖真实进程与 PTY 边界的测试。 ## 决策 -TUI 覆盖分为三个互补层次: +TUI 覆盖分为四个互补层次: -1. `tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。 -2. `tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器,并比较语义终端状态金标。 -3. `tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段完整的脚本化会话,使其依次经过流式输出和 `ask_user_question`,再通过 `/exit` 退出并验证终端清理。生产 coding-agent 配置还保留欢迎信息与退出,以及启动失败两类 PTY 场景。 +1. `packages/ui/tui/tests/tui.spec.ts` 直接测试事件映射、输入路由、资源释放和错误行为。 +2. `packages/ui/tui/tests/tui.snapshot.ts` 将生产 TUI 挂载到无界面终端模拟器,覆盖完整会话日志无法保留的瞬态:进行中的流式输出、待完成工具调用、浮层、展开状态、压缩重排、错误和关闭过程。 +3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 +4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。快照会等待 pi-tui 的同步输出结束标记,再读取状态。因此,每个检查点表示已经完成的帧,而不是依赖计时的写入前缀。 +可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 readline `coding-agent` 和 `acp-agent` 叶节点并列。它通过带断言的 include patch 复用 coding agent 的后端与工具组合,只把共享终端应用固定为 `ui.mode: tui`;TUI 快照和 PTY 测试也归属这个叶节点。 -每份金标把终端状态投影为文本:尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记,以及非默认样式区间。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 +### 已录制会话回放 -每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。测试套件拥有封闭的检查点清单:类型会拒绝未声明的名称,清单检查会拒绝缺失的检查点和遗留的 `.golden.txt` 文件。 +每个示例级场景目录都包含 `session.jsonl`、可选的子会话日志 `session..jsonl`,以及 `terminal.golden.txt`。主日志提供用户来源的 `user/message` 提示词和已录制的 `assistant/chunk` 序列。`dsh-llm-replay` 为每个会话派生一份模型调用脚本,并将子日志绑定到新建的子会话;这是测试中唯一的 mock 边界。agent loop、bash 与文件系统实现、Code Mode worker、subagent 提供方、工作流 worker、Cordis 工具、呈现器和 TUI 都使用生产实现。 + +如果工具调用顺序不符、预期事件数量不足、工具结果报错、轮次以错误结束、工作流生命周期不完整,或者实时子会话数量与 fixture(测试前置数据)集合不一致,测试都会失败。即使终端金标表面正确,这些断言也能阻止失败或被绕过的生产路径混入结果。 + +真实模型 fixture 通过 `DSH_SNAPSHOT=record` 更新;录制模式会重写其主会话与子会话 JSONL 日志以及终端金标。确定性的 Cordis 工具链保留一份人工编写的完整 JSONL 脚本,因为要求真实模型稳定经过五个指定工具边界和两个子会话并不是可靠的录制契约。`DSH_SNAPSHOT=refresh` 会无密钥回放所有已提交脚本,并且只重写派生的终端金标。普通回放只比较而不写入,未知模式值会快速失败。 + +### 语义终端投影 + +包内的 `HeadlessTerminal` 实现与进程终端相同的 pi-tui `Terminal` 接口,并把每次 ANSI 写入交给固定版本的 `@xterm/headless` 解析器。读取状态前,快照代码会等待同步帧稳定,因此每个检查点表示已经完成的画面,而不是依赖计时的写入前缀。 + +每份金标把终端尺寸、活动缓冲区和视口坐标、生命周期与光标状态、各行、换行标记以及非默认样式区间投影为文本。滚动内容较多的卡片捕获已使用缓冲区;浮层捕获可见视口。文本和样式相互分离,评审人无需解码 ANSI 字节即可区分内容变化与呈现变化。 + +每个检查点还会对完整终端状态强制执行主题无关性:禁止 RGB 颜色、禁止 ANSI 0–15 以外的调色板项,也禁止显式背景色。选择行使用终端默认色进行反显,因此仍然有效。两套测试都拥有封闭清单,会拒绝缺失的场景、缺失的检查点和遗留金标文件。 ### 必需场景矩阵 -| 范围 | 代表性检查点 | 固定的契约 | +| 层次 | 场景 | 固定的契约 | |---|---|---| -| 会话 | 回放、流式输出、完成 | 恢复后的 Markdown 与推理、实时增量、计划、token 用量,以及达到 token 上限时的完成状态 | -| Code Mode | `run_code` 待完成与已完成 | 生产 Code Mode 注册表与呈现器、源程序、捕获日志和结果 | -| 动态工作流 | `workflow` 待完成与已完成 | 生产工作流呈现器、元数据、阶段、并行 agent、脚本和结构化结果 | -| Cordis 工具 | inspect/mount/unmount 待完成与已完成 | 生产 `cordis_inspect`、`cordis_mount` 和 `cordis_unmount` 呈现器及其生命周期结果 | -| 高级工具卡片 | 折叠与展开 | 终端、diff、通用、subagent、后台任务和 skill 卡片形态,以及输出截断 | -| 交互 | 问题与校验 | 受限多选浮层的组合、焦点、滚动、选择和校验错误 | -| 表层与布局 | 压缩前、窄幅替换、宽幅替换 | 表层替换会移除退役内容;调整尺寸只会重排保留的表层,不会让旧内容重新出现 | -| 失败与关闭 | 错误与帮助、终端已释放 | 帮助与未知命令、实时错误和轮次错误去重、中断、光标恢复及终端停止 | - -面向模型的高级场景明确使用真实 `ToolRegistry` 配置,以及生产 Code Mode、工作流和 Cordis 工具呈现器。只有通用卡片形态矩阵使用合成呈现器 fixture;在这里,呈现器视图本身就是 TUI 的输入契约。测试仍由会话事件驱动,因此回放、流式输出、结果到达、表层替换和生命周期顺序都会经过与生产环境相同的投影路径。 - -`vitest.snapshot.config.ts` 会包含 TUI 测试套件,因此 `pnpm run test:snapshot` 可以无密钥比较快照。`pnpm run test:snapshot:refresh` 会重写从终端状态派生的金标,而不会联系模型;对于 transcript(文本记录)来源需要录制的测试套件,`test:snapshot:record` 仍有其原有含义。两条刷新路径都会在同一次运行中继续比较生成后的文件。 +| 已录制流程 | 多轮会话 | 已录制的推理与文本分片、两轮输入、保留历史、token 总量和空闲编辑器状态 | +| 已录制流程 | Todo 计划 | 真实 `todo_write` 执行、结果卡片和持久计划渲染 | +| 已录制流程 | Bash 终端卡片 | 真实本地执行器输出、说明、退出状态和已完成终端卡片 | +| 已录制流程 | 并行文件读取 | 同一条 assistant 消息中的两次调用、真实文件内容、顺序和两个独立完成卡片 | +| 已录制流程 | Code Mode | 真实 `run_code` worker 执行、两条 `tool/code-dispatch` 事件、捕获的程序输出和已完成卡片 | +| 已录制流程 | 动态工作流 | 真实工作流 worker、阶段生命周期、回放的子会话、结构化返回值和已完成卡片 | +| 已录制流程 | Cordis 动态工具链 | 真实挂载、Code Mode 检查、直接 subagent、工作流子会话、卸载和全部生产呈现器 | +| 瞬态 | 流式输出与待完成高级调用 | 进行中的推理和文本,以及完整日志中不会保留的待完成 Code Mode、工作流和 Cordis 卡片 | +| 瞬态 | 卡片、交互、布局、失败和关闭 | 折叠与展开的卡片族、问题校验、压缩替换、尺寸重排、帮助与错误、光标恢复和终端停止 | ## 曾考虑的替代方案 - **快照原始终端写入**:不予采纳,因为差分渲染可能在画面不变时改变写入边界,而且光标与清屏序列难以评审。 - **快照进入终端输出之前的组件渲染行**:不予采纳,因为它无法测试 ANSI 解析、光标移动、浮层、视口行为,也无法测试独立组件在同一帧中的相互作用。 +- **通过追加会话事件构造所有完整流程**:不予采纳,因为人工编写的事件序列可能与 agent loop、工具执行、子会话绑定或 worker 行为发生偏差,但呈现测试仍然保持绿色。直接构造事件只用于渲染器瞬态。 +- **复用 ACP stdout 金标作为 TUI 判定依据**:不予采纳,因为已录制模型流程与传输方式无关,其呈现方式却并非如此。TUI 场景使用同一套 JSONL 回放词汇,但拥有独立的终端金标。 - **提交栅格截图**:不予采纳,因为字体、字形度量、抗锯齿和宿主终端主题会使结果依赖平台,也会增加语义样式变更的评审难度。 - **只使用 PTY 端到端测试**:不予采纳,因为原始 PTY 输出是一系列历史绘制操作,而不是可查询的最终状态。PTY 测试保留真实 Loader、输入与清理边界,模拟器负责广泛的状态覆盖。 -- **复制 pi-tui 未发布的虚拟终端测试 helper**:不予采纳,因为已安装的包并未导出该 helper。围绕公开 `@xterm/headless` API 编写小型适配器,可以显式声明依赖,并让本包拥有状态投影。 ## 后果 -- TUI 视觉回归会产生便于阅读的单元格和样式 diff;必需场景矩阵也让高级功能成为一等测试对象,而不是偶然覆盖。 -- 测试依赖固定到 pi-tui 使用的 xterm 版本。适配器使用 xterm 的拟议缓冲区 API,因此升级 xterm 时必须重新运行并评审语义投影。 -- 模拟器可以建模 ANSI 终端状态,但无法证明每种终端实现独有的行为。真实 PTY 会话覆盖进程选择、键盘输入、用户交互和清理,无需复制完整矩阵。 -- 金标有意固定指定尺寸下的换行与视口行为。布局的预期变更通过无密钥刷新命令更新,并接受常规快照评审。 +- 当真实 Code Mode、工作流、subagent、文件系统、bash 或 Cordis 路径损坏时,已完成高级快照会失败,不会继续接受伪造的结果事件。 +- TUI 视觉回归会产生便于阅读的单元格和样式 diff,而 JSONL fixture 会保留触发生产路径的确切模型分片。 +- 模拟器使用 xterm 的拟议缓冲区 API。升级 xterm 时必须重新运行并评审语义投影;终端特有行为仍需由 PTY 冒烟测试覆盖。 +- 金标有意固定指定尺寸下的换行与视口行为。预期布局变更使用无密钥刷新;模型流程变更使用录制模式,并同时评审 JSONL 与终端 diff。 diff --git a/docs/testing.md b/docs/testing.md index 5f10e1741c..e8b32fac80 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over `packages|examples/*/tests/**/*.spec.ts`, colocated with what they test. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e RFC](rfc/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized stdout plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)); the TUI suite parses real ANSI output into semantic terminal-state goldens and retains a real PTY conversation at the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when the committed transcript or scripted events remain correct; review every golden diff. System-prompt/tool-schema content is pinned by ONE ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): transport-specific keyless goldens cover external presentation. ACP suites boot the real example subprocess, replay a recorded session, and diff normalized stdout plus the re-persisted log ([ACP snapshot RFC](rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md)). TUI completed journeys replay recorded primary/child JSONL through the real agent loop and tools before projecting ANSI into semantic terminal-state goldens; package-local snapshots retain transient renderer states, and a real PTY conversation covers the process boundary ([TUI snapshot RFC](rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md)). Use `pnpm run test:snapshot:record` when a model transcript must change and `pnpm run test:snapshot:refresh` when committed replay input remains correct; review every JSONL and golden diff. System-prompt/tool-schema content is pinned by ONE ACP scenario (`text-turn`) and tokenized in every other fixture, so a prompt or schema edit churns one committed line ([pinned-header RFC](rfc/implemented/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). ## The with-key policy: inference is cheap here @@ -35,4 +35,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Any change affecting an editor-facing transcript or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); interactive-terminal presentation uses the semantic TUI matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. +Any change affecting an editor-facing transcript or end-to-end agent UX adds or updates a scenario in the owning snapshot suite, or states in the PR why none applies. ACP surfaces use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary). Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. New capability seams, lifecycle shapes, or transcript surfaces name their coverage at every tier at plan time and verify the harness can express it — a harness gap is scheduled work, not a mid-build surprise. diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index 909ccf700d..ee5b610d53 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -17,3 +17,7 @@ Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay. ## Composition [`cordis.yml`](cordis.yml) includes the readline coding-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the coding agent's Code Mode overlay. + +## Snapshot tests + +`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable terminal cell/style goldens. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot RFC](../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl new file mode 100644 index 0000000000..e53f4b3da3 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl @@ -0,0 +1,98 @@ +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/tmp/acp-snap-cwd-mrFUuk"} +{"type":"turn/start","seq":0,"time":1783352050753,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352050753,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352050755,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352050756,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352051421,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352051422,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352051618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352051618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352051619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352051619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783352051619,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1783352051645,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" simple"}}} +{"type":"assistant/chunk","seq":13,"time":1783352051675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":14,"time":1783352051675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":15,"time":1783352051675,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":16,"time":1783352051676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":17,"time":1783352051676,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":18,"time":1783352051703,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":19,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":21,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":22,"time":1783352051704,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":23,"time":1783352051790,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1783352051791,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":25,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":26,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":27,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"command"}}} +{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":29,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":30,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":31,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":32,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":33,"time":1783352051848,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":34,"time":1783352051877,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":35,"time":1783352051877,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":36,"time":1783352051877,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":37,"time":1783352051905,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":38,"time":1783352051906,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":39,"time":1783352051906,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":40,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":41,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":42,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783352051935,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"E"}}} +{"type":"assistant/chunk","seq":44,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"cho"}}} +{"type":"assistant/chunk","seq":45,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" TER"}}} +{"type":"assistant/chunk","seq":46,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"MIN"}}} +{"type":"assistant/chunk","seq":47,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"AL"}}} +{"type":"assistant/chunk","seq":48,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":49,"time":1783352051967,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" to"}}} +{"type":"assistant/chunk","seq":50,"time":1783352052041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" verify"}}} +{"type":"assistant/chunk","seq":51,"time":1783352052041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" terminal"}}} +{"type":"assistant/chunk","seq":52,"time":1783352052041,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":" access"}}} +{"type":"assistant/chunk","seq":53,"time":1783352052054,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":54,"time":1783352052054,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":55,"time":1783352052117,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":56,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} +{"type":"assistant/chunk","seq":57,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":58,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":59,"time":1783352052121,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} +{"type":"tool/call","seq":60,"time":1783352052121,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} +{"type":"tool/result","seq":61,"time":1783352052136,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false},"sourceEventSeqs":[60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783352052137,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":63,"time":1783352052137,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":64,"time":1783352052701,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":65,"time":1783352052702,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":66,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" command"}}} +{"type":"assistant/chunk","seq":67,"time":1783352052809,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" ran"}}} +{"type":"assistant/chunk","seq":68,"time":1783352052838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":69,"time":1783352052838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":70,"time":1783352052838,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":71,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":72,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"TER"}}} +{"type":"assistant/chunk","seq":73,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"MIN"}}} +{"type":"assistant/chunk","seq":74,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":75,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":76,"time":1783352052867,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":77,"time":1783352052895,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":78,"time":1783352052896,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":79,"time":1783352052924,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":80,"time":1783352052925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":81,"time":1783352052925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":82,"time":1783352052925,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":83,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":84,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":85,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":87,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":88,"time":1783352052957,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":89,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":90,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":91,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":94,"time":1783352052987,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} +{"type":"step/end","seq":95,"time":1783352052987,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":96,"time":1783352052987,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt new file mode 100644 index 0000000000..30770dcccb --- /dev/null +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt @@ -0,0 +1,72 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=27 bufferRow=27 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: bash-terminal-card │" + style 0-0 fg=bright-blue + style 2-36 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop." + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " The user wants me to run a simple bash command and then reply with \"DONE\". " + style 1-74 fg=bright-black italic +13| +14| "▌ " + style 0-0 fg=green +15| "▌ ✓ echo TERMINAL_OK " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-19 bold +16| "▌ Echo TERMINAL_OK to verify terminal access " + style 0-0 fg=green + style 2-43 fg=bright-black +17| "▌ TERMINAL_OK " + style 0-0 fg=green +18| "▌ [exit 0] " + style 0-0 fg=green + style 2-9 dim +19| "▌ " + style 0-0 fg=green +20| +21| " Reasoning " + style 1-9 fg=bright-black italic +22| " The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\". " + style 1-91 fg=bright-black italic +23| +24| " Assistant " + style 1-9 fg=bright-magenta bold +25| " DONE " +26| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +27| " " + style 1-1 inverse +28| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +29| "/workspace/project ↑3.0k ↓" + style 0-99 dim +30-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl new file mode 100644 index 0000000000..0fe57055e8 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/code-mode/session.jsonl @@ -0,0 +1,150 @@ +{"type":"session","version":0,"id":"94cd1ae4-e1d1-4ec8-9d27-50a1f849b6b3","createdAt":1783611771392,"cwd":"/var/folders/2c/psb0_fmx7hbgz558xjt_f0l00000gn/T/acp-snap-cwd-BteTVR"} +{"type":"turn/start","seq":0,"time":1783611771394,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783611771394,"data":{"content":[{"type":"text","text":"Using ONE run_code program: call the bash tool twice — exactly `echo CODE_ONE` then exactly `echo CODE_TWO` — and return the two outputs joined with a plus sign. Then reply with that joined string only and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783611771396,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783611771396,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783611771869,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783611771978,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783611772007,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783611772008,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":9,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":10,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" run"}}} +{"type":"assistant/chunk","seq":11,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_code"}}} +{"type":"assistant/chunk","seq":12,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" program"}}} +{"type":"assistant/chunk","seq":13,"time":1783611772036,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":14,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" calls"}}} +{"type":"assistant/chunk","seq":15,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":16,"time":1783611772089,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":17,"time":1783611772096,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":18,"time":1783611772124,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":19,"time":1783611772153,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":20,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":21,"time":1783611772183,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":22,"time":1783611772211,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" outputs"}}} +{"type":"assistant/chunk","seq":23,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" joined"}}} +{"type":"assistant/chunk","seq":24,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":25,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":26,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plus"}}} +{"type":"assistant/chunk","seq":27,"time":1783611772212,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sign"}}} +{"type":"assistant/chunk","seq":28,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":30,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":31,"time":1783611772241,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" write"}}} +{"type":"assistant/chunk","seq":32,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":33,"time":1783611772270,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":34,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":35,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":36,"time":1783611772361,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":37,"time":1783611772362,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":38,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"code"}}} +{"type":"assistant/chunk","seq":39,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":41,"time":1783611772390,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":42,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":43,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":44,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":45,"time":1783611772420,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":46,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":47,"time":1783611772421,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":48,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":49,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":50,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":51,"time":1783611772449,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":52,"time":1783611772478,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":53,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":54,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":55,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":56,"time":1783611772479,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_"}}} +{"type":"assistant/chunk","seq":57,"time":1783611772508,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ONE"}}} +{"type":"assistant/chunk","seq":58,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":59,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":60,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":61,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":62,"time":1783611772510,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"First"}}} +{"type":"assistant/chunk","seq":63,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":64,"time":1783611772538,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":65,"time":1783611772566,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":66,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":67,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":68,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":69,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":70,"time":1783611772567,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":71,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" tools"}}} +{"type":"assistant/chunk","seq":72,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".b"}}} +{"type":"assistant/chunk","seq":73,"time":1783611772597,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"ash"}}} +{"type":"assistant/chunk","seq":74,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"({"}}} +{"type":"assistant/chunk","seq":75,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" command"}}} +{"type":"assistant/chunk","seq":76,"time":1783611772598,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":77,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":78,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"echo"}}} +{"type":"assistant/chunk","seq":79,"time":1783611772625,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" CODE"}}} +{"type":"assistant/chunk","seq":80,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"_T"}}} +{"type":"assistant/chunk","seq":81,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"WO"}}} +{"type":"assistant/chunk","seq":82,"time":1783611772626,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\","}}} +{"type":"assistant/chunk","seq":83,"time":1783611772654,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" description"}}} +{"type":"assistant/chunk","seq":84,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":85,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\""}}} +{"type":"assistant/chunk","seq":86,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"Second"}}} +{"type":"assistant/chunk","seq":87,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" echo"}}} +{"type":"assistant/chunk","seq":88,"time":1783611772655,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":89,"time":1783611772684,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" });\\n"}}} +{"type":"assistant/chunk","seq":90,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":91,"time":1783611772685,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":92,"time":1783611772713,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"1"}}} +{"type":"assistant/chunk","seq":93,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":94,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"()"}}} +{"type":"assistant/chunk","seq":95,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":96,"time":1783611772714,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" \\\"+"}}} +{"type":"assistant/chunk","seq":97,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\\\""}}} +{"type":"assistant/chunk","seq":98,"time":1783611772744,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" +"}}} +{"type":"assistant/chunk","seq":99,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":" out"}}} +{"type":"assistant/chunk","seq":100,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"2"}}} +{"type":"assistant/chunk","seq":101,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":".trim"}}} +{"type":"assistant/chunk","seq":102,"time":1783611772745,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"();"}}} +{"type":"assistant/chunk","seq":103,"time":1783611772772,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":104,"time":1783611772773,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":105,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."}}}} +{"type":"assistant/chunk","seq":106,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}}}} +{"type":"assistant/chunk","seq":107,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}}}} +{"type":"assistant/chunk","seq":108,"time":1783611772836,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":109,"time":1783611772840,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants a single run_code program that calls bash twice, then returns the two outputs joined with a plus sign. Let me write this."},{"type":"tool-call","id":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3009,"outputTokens":133,"cacheReadTokens":0,"reasoningTokens":29}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108],"surfaceOp":"append"} +{"type":"tool/call","seq":110,"time":1783611772840,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","name":"run_code","arguments":"{\"code\": \"const out1 = await tools.bash({ command: \\\"echo CODE_ONE\\\", description: \\\"First echo\\\" });\\nconst out2 = await tools.bash({ command: \\\"echo CODE_TWO\\\", description: \\\"Second echo\\\" });\\nreturn out1.trim() + \\\"+\\\" + out2.trim();\"}"}} +{"type":"tool/code-dispatch","seq":111,"time":1783611772933,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:1","name":"bash","arguments":{"command":"echo CODE_ONE","description":"First echo"},"isError":false,"resultSummary":"CODE_ONE\n"}} +{"type":"tool/code-dispatch","seq":112,"time":1783611772936,"data":{"parentCallId":"call_00_DRxnM6R1TThfDwcudW0f2050","subCallId":"call_00_DRxnM6R1TThfDwcudW0f2050:code:2","name":"bash","arguments":{"command":"echo CODE_TWO","description":"Second echo"},"isError":false,"resultSummary":"CODE_TWO\n"}} +{"type":"tool/result","seq":113,"time":1783611772937,"data":{"turn":1,"step":1,"callId":"call_00_DRxnM6R1TThfDwcudW0f2050","content":[{"type":"text","text":"CODE_ONE+CODE_TWO"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[110],"surfaceOp":"append"} +{"type":"step/end","seq":114,"time":1783611772938,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":115,"time":1783611772938,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":116,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":117,"time":1783611773376,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":118,"time":1783611773480,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" output"}}} +{"type":"assistant/chunk","seq":119,"time":1783611773511,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":120,"time":1783611773512,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":121,"time":1783611773540,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":122,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":123,"time":1783611773541,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":124,"time":1783611773569,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":125,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":126,"time":1783611773570,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":127,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" CODE"}}} +{"type":"assistant/chunk","seq":128,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_"}}} +{"type":"assistant/chunk","seq":129,"time":1783611773597,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":130,"time":1783611773626,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"+"}}} +{"type":"assistant/chunk","seq":131,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CODE"}}} +{"type":"assistant/chunk","seq":132,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_T"}}} +{"type":"assistant/chunk","seq":133,"time":1783611773627,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":134,"time":1783611773654,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":135,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":136,"time":1783611773655,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_"}}} +{"type":"assistant/chunk","seq":137,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":138,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"+"}}} +{"type":"assistant/chunk","seq":139,"time":1783611773656,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"CODE"}}} +{"type":"assistant/chunk","seq":140,"time":1783611773657,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_T"}}} +{"type":"assistant/chunk","seq":141,"time":1783611773685,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":142,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":143,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CODE_ONE+CODE_TWO"}}}} +{"type":"assistant/chunk","seq":144,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":145,"time":1783611773686,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":146,"time":1783611773687,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output is exactly what the user asked for: CODE_ONE+CODE_TWO"},{"type":"text","text":"CODE_ONE+CODE_TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":89,"outputTokens":25,"cacheReadTokens":3072,"reasoningTokens":17}},"sourceEventSeqs":[116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145],"surfaceOp":"append"} +{"type":"step/end","seq":147,"time":1783611773687,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":148,"time":1783611773687,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt new file mode 100644 index 0000000000..a7f1457d7a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt @@ -0,0 +1,79 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=29 bufferRow=29 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: code-mode │" + style 0-0 fg=bright-blue + style 2-27 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Using ONE run_code program: call the bash tool twice — exactly echo CODE_ONE then exactly echo " + style 0-0 fg=bright-blue + style 65-77 fg=cyan + style 92-99 fg=cyan +9| "▌ CODE_TWO — and return the two outputs joined with a plus sign. Then reply with that joined string " + style 0-0 fg=bright-blue + style 2-9 fg=cyan +10| "▌ only and stop. " + style 0-0 fg=bright-blue +11| "▌ " + style 0-0 fg=bright-blue +12| +13| " Reasoning " + style 1-9 fg=bright-black italic +14| " The user wants a single run_code program that calls bash twice, then returns the two outputs " + style 1-99 fg=bright-black italic +15| " joined with a plus sign. Let me write this. " + style 1-43 fg=bright-black italic +16| +17| "▌ " + style 0-0 fg=green +18| "▌ ✓ const out1 = await tools.bash({ command: \"echo CODE_ONE\", description: \"First echo\" }); " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-99 bold +19| "▌ const o " + style 0-0 fg=green + style 2-8 bold +20| "▌ CODE_ONE+CODE_TWO " + style 0-0 fg=green +21| "▌ " + style 0-0 fg=green +22| +23| " Reasoning " + style 1-9 fg=bright-black italic +24| " The output is exactly what the user asked for: CODE_ONE+CODE_TWO " + style 1-64 fg=bright-black italic +25| +26| " Assistant " + style 1-9 fg=bright-magenta bold +27| " CODE_ONE+CODE_TWO " +28| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +29| " " + style 1-1 inverse +30| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +31| "/workspace/project ↑3.1k ↓158 idle" + style 0-93 dim + style 96-99 dim +32-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl new file mode 100644 index 0000000000..25a6f76411 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884563,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884563,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884564,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950001005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884564,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884564,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl new file mode 100644 index 0000000000..45d9043a4a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl @@ -0,0 +1,13 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"/tmp/advanced-acp","parentSession":"11111111-1111-4111-8111-111111111111"} +{"type":"turn/start","seq":0,"time":1783957884700,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884700,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884700,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884701,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950002005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":5,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":6,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":7,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"step/end","seq":10,"time":1783957884701,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":11,"time":1783957884701,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl new file mode 100644 index 0000000000..39f867984a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl @@ -0,0 +1,64 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"/tmp/advanced-acp"} +{"type":"turn/start","seq":0,"time":1783957884479,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783957884479,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783957884486,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783957884486,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783950000005,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":1783950000006,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} +{"type":"assistant/chunk","seq":6,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":8,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":9,"time":1783957884487,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[4,5,6,7,8],"surfaceOp":"append"} +{"type":"tool/call","seq":10,"time":1783957884487,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} +{"type":"tool/result","seq":11,"time":1783957884488,"data":{"turn":1,"step":1,"callId":"advanced-mount","content":[{"type":"text","text":"mounted dyn-1 (plugin \"snapshot-marker\", state: active)"}],"isError":false},"sourceEventSeqs":[10],"surfaceOp":"append"} +{"type":"step/end","seq":12,"time":1783957884489,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":13,"time":1783957884489,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":14,"time":1783950000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":15,"time":1783950000016,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}} +{"type":"assistant/chunk","seq":16,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}}}} +{"type":"assistant/chunk","seq":17,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":18,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":19,"time":1783957884490,"data":{"turn":1,"step":2,"content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"tool/call","seq":20,"time":1783957884490,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"return await tools.cordis_inspect({ what: 'dynamic' })\"}"}} +{"type":"tool/code-dispatch","seq":21,"time":1783957884560,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"dynamic"},"isError":false,"resultSummary":"## dynamic\n- dyn-1: snapshot-marker [active]"}} +{"type":"tool/result","seq":22,"time":1783957884561,"data":{"turn":1,"step":2,"callId":"advanced-code","content":[{"type":"text","text":"## dynamic\n- dyn-1: snapshot-marker [active]"}],"isError":false,"meta":{"logs":[]}},"sourceEventSeqs":[20],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1783957884561,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":24,"time":1783957884562,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":25,"time":1783950000026,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":26,"time":1783950000027,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":27,"time":1783950000028,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":28,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":29,"time":1783950000030,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":30,"time":1783957884562,"data":{"turn":1,"step":3,"content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} +{"type":"tool/call","seq":31,"time":1783957884562,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":32,"time":1783957884593,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false},"sourceEventSeqs":[31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783957884593,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":34,"time":1783957884594,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":35,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":36,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}} +{"type":"assistant/chunk","seq":37,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}}}} +{"type":"assistant/chunk","seq":38,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":39,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[35,36,37,38,39],"surfaceOp":"append"} +{"type":"tool/call","seq":41,"time":1783957884594,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-acp-snapshot\",\"description\":\"exercise one workflow child through ACP\"}}"}} +{"type":"tool/result","seq":42,"time":1783957884717,"data":{"turn":1,"step":4,"callId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-acp-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[41],"surfaceOp":"append"} +{"type":"step/end","seq":43,"time":1783957884718,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":44,"time":1783957884718,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":45,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":46,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\":\"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":47,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":48,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":49,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[45,46,47,48,49],"surfaceOp":"append"} +{"type":"tool/call","seq":51,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} +{"type":"tool/result","seq":52,"time":1783957884719,"data":{"turn":1,"step":5,"callId":"advanced-unmount","content":[{"type":"text","text":"unmounted dyn-1 (plugin \"snapshot-marker\")"}],"isError":false},"sourceEventSeqs":[51],"surfaceOp":"append"} +{"type":"step/end","seq":53,"time":1783957884719,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":54,"time":1783957884720,"data":{"turn":1,"step":6}} +{"type":"assistant/chunk","seq":55,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":56,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_ACP_OK"}}} +{"type":"assistant/chunk","seq":57,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_ACP_OK"}}}} +{"type":"assistant/chunk","seq":58,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":59,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"content":[{"type":"text","text":"ADVANCED_ACP_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[55,56,57,58,59],"surfaceOp":"append"} +{"type":"step/end","seq":61,"time":1783957884721,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":62,"time":1783957884721,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt new file mode 100644 index 0000000000..5c5afc10ca --- /dev/null +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt @@ -0,0 +1,115 @@ +terminal 100x36 buffer=normal length=50 base=14 viewport=14 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=47 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: cordis-dynamic-toolchain │" + style 0-0 fg=bright-blue + style 2-42 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Run this advanced flow exactly once: mount a no-op Cordis plugin named snapshot-marker; use " + style 0-0 fg=bright-blue +9| "▌ run_code to inspect the live dynamic mounts through tools.cordis_inspect; delegate once to a " + style 0-0 fg=bright-blue +10| "▌ direct spawn child; run one workflow that delegates to another spawn child; unmount dyn-1; then " + style 0-0 fg=bright-blue +11| "▌ reply with exactly ADVANCED_ACP_OK. " + style 0-0 fg=bright-blue +12| "▌ " + style 0-0 fg=bright-blue +13| +14| "▌ " + style 0-0 fg=green +15| "▌ ✓ Mount plugin into live cordis runtime " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-40 bold +16| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " + style 0-0 fg=green +17| "▌ " + style 0-0 fg=green +18| +19| "▌ " + style 0-0 fg=green +20| "▌ ✓ return await tools.cordis_inspect({ what: 'dynamic' }) " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-57 bold +21| "▌ ## dynamic " + style 0-0 fg=green +22| "▌ - dyn-1: snapshot-marker [active] " + style 0-0 fg=green +23| "▌ " + style 0-0 fg=green +24| +25| "▌ " + style 0-0 fg=green +26| "▌ ✓ subagent " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-11 bold +27| "▌ DIRECT_CHILD_OK " + style 0-0 fg=green +28| "▌ " + style 0-0 fg=green +29| +30| "▌ " + style 0-0 fg=green +31| "▌ ✓ workflow: advanced-acp-snapshot " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-34 bold +32| "▌ workflow \"advanced-acp-snapshot\" completed (1 agent). " + style 0-0 fg=green +33| "▌ Return value: " + style 0-0 fg=green +34| "▌ { " + style 0-0 fg=green +35| "▌ \"reply\": \"WORKFLOW_CHILD_OK\" " + style 0-0 fg=green +36| "▌ } " + style 0-0 fg=green +37| "▌ " + style 0-0 fg=green +38| +39| "▌ " + style 0-0 fg=green +40| "▌ ✓ Unmount dyn-1 " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-16 bold +41| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " + style 0-0 fg=green +42| "▌ " + style 0-0 fg=green +43| +44| " Assistant " + style 1-9 fg=bright-magenta bold +45| " ADVANCED_ACP_OK " +46| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +47| " " + style 1-1 inverse +48| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +49| "/workspace/project ↑" + style 0-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl new file mode 100644 index 0000000000..3d89428bbd --- /dev/null +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl @@ -0,0 +1,36 @@ +{"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8"} +{"type":"turn/start","seq":0,"time":1783600636316,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600636316,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600636316,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600636317,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600638073,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600638173,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":14,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":15,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":16,"time":1783600638213,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1783600638242,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WF"}}} +{"type":"assistant/chunk","seq":25,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_CH"}}} +{"type":"assistant/chunk","seq":26,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":29,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":30,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":31,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":32,"time":1783600638281,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31],"surfaceOp":"append"} +{"type":"step/end","seq":33,"time":1783600638281,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":34,"time":1783600638281,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl new file mode 100644 index 0000000000..3e0ae3da73 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl @@ -0,0 +1,209 @@ +{"type":"session","version":0,"id":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","createdAt":1783600631835,"cwd":"/var/folders/bn/vj1dvck95yd5jh3x4wskflxm0000gn/T/acp-snap-cwd-vdJYjz"} +{"type":"turn/start","seq":0,"time":1783600631838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783600631838,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783600631839,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783600631839,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783600634643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":13,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":15,"time":1783600635634,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":16,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":18,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" parameters"}}} +{"type":"assistant/chunk","seq":19,"time":1783600635635,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":20,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":21,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":22,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" carefully"}}} +{"type":"assistant/chunk","seq":23,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" follow"}}} +{"type":"assistant/chunk","seq":24,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" instructions"}}} +{"type":"assistant/chunk","seq":26,"time":1783600635743,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":27,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":28,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" args"}}} +{"type":"assistant/chunk","seq":30,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" omitted"}}} +{"type":"assistant/chunk","seq":31,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ("}}} +{"type":"assistant/chunk","seq":32,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"so"}}} +{"type":"assistant/chunk","seq":33,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":34,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" don"}}} +{"type":"assistant/chunk","seq":35,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'t"}}} +{"type":"assistant/chunk","seq":36,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" include"}}} +{"type":"assistant/chunk","seq":37,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":38,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":")\n"}}} +{"type":"assistant/chunk","seq":39,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":40,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":41,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" meta"}}} +{"type":"assistant/chunk","seq":42,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":43,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" {"}}} +{"type":"assistant/chunk","seq":44,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":45,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"name"}}} +{"type":"assistant/chunk","seq":46,"time":1783600635744,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":47,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":48,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"sn"}}} +{"type":"assistant/chunk","seq":49,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"apshot"}}} +{"type":"assistant/chunk","seq":50,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"-flow"}}} +{"type":"assistant/chunk","seq":51,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":52,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":53,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"description"}}} +{"type":"assistant/chunk","seq":54,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\":"}}} +{"type":"assistant/chunk","seq":55,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":56,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"one"}}} +{"type":"assistant/chunk","seq":57,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":58,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":59,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":60,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" snapshot"}}} +{"type":"assistant/chunk","seq":61,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":62,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" }\n"}}} +{"type":"assistant/chunk","seq":63,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":64,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":65,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" script"}}} +{"type":"assistant/chunk","seq":66,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ="}}} +{"type":"assistant/chunk","seq":67,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":68,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" given"}}} +{"type":"assistant/chunk","seq":69,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" verb"}}} +{"type":"assistant/chunk","seq":70,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"atim"}}} +{"type":"assistant/chunk","seq":71,"time":1783600635745,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\n"}}} +{"type":"assistant/chunk","seq":72,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"4"}}} +{"type":"assistant/chunk","seq":73,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":74,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":75,"time":1783600635746,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":76,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":77,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":78,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":81,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":82,"time":1783600635754,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":83,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":84,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":85,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":86,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"\n\n"}}} +{"type":"assistant/chunk","seq":87,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":88,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":89,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":90,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":91,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":92,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":93,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":94,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":95,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":96,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":97,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"meta"}}} +{"type":"assistant/chunk","seq":98,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":99,"time":1783600635756,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":100,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":101,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"name"}}} +{"type":"assistant/chunk","seq":102,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":103,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":104,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"sn"}}} +{"type":"assistant/chunk","seq":105,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"apshot"}}} +{"type":"assistant/chunk","seq":106,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"-flow"}}} +{"type":"assistant/chunk","seq":107,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":108,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":109,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":110,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":111,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":112,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"one"}}} +{"type":"assistant/chunk","seq":113,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" child"}}} +{"type":"assistant/chunk","seq":114,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" for"}}} +{"type":"assistant/chunk","seq":115,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":116,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" snapshot"}}} +{"type":"assistant/chunk","seq":117,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":118,"time":1783600635757,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":119,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":120,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":121,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"script"}}} +{"type":"assistant/chunk","seq":122,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":123,"time":1783600635759,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":124,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":125,"time":1783600635764,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\\n"}}} +{"type":"assistant/chunk","seq":126,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"phase"}}} +{"type":"assistant/chunk","seq":127,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":128,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Run"}}} +{"type":"assistant/chunk","seq":129,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"')\\n"}}} +{"type":"assistant/chunk","seq":130,"time":1783600636039,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"const"}}} +{"type":"assistant/chunk","seq":131,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":132,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" ="}}} +{"type":"assistant/chunk","seq":133,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" await"}}} +{"type":"assistant/chunk","seq":134,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" agent"}}} +{"type":"assistant/chunk","seq":135,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"('"}}} +{"type":"assistant/chunk","seq":136,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":137,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":138,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":139,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":140,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":141,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" WF"}}} +{"type":"assistant/chunk","seq":142,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_CH"}}} +{"type":"assistant/chunk","seq":143,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":144,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":145,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":146,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":147,"time":1783600636245,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":148,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":".')\\n"}}} +{"type":"assistant/chunk","seq":149,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"return"}}} +{"type":"assistant/chunk","seq":150,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" {"}}} +{"type":"assistant/chunk","seq":151,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" reply"}}} +{"type":"assistant/chunk","seq":152,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":" }\\n"}}} +{"type":"assistant/chunk","seq":153,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":155,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."}}}} +{"type":"assistant/chunk","seq":156,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}}}} +{"type":"assistant/chunk","seq":157,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}}}} +{"type":"assistant/chunk","seq":158,"time":1783600636246,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":159,"time":1783600636247,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully follow the instructions:\n\n1. args omitted (so I don't include it)\n2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }\n3. script = as given verbatim\n4. After it returns, reply with \"WORKFLOW_DONE\"\n\nLet me do exactly that."},{"type":"tool-call","id":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":3174,"outputTokens":191,"cacheReadTokens":0,"reasoningTokens":88}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158],"surfaceOp":"append"} +{"type":"tool/call","seq":160,"time":1783600636247,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","name":"workflow","arguments":"{\"meta\": {\"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\"}, \"script\": \"\\nphase('Run')\\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\\nreturn { reply }\\n\"}"}} +{"type":"tool/result","seq":161,"time":1783600638304,"data":{"turn":1,"step":1,"callId":"call_00_dD2BLuNeJCTh2iiYC1QR3449","content":[{"type":"text","text":"workflow \"snapshot-flow\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WF_CHILD_OK\"\n}"}],"isError":false},"sourceEventSeqs":[160],"surfaceOp":"append"} +{"type":"step/end","seq":162,"time":1783600638304,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":163,"time":1783600638305,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":164,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":165,"time":1783600640028,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":166,"time":1783600640134,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" workflow"}}} +{"type":"assistant/chunk","seq":167,"time":1783600640162,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":168,"time":1783600640195,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":169,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":170,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":172,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":173,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WF"}}} +{"type":"assistant/chunk","seq":174,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_CH"}}} +{"type":"assistant/chunk","seq":175,"time":1783600640862,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":176,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":177,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":178,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":179,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":180,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":181,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":182,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":183,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":184,"time":1783600640864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":185,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":186,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"WORK"}}} +{"type":"assistant/chunk","seq":187,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FL"}}} +{"type":"assistant/chunk","seq":188,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"OW"}}} +{"type":"assistant/chunk","seq":189,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":190,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":191,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":192,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":193,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":194,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":195,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":196,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"WORK"}}} +{"type":"assistant/chunk","seq":197,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"FL"}}} +{"type":"assistant/chunk","seq":198,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"OW"}}} +{"type":"assistant/chunk","seq":199,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":200,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":201,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":202,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WORKFLOW_DONE"}}}} +{"type":"assistant/chunk","seq":203,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}}}} +{"type":"assistant/chunk","seq":204,"time":1783600640865,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":205,"time":1783600640865,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly \"WORKFLOW_DONE\" and stop."},{"type":"text","text":"WORKFLOW_DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":328,"outputTokens":36,"cacheReadTokens":3072,"reasoningTokens":30}},"sourceEventSeqs":[164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204],"surfaceOp":"append"} +{"type":"step/end","seq":206,"time":1783600640865,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":207,"time":1783600640865,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt new file mode 100644 index 0000000000..8589ac94ea --- /dev/null +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt @@ -0,0 +1,105 @@ +terminal 100x36 buffer=normal length=47 base=11 viewport=11 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=44 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: dynamic-workflow │" + style 0-0 fg=bright-blue + style 2-34 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", " + style 0-0 fg=bright-blue +9| "▌ \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim): " + style 0-0 fg=bright-blue +10| "▌ phase('Run') " + style 0-0 fg=bright-blue +11| "▌ const reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.') " + style 0-0 fg=bright-blue +12| "▌ return { reply } " + style 0-0 fg=bright-blue +13| "▌ After the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any " + style 0-0 fg=bright-blue +14| "▌ other tool. " + style 0-0 fg=bright-blue +15| "▌ " + style 0-0 fg=bright-blue +16| +17| " Reasoning " + style 1-9 fg=bright-black italic +18| " The user wants me to use the workflow tool exactly once with specific parameters. Let me carefully " + style 1-99 fg=bright-black italic +19| " follow the instructions: " + style 1-24 fg=bright-black italic +20| " " +21| " 1. args omitted (so I don't include it) " + style 1-3 fg=bright-blue + style 4-39 fg=bright-black italic +22| " 2. meta = { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" } " + style 1-3 fg=bright-blue + style 4-82 fg=bright-black italic +23| " 3. script = as given verbatim " + style 1-3 fg=bright-blue + style 4-29 fg=bright-black italic +24| " 4. After it returns, reply with \"WORKFLOW_DONE\" " + style 1-3 fg=bright-blue + style 4-47 fg=bright-black italic +25| " " +26| " Let me do exactly that. " + style 1-23 fg=bright-black italic +27| +28| "▌ " + style 0-0 fg=green +29| "▌ ✓ workflow: snapshot-flow " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-26 bold +30| "▌ workflow \"snapshot-flow\" completed (1 agent). " + style 0-0 fg=green +31| "▌ Return value: " + style 0-0 fg=green +32| "▌ { " + style 0-0 fg=green +33| "▌ \"reply\": \"WF_CHILD_OK\" " + style 0-0 fg=green +34| "▌ } " + style 0-0 fg=green +35| "▌ " + style 0-0 fg=green +36| +37| " Reasoning " + style 1-9 fg=bright-black italic +38| " The workflow returned successfully with the reply \"WF_CHILD_OK\". Now I need to reply with exactly " + style 1-99 fg=bright-black italic +39| " \"WORKFLOW_DONE\" and stop. " + style 1-25 fg=bright-black italic +40| +41| " Assistant " + style 1-9 fg=bright-magenta bold +42| " WORKFLOW_DONE " +43| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +44| " " + style 1-1 inverse +45| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +46| "/workspace/project ↑3.5k ↓22" + style 0-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl new file mode 100644 index 0000000000..cd32072eaa --- /dev/null +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl @@ -0,0 +1,65 @@ +{"type":"session","version":0,"id":"228b7b82-84ed-49b7-a567-981c03b28c77","createdAt":1783352113760,"cwd":"/tmp/acp-snap-cwd-aN2GRR"} +{"type":"turn/start","seq":0,"time":1783352113765,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352113765,"data":{"content":[{"type":"text","text":"Reply with exactly the word: ONE. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352113767,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352113768,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352114428,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352114542,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352114570,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352114571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352114571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352114571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":11,"time":1783352114572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":12,"time":1783352114600,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":13,"time":1783352114601,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":14,"time":1783352114602,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":15,"time":1783352114602,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1783352114602,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":17,"time":1783352114603,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1783352114627,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1783352114628,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":20,"time":1783352114657,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":21,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":22,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1783352114658,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":25,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."}}}} +{"type":"assistant/chunk","seq":26,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ONE"}}}} +{"type":"assistant/chunk","seq":27,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":28,"time":1783352114687,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1783352114690,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ONE\" and use no tools."},{"type":"text","text":"ONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2864,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28],"surfaceOp":"append"} +{"type":"step/end","seq":30,"time":1783352114690,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":31,"time":1783352114690,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":32,"time":1783352114699,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":33,"time":1783352114699,"data":{"content":[{"type":"text","text":"Reply with exactly the word: TWO. No tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":34,"time":1783352114700,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":35,"time":1783352115341,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":36,"time":1783352115341,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":37,"time":1783352115465,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":38,"time":1783352115492,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":39,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":40,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":41,"time":1783352115493,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1783352115521,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1783352115521,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":44,"time":1783352115521,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":45,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":46,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":47,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"T"}}} +{"type":"assistant/chunk","seq":48,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"WO"}}} +{"type":"assistant/chunk","seq":49,"time":1783352115552,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":50,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":51,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" no"}}} +{"type":"assistant/chunk","seq":52,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":53,"time":1783352115580,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":54,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":55,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"T"}}} +{"type":"assistant/chunk","seq":56,"time":1783352115609,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"WO"}}} +{"type":"assistant/chunk","seq":57,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."}}}} +{"type":"assistant/chunk","seq":58,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"TWO"}}}} +{"type":"assistant/chunk","seq":59,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":60,"time":1783352115610,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":61,"time":1783352115611,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"TWO\" and no tools."},{"type":"text","text":"TWO"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":64,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60],"surfaceOp":"append"} +{"type":"step/end","seq":62,"time":1783352115611,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":63,"time":1783352115611,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt new file mode 100644 index 0000000000..fca6c6dd4a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt @@ -0,0 +1,69 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=28 bufferRow=28 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: multi-turn-conversation │" + style 0-0 fg=bright-blue + style 2-41 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Reply with exactly the word: ONE. No tools. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " The user wants me to reply with exactly the word \"ONE\" and use no tools. " + style 1-72 fg=bright-black italic +13| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " ONE " +16| +17| "▌ " + style 0-0 fg=bright-blue +18| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +19| "▌ Reply with exactly the word: TWO. No tools. " + style 0-0 fg=bright-blue +20| "▌ " + style 0-0 fg=bright-blue +21| +22| " Reasoning " + style 1-9 fg=bright-black italic +23| " The user wants me to reply with exactly the word \"TWO\" and no tools. " + style 1-68 fg=bright-black italic +24| +25| " Assistant " + style 1-9 fg=bright-magenta bold +26| " TWO " +27| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +28| " " + style 1-1 inverse +29| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +30| "/workspace/project ↑2" + style 0-99 dim +31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl b/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl new file mode 100644 index 0000000000..81503dc4cf --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_read_a","name":"read","argumentsDelta":"{\"file_path\":\"a.txt\"}"}}} +{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}}}} +{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_read_b","name":"read","argumentsDelta":"{\"file_path\":\"b.txt\"}"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":12,"time":0,"data":{"turn":1,"step":1,"content":[{"type":"tool-call","id":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"},{"type":"tool-call","id":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[4,5,6,7,8,9,10,11],"surfaceOp":"append"} +{"type":"tool/call","seq":13,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","name":"read","arguments":"{\"file_path\":\"a.txt\"}"}} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","name":"read","arguments":"{\"file_path\":\"b.txt\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_a","content":[{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[13],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"callId":"call_read_b","content":[{"type":"text","text":"{{cwd}}/b.txt\nfile\n\n1: beta\n\n(End of file - total 1 lines)\n"}],"isError":false},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":1}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"content":[{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":10,"outputTokens":1}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":0,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt new file mode 100644 index 0000000000..7906694d0a --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt @@ -0,0 +1,94 @@ +terminal 100x36 buffer=normal length=41 base=5 viewport=5 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=38 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: parallel-file-reads │" + style 0-0 fg=bright-blue + style 2-37 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the read tool twice in the same assistant message: read a.txt and b.txt. Then reply DONE. " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| "▌ " + style 0-0 fg=green +12| "▌ ✓ Read a.txt " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-13 bold +13| "▌ /workspace/project" + style 0-0 fg=green +14| "▌ /a.txt " + style 0-0 fg=green +15| "▌ file " + style 0-0 fg=green +16| "▌ " + style 0-0 fg=green +17| "▌ 1: alpha " + style 0-0 fg=green +18| "▌ " + style 0-0 fg=green +19| "▌ (End of file - total 1 lines) " + style 0-0 fg=green +20| "▌ " + style 0-0 fg=green +21| "▌ " + style 0-0 fg=green +22| +23| "▌ " + style 0-0 fg=green +24| "▌ ✓ Read b.txt " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-13 bold +25| "▌ /workspace/project" + style 0-0 fg=green +26| "▌ /b.txt " + style 0-0 fg=green +27| "▌ file " + style 0-0 fg=green +28| "▌ " + style 0-0 fg=green +29| "▌ 1: beta " + style 0-0 fg=green +30| "▌ " + style 0-0 fg=green +31| "▌ (End of file - total 1 lines) " + style 0-0 fg=green +32| "▌ " + style 0-0 fg=green +33| "▌ " + style 0-0 fg=green +34| +35| " Assistant " + style 1-9 fg=bright-magenta bold +36| " DONE " +37| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +38| " " + style 1-1 inverse +39| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +40| "/workspace/project ↑20 ↓6" + style 0-99 dim diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt new file mode 100644 index 0000000000..4a58007052 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt @@ -0,0 +1 @@ +alpha diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt new file mode 100644 index 0000000000..65b2df87f7 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt @@ -0,0 +1 @@ +beta diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl new file mode 100644 index 0000000000..909afc44cd --- /dev/null +++ b/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl @@ -0,0 +1,134 @@ +{"type":"session","version":0,"id":"b0f1f758-dcf0-474e-851d-e62c11ec0a09","createdAt":1783352057652,"cwd":"/tmp/acp-snap-cwd-AYilT7"} +{"type":"turn/start","seq":0,"time":1783352057655,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1783352057655,"data":{"content":[{"type":"text","text":"Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then reply with the single word DONE and stop."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1783352057657,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":3,"time":1783352057657,"data":{"header":{"config":{"provider":"deepseek","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"assistant/chunk","seq":4,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":5,"time":1783352058320,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":6,"time":1783352058426,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":7,"time":1783352058466,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":8,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":11,"time":1783352058467,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":12,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todo"}}} +{"type":"assistant/chunk","seq":13,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_write"}}} +{"type":"assistant/chunk","seq":14,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":15,"time":1783352058484,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":16,"time":1783352058485,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" record"}}} +{"type":"assistant/chunk","seq":17,"time":1783352058511,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":18,"time":1783352058512,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" plan"}}} +{"type":"assistant/chunk","seq":19,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":20,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":21,"time":1783352058513,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" three"}}} +{"type":"assistant/chunk","seq":22,"time":1783352058514,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} +{"type":"assistant/chunk","seq":23,"time":1783352058540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":24,"time":1783352058540,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":25,"time":1783352058571,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":26,"time":1783352058572,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" status"}}} +{"type":"assistant/chunk","seq":27,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"es"}}} +{"type":"assistant/chunk","seq":28,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":29,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":30,"time":1783352058597,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":31,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":32,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":33,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":34,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":35,"time":1783352058626,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":36,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":37,"time":1783352058717,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":38,"time":1783352058746,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":39,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":40,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"t"}}} +{"type":"assistant/chunk","seq":41,"time":1783352058747,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"odos"}}} +{"type":"assistant/chunk","seq":42,"time":1783352058775,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":43,"time":1783352058775,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":44,"time":1783352058776,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"["}}} +{"type":"assistant/chunk","seq":45,"time":1783352058805,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"{\""}}} +{"type":"assistant/chunk","seq":46,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":47,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":48,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":49,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"read"}}} +{"type":"assistant/chunk","seq":50,"time":1783352058806,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":51,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" code"}}} +{"type":"assistant/chunk","seq":52,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":53,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":54,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":55,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":56,"time":1783352058832,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":57,"time":1783352058862,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"in"}}} +{"type":"assistant/chunk","seq":58,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"_pro"}}} +{"type":"assistant/chunk","seq":59,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"gress"}}} +{"type":"assistant/chunk","seq":60,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":61,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":62,"time":1783352058863,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":63,"time":1783352058889,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":64,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":65,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"write"}}} +{"type":"assistant/chunk","seq":66,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":67,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" fix"}}} +{"type":"assistant/chunk","seq":68,"time":1783352058890,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":69,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":70,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":71,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":72,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":73,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"pending"}}} +{"type":"assistant/chunk","seq":74,"time":1783352058918,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\"},"}}} +{"type":"assistant/chunk","seq":75,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" {\""}}} +{"type":"assistant/chunk","seq":76,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"content"}}} +{"type":"assistant/chunk","seq":77,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":78,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":79,"time":1783352058947,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"run"}}} +{"type":"assistant/chunk","seq":80,"time":1783352058948,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":81,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" tests"}}} +{"type":"assistant/chunk","seq":82,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\","}}} +{"type":"assistant/chunk","seq":83,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":84,"time":1783352058976,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"status"}}} +{"type":"assistant/chunk","seq":85,"time":1783352058977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\":"}}} +{"type":"assistant/chunk","seq":86,"time":1783352058977,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":" \""}}} +{"type":"assistant/chunk","seq":87,"time":1783352059004,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"pending"}}} +{"type":"assistant/chunk","seq":88,"time":1783352059005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1783352059005,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"}]"}}} +{"type":"assistant/chunk","seq":90,"time":1783352059033,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":91,"time":1783352059095,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":92,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}}}} +{"type":"assistant/chunk","seq":93,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":94,"time":1783352059096,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":95,"time":1783352059099,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the todo_write tool to record a plan with exactly three todos in the specified statuses, then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":2913,"outputTokens":121,"cacheReadTokens":0,"reasoningTokens":31}},"sourceEventSeqs":[4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94],"surfaceOp":"append"} +{"type":"tool/call","seq":96,"time":1783352059099,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"read the code\", \"status\": \"in_progress\"}, {\"content\": \"write the fix\", \"status\": \"pending\"}, {\"content\": \"run the tests\", \"status\": \"pending\"}]}"}} +{"type":"todo/write","seq":97,"time":1783352059100,"data":{"todos":[{"content":"read the code","status":"in_progress"},{"content":"write the fix","status":"pending"},{"content":"run the tests","status":"pending"}]}} +{"type":"tool/result","seq":98,"time":1783352059101,"data":{"turn":1,"step":1,"callId":"call_00_fjAnBThbDjxepBtp3hDt3264","content":[{"type":"text","text":"Updated todo list: 2 pending, 1 in progress, 0 completed."}],"isError":false},"sourceEventSeqs":[96],"surfaceOp":"append"} +{"type":"step/end","seq":99,"time":1783352059101,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":100,"time":1783352059102,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":101,"time":1783352059732,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":102,"time":1783352059733,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":103,"time":1783352059835,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" todos"}}} +{"type":"assistant/chunk","seq":104,"time":1783352059863,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":105,"time":1783352059863,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" been"}}} +{"type":"assistant/chunk","seq":106,"time":1783352059864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" written"}}} +{"type":"assistant/chunk","seq":107,"time":1783352059864,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" successfully"}}} +{"type":"assistant/chunk","seq":108,"time":1783352059892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":109,"time":1783352059892,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":110,"time":1783352059893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":111,"time":1783352059893,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":112,"time":1783352059920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":113,"time":1783352059920,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":114,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":115,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":116,"time":1783352059921,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":117,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" single"}}} +{"type":"assistant/chunk","seq":118,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":119,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":120,"time":1783352059950,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"D"}}} +{"type":"assistant/chunk","seq":121,"time":1783352059951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":122,"time":1783352059951,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":123,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":124,"time":1783352059979,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":125,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":126,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."}}}} +{"type":"assistant/chunk","seq":127,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":128,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}}}} +{"type":"assistant/chunk","seq":129,"time":1783352059980,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":130,"time":1783352059981,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The todos have been written successfully. Now I just need to reply with the single word \"DONE\"."},{"type":"text","text":"DONE"}],"provenance":{"provider":"deepseek","model":"deepseek-v4-flash"},"usage":{"inputTokens":237,"outputTokens":24,"cacheReadTokens":2816,"reasoningTokens":21}},"sourceEventSeqs":[101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129],"surfaceOp":"append"} +{"type":"step/end","seq":131,"time":1783352059981,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":132,"time":1783352059981,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt new file mode 100644 index 0000000000..2d6520f067 --- /dev/null +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt @@ -0,0 +1,81 @@ +terminal 100x36 buffer=normal length=36 base=0 viewport=0 +lifecycle started=1 stopped=0 progress=inactive +title "DSH TUI snapshot" +cursor hidden column=1 viewportRow=33 bufferRow=33 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Recorded replay: todo-plan │" + style 0-0 fg=bright-blue + style 2-27 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Use the todo_write tool to record a plan with exactly three todos: \"read the code\" (in_progress), " + style 0-0 fg=bright-blue +9| "▌ \"write the fix\" (pending), \"run the tests\" (pending). Send all three in one todo_write call. Then " + style 0-0 fg=bright-blue +10| "▌ reply with the single word DONE and stop. " + style 0-0 fg=bright-blue +11| "▌ " + style 0-0 fg=bright-blue +12| +13| " Reasoning " + style 1-9 fg=bright-black italic +14| " The user wants me to use the todo_write tool to record a plan with exactly three todos in the " + style 1-99 fg=bright-black italic +15| " specified statuses, then reply with \"DONE\". " + style 1-43 fg=bright-black italic +16| +17| "▌ " + style 0-0 fg=green +18| "▌ ✓ Update todo list " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-19 bold +19| "▌ Updated todo list: 2 pending, 1 in progress, 0 completed. " + style 0-0 fg=green +20| "▌ " + style 0-0 fg=green +21| +22| " Reasoning " + style 1-9 fg=bright-black italic +23| " The todos have been written successfully. Now I just need to reply with the single word \"DONE\". " + style 1-95 fg=bright-black italic +24| +25| " Assistant " + style 1-9 fg=bright-magenta bold +26| " DONE " +27| +28| "Plan" + style 0-3 fg=bright-blue bold +29| " ● read the code" + style 2-2 fg=yellow +30| " ○ write the fix" + style 2-2 dim +31| " ○ run the tests" + style 2-2 dim +32| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +33| " " + style 1-1 inverse +34| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +35| "/workspace/project ↑3.1k ↓145 idle" + style 0-93 dim + style 96-99 dim diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts new file mode 100644 index 0000000000..453c587c2a --- /dev/null +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -0,0 +1,329 @@ +import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { basename, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import * as AgentCore from '@deepseek-ai/dsh-agent-spine-demo' +import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' +import WorkerCodeRuntime from '@deepseek-ai/dsh-code-runtime-worker' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { SessionId } from '@deepseek-ai/dsh-session' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' +import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' +import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' +import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +import { createTuiChat } from '@deepseek-ai/dsh-tui' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' +import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' + +const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash' }] }] +const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi + +type SnapshotMode = 'replay' | 'record' | 'refresh' +type Composition = 'native' | 'code' | 'advanced' + +interface Scenario { + name: string + composition: Composition + expectedTools: string[] + expectedEventCounts?: Record + childSessions?: number + recorded: boolean + seedWorkspace?: boolean +} + +const SCENARIOS: Scenario[] = [ + { + name: 'multi-turn-conversation', + composition: 'native', + expectedTools: [], + recorded: true, + }, + { + name: 'todo-plan', + composition: 'native', + expectedTools: ['todo_write'], + expectedEventCounts: { 'todo/write': 1 }, + recorded: true, + }, + { + name: 'bash-terminal-card', + composition: 'native', + expectedTools: ['bash'], + recorded: true, + }, + { + name: 'parallel-file-reads', + composition: 'native', + expectedTools: ['read', 'read'], + recorded: true, + seedWorkspace: true, + }, + { + name: 'code-mode', + composition: 'code', + expectedTools: ['run_code'], + expectedEventCounts: { 'tool/code-dispatch': 2 }, + recorded: true, + }, + { + name: 'dynamic-workflow', + composition: 'native', + expectedTools: ['workflow'], + childSessions: 1, + recorded: true, + }, + { + name: 'cordis-dynamic-toolchain', + composition: 'advanced', + expectedTools: ['cordis_mount', 'run_code', 'subagent', 'workflow', 'cordis_unmount'], + expectedEventCounts: { 'tool/code-dispatch': 1 }, + childSessions: 2, + recorded: false, + }, +] + +function snapshotModeFromEnv(value: string | undefined): SnapshotMode { + if (value === undefined || value === '' || value === 'replay') return 'replay' + if (value === 'record' || value === 'refresh') return value + throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) +} + +const MODE = snapshotModeFromEnv(process.env.DSH_SNAPSHOT) +const observedScenarios = new Set() + +function scenarioDir(scenario: Scenario): string { + return join(SNAPSHOTS_DIR, scenario.name) +} + +function childFixturePaths(scenario: Scenario): string[] { + return Array.from( + { length: scenario.childSessions ?? 0 }, + (_, index) => join(scenarioDir(scenario), `session.${index + 1}.jsonl`), + ) +} + +function userPrompts(rawLog: string): string[] { + return parseSessionLog(rawLog).flatMap((event) => { + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return [] + const text = event.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + return text.length > 0 ? [text] : [] + }) +} + +function rawSessionLog(session: Session): string { + return [ + JSON.stringify({ type: 'session', ...session.header }), + ...session.events.map(event => JSON.stringify(event)), + '', + ].join('\n') +} + +function normalizeTerminalSnapshot(snapshot: string, cwd: string): string { + return snapshot + .split(`/private${cwd}`).join('/workspace/project') + .split(cwd).join('/workspace/project') + .replace(UUID_RE, '{{uuid}}') +} + +async function settleTerminal(terminal: HeadlessTerminal): Promise { + let stable = 0 + for (let attempt = 0; attempt < 20 && stable < 3; attempt++) { + const before = terminal.frames + await new Promise(resolve => setTimeout(resolve, 10)) + await terminal.flush() + stable = terminal.frames === before ? stable + 1 : 0 + } + if (stable < 3) throw new Error('TUI frames did not quiesce within 200ms') +} + +async function mountScenarioContext( + scenario: Scenario, + cwd: string, + fixtureFile: string, + childFiles: string[], +): Promise { + const ctx = new Context() + await ctx.plugin(AgentCore, { + agents: [], + dshHome: join(cwd, '.dsh'), + workspaceContext: false, + tools: { mode: scenario.composition === 'code' ? 'code' : scenario.composition === 'advanced' ? 'both' : 'native' }, + skills: { local: { agentsHome: join(cwd, '.agents') } }, + }) + await ctx.plugin(LocalBashExecutor, { cwd, timeoutMs: 30_000 }) + await ctx.plugin(LocalFileSystem, { cwd: '/' }) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs) + await ctx.plugin(UserInteractionService) + await ctx.plugin(ToolTodo) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(ToolSubagent, { provider: 'spawn', toolName: 'subagent', enableRunInBackground: false }) + await ctx.plugin(WorkerWorkflowEngine, { provider: 'spawn' }) + await ctx.plugin(ToolWorkflow) + if (scenario.composition === 'code' || scenario.composition === 'advanced') { + await ctx.plugin(WorkerCodeRuntime, {}) + } + if (scenario.composition === 'advanced') await ctx.plugin(ToolCordis, { vmTimeoutMs: 5_000 }) + if (MODE === 'record' && scenario.recorded) { + await ctx.plugin(LlmDeepSeek) + } else { + installLlmReplay(ctx, { file: fixtureFile, childFiles, providers: PROVIDERS }) + } + return ctx +} + +interface ScenarioResult { + terminal: string + parent: Session + children: Session[] + workflowEvents: string[] +} + +async function runScenario(scenario: Scenario): Promise { + const dir = scenarioDir(scenario) + const fixtureFile = join(dir, 'session.jsonl') + const childFiles = childFixturePaths(scenario) + const fixture = await readFile(fixtureFile, 'utf8') + const prompts = userPrompts(fixture) + expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) + + const cwd = await mkdtemp(join(tmpdir(), `dsh-tui-snapshot-${scenario.name}-`)) + let ctx: Context | undefined + let controller: ReturnType | undefined + const terminal = new HeadlessTerminal(100, 36) + try { + if (scenario.seedWorkspace === true) { + const source = join(scenarioDir(scenario), 'workspace') + await cp(source, cwd, { recursive: true }) + } + ctx = await mountScenarioContext(scenario, cwd, fixtureFile, childFiles) + const disposedSessions: Session[] = [] + ctx.on('session/disposed', (session) => { disposedSessions.push(session) }) + const workflowEvents: string[] = [] + for (const name of ['workflow/start', 'workflow/phase', 'workflow/agent-start', 'workflow/agent-end', 'workflow/end'] as const) { + ctx.on(name, () => { workflowEvents.push(name) }) + } + const handle = await ctx.agents.create({ + agentId: AgentId('main'), + sessionId: SessionId('main-session'), + meta: { cwd }, + agentOptions: { provider: 'deepseek', model: 'deepseek-v4-flash' }, + }) + const agent: Agent = handle.agent + controller = createTuiChat(ctx, { + agent: 'main', + color: true, + showReasoning: true, + title: 'DSH TUI snapshot', + welcome: `Recorded replay: ${scenario.name}`, + maxToolOutputLines: 8, + }, { terminal, exit: () => {} }) + await settleTerminal(terminal) + + for (const prompt of prompts) { + terminal.send(prompt) + terminal.send('\r') + await agent.whenIdle() + await settleTerminal(terminal) + } + + const events: SessionEvent[] = [...agent.session.events] + expect(events.filter(event => event.type === 'tool/call').map(event => event.data.name)).toEqual(scenario.expectedTools) + for (const [type, count] of Object.entries(scenario.expectedEventCounts ?? {})) { + expect(events.filter(event => event.type === type), `${scenario.name} must emit ${type}`).toHaveLength(count) + } + expect(events.filter(event => event.type === 'tool/result').every(event => !event.data.isError)).toBe(true) + expect(events.filter(event => event.type === 'turn/end').every(event => event.data.reason.kind !== 'error')).toBe(true) + if (scenario.name === 'dynamic-workflow' || scenario.name === 'cordis-dynamic-toolchain') { + expect(workflowEvents).toEqual([ + 'workflow/start', + 'workflow/phase', + 'workflow/agent-start', + 'workflow/agent-end', + 'workflow/end', + ]) + } + + expect(terminal.themeViolations(), `${scenario.name} must remain theme-agnostic`).toEqual([]) + const snapshot = normalizeTerminalSnapshot( + await terminal.snapshot({ includeScrollback: true }), + cwd, + ) + await handle.dispose() + const children = disposedSessions + .filter(session => session !== agent.session) + .sort((a, b) => a.header.createdAt - b.header.createdAt) + expect(children).toHaveLength(scenario.childSessions ?? 0) + return { terminal: snapshot, parent: agent.session, children, workflowEvents } + } finally { + await controller?.dispose() + await ctx?.fiber.dispose() + await terminal.dispose() + await rm(cwd, { recursive: true, force: true }) + } +} + +async function writeRecording(scenario: Scenario, result: ScenarioResult): Promise { + const dir = scenarioDir(scenario) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, 'session.jsonl'), scrubRequestHeaders(rawSessionLog(result.parent))) + expect(result.children).toHaveLength(scenario.childSessions ?? 0) + for (const [index, child] of result.children.entries()) { + await writeFile(join(dir, `session.${index + 1}.jsonl`), scrubRequestHeaders(rawSessionLog(child))) + } +} + +describe('TUI recorded-session terminal snapshots', () => { + for (const scenario of SCENARIOS) { + it(scenario.name, async () => { + observedScenarios.add(scenario.name) + const result = await runScenario(scenario) + const terminalFile = join(scenarioDir(scenario), 'terminal.golden.txt') + if (MODE === 'record' || MODE === 'refresh') { + await mkdir(scenarioDir(scenario), { recursive: true }) + await writeFile(terminalFile, result.terminal) + } + if (MODE === 'record' && scenario.recorded) await writeRecording(scenario, result) + await expect(result.terminal).toMatchFileSnapshot(terminalFile) + }, 120_000) + } +}) + +afterAll(async () => { + expect([...observedScenarios].sort()).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) + const directories = (await readdir(SNAPSHOTS_DIR, { withFileTypes: true })) + .filter(entry => entry.isDirectory()) + .map(entry => entry.name) + .sort() + expect(directories).toEqual(SCENARIOS.map(scenario => scenario.name).sort()) + for (const scenario of SCENARIOS) { + const expected = [ + 'session.jsonl', + 'terminal.golden.txt', + ...scenario.seedWorkspace === true ? ['workspace'] : [], + ...Array.from({ length: scenario.childSessions ?? 0 }, (_, index) => `session.${index + 1}.jsonl`), + ].sort() + expect((await readdir(scenarioDir(scenario))).sort()).toEqual(expected) + for (const fixture of ['session.jsonl', ...childFixturePaths(scenario).map(path => basename(path))]) { + const content = await readFile(join(scenarioDir(scenario), fixture), 'utf8') + expect(scrubRequestHeaders(content), `${scenario.name}/${fixture} carries request-header bulk`).toBe(content) + } + } +}) diff --git a/packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt b/packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt deleted file mode 100644 index 72f7fce352..0000000000 --- a/packages/ui/tui/tests/snapshots/code-mode-complete.golden.txt +++ /dev/null @@ -1,50 +0,0 @@ -terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=1 viewportRow=14 bufferRow=14 -buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" - style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ main • deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-44 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=green -7| "▌ ✓ const first = await tools.bash({ command: 'echo CODE_ONE' }) " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-95 bold -8| "▌ const second = await tools.bas " - style 0-0 fg=green - style 2-31 bold -9| "▌ CODE_ONE " - style 0-0 fg=green -10| "▌ CODE_TWO " - style 0-0 fg=green -11| "▌ combined: CODE_ONE+CODE_TWO " - style 0-0 fg=green -12| "▌ " - style 0-0 fg=green -13| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -14| " " - style 1-1 inverse -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 63-95 dim -17-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-complete.golden.txt b/packages/ui/tui/tests/snapshots/conversation-complete.golden.txt deleted file mode 100644 index 8642d2ffa1..0000000000 --- a/packages/ui/tui/tests/snapshots/conversation-complete.golden.txt +++ /dev/null @@ -1,75 +0,0 @@ -terminal 96x36 buffer=normal length=43 base=7 viewport=7 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=40 -viewport -7| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -8| "▌ Explain snapshot fidelity with cells. " - style 0-0 fg=bright-blue - style 10-26 bold - style 33-37 fg=cyan -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " Compare the terminal state, not write fragments. " - style 1-48 fg=bright-black italic -13| -14| " Assistant " - style 1-9 fg=bright-magenta bold -15| " Result " - style 1-6 fg=bright-blue bold -16| " " -17| " - final viewport " - style 1-2 fg=bright-blue -18| " - semantic styles " - style 1-2 fg=bright-blue -19| " " -20| " │ deterministic and reviewable " - style 1-2 fg=bright-magenta - style 3-30 fg=bright-black italic -21| -22| "▌ " - style 0-0 fg=bright-blue -23| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -24| "▌ Show the live update. " - style 0-0 fg=bright-blue -25| "▌ " - style 0-0 fg=bright-blue -26| -27| " Reasoning " - style 1-9 fg=bright-black italic -28| " Inspecting width and styles. " - style 1-28 fg=bright-black italic -29| -30| " Assistant " - style 1-9 fg=bright-magenta bold -31| " Streaming visible state is complete. " - style 11-23 bold -32| -33| " The model reached its output-token limit. " - style 1-41 fg=yellow -34| -35| "Plan" - style 0-3 fg=bright-blue bold -36| " ✓ model the terminal" - style 2-2 fg=green - style 4-21 fg=bright-black -37| " ● capture advanced states" - style 2-2 fg=yellow -38| " ○ verify PTY cleanup" - style 2-2 dim -39| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -40| " " - style 1-1 inverse -41| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -42| "/workspace/project ↑13k ↓760 idle reasoning:on tools:compact" - style 0-28 dim - style 63-95 dim diff --git a/packages/ui/tui/tests/snapshots/conversation-replay.golden.txt b/packages/ui/tui/tests/snapshots/conversation-replay.golden.txt deleted file mode 100644 index 2395db555b..0000000000 --- a/packages/ui/tui/tests/snapshots/conversation-replay.golden.txt +++ /dev/null @@ -1,73 +0,0 @@ -terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=1 viewportRow=27 bufferRow=27 -viewport -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" - style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ main • deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-44 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=bright-blue -7| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -8| "▌ Explain snapshot fidelity with cells. " - style 0-0 fg=bright-blue - style 10-26 bold - style 33-37 fg=cyan -9| "▌ " - style 0-0 fg=bright-blue -10| -11| " Reasoning " - style 1-9 fg=bright-black italic -12| " Compare the terminal state, not write fragments. " - style 1-48 fg=bright-black italic -13| -14| " Assistant " - style 1-9 fg=bright-magenta bold -15| " Result " - style 1-6 fg=bright-blue bold -16| " " -17| " - final viewport " - style 1-2 fg=bright-blue -18| " - semantic styles " - style 1-2 fg=bright-blue -19| " " -20| " │ deterministic and reviewable " - style 1-2 fg=bright-magenta - style 3-30 fg=bright-black italic -21| -22| "Plan" - style 0-3 fg=bright-blue bold -23| " ✓ model the terminal" - style 2-2 fg=green - style 4-21 fg=bright-black -24| " ● capture advanced states" - style 2-2 fg=yellow -25| " ○ verify PTY cleanup" - style 2-2 dim -26| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -27| " " - style 1-1 inverse -28| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -29| "/workspace/project ↑13k ↓640 idle reasoning:on tools:compact" - style 0-28 dim - style 63-95 dim -30-35| diff --git a/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt b/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt index 55044a44f9..12a4251bb9 100644 --- a/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt +++ b/packages/ui/tui/tests/snapshots/conversation-streaming.golden.txt @@ -1,75 +1,52 @@ -terminal 96x36 buffer=normal length=41 base=5 viewport=5 +terminal 96x36 buffer=normal length=36 base=0 viewport=0 lifecycle started=1 stopped=0 progress=inactive title "DSH snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=38 +cursor hidden column=1 viewportRow=17 bufferRow=17 viewport +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-95 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 95-95 fg=bright-blue +2| "│ Snapshot agent ready. │" + style 0-0 fg=bright-blue + style 2-22 fg=bright-black + style 95-95 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 95-95 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-95 fg=bright-blue 5| 6| "▌ " style 0-0 fg=bright-blue 7| "▌ You " style 0-0 fg=bright-blue style 2-4 fg=bright-blue bold -8| "▌ Explain snapshot fidelity with cells. " +8| "▌ Show the live update. " style 0-0 fg=bright-blue - style 10-26 bold - style 33-37 fg=cyan 9| "▌ " style 0-0 fg=bright-blue 10| 11| " Reasoning " style 1-9 fg=bright-black italic -12| " Compare the terminal state, not write fragments. " - style 1-48 fg=bright-black italic +12| " Inspecting width and styles. " + style 1-28 fg=bright-black italic 13| 14| " Assistant " style 1-9 fg=bright-magenta bold -15| " Result " - style 1-6 fg=bright-blue bold -16| " " -17| " - final viewport " - style 1-2 fg=bright-blue -18| " - semantic styles " - style 1-2 fg=bright-blue -19| " " -20| " │ deterministic and reviewable " - style 1-2 fg=bright-magenta - style 3-30 fg=bright-black italic -21| -22| "▌ " - style 0-0 fg=bright-blue -23| "▌ You " - style 0-0 fg=bright-blue - style 2-4 fg=bright-blue bold -24| "▌ Show the live update. " - style 0-0 fg=bright-blue -25| "▌ " - style 0-0 fg=bright-blue -26| -27| " Reasoning " - style 1-9 fg=bright-black italic -28| " Inspecting width and styles. " - style 1-28 fg=bright-black italic -29| -30| " Assistant " - style 1-9 fg=bright-magenta bold -31| " Streaming visible state… " +15| " Streaming visible state… " style 11-23 bold -32| -33| "Plan" - style 0-3 fg=bright-blue bold -34| " ✓ model the terminal" - style 2-2 fg=green - style 4-21 fg=bright-black -35| " ● capture advanced states" - style 2-2 fg=yellow -36| " ○ verify PTY cleanup" - style 2-2 dim -37| "────────────────────────────────────────────────────────────────────────────────────────────────" +16| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -38| " " +17| " " style 1-1 inverse -39| "────────────────────────────────────────────────────────────────────────────────────────────────" +18| "────────────────────────────────────────────────────────────────────────────────────────────────" style 0-95 dim -40| "/workspace/project ↑13k ↓640 idle reasoning:on tools:compact" - style 0-28 dim +19| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim style 63-95 dim +20-35| diff --git a/packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt b/packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt deleted file mode 100644 index d33b56d7ce..0000000000 --- a/packages/ui/tui/tests/snapshots/cordis-tools-complete.golden.txt +++ /dev/null @@ -1,73 +0,0 @@ -terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=1 viewportRow=25 bufferRow=25 -buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" - style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ main • deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-44 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=green -7| "▌ ✓ Inspect cordis runtime: tools " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-32 bold -8| "▌ ## tools " - style 0-0 fg=green -9| "▌ run_code " - style 0-0 fg=green -10| "▌ workflow " - style 0-0 fg=green -11| "▌ cordis_mount " - style 0-0 fg=green -12| "▌ cordis_unmount " - style 0-0 fg=green -13| "▌ " - style 0-0 fg=green -14| -15| "▌ " - style 0-0 fg=green -16| "▌ ✓ Mount plugin into live cordis runtime " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-40 bold -17| "▌ mounted dyn-1 (plugin \"snapshot-marker\", state: active) " - style 0-0 fg=green -18| "▌ " - style 0-0 fg=green -19| -20| "▌ " - style 0-0 fg=green -21| "▌ ✓ Unmount dyn-1 " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-16 bold -22| "▌ unmounted dyn-1 (plugin \"snapshot-marker\") " - style 0-0 fg=green -23| "▌ " - style 0-0 fg=green -24| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -25| " " - style 1-1 inverse -26| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -27| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 63-95 dim -28-35| diff --git a/packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt b/packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt deleted file mode 100644 index 00da73a956..0000000000 --- a/packages/ui/tui/tests/snapshots/dynamic-workflow-complete.golden.txt +++ /dev/null @@ -1,53 +0,0 @@ -terminal 96x36 buffer=normal length=36 base=0 viewport=0 -lifecycle started=1 stopped=0 progress=inactive -title "DSH snapshot" -cursor hidden column=1 viewportRow=16 bufferRow=16 -buffer -0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮" - style 0-95 fg=bright-blue -1| "│ DEEPSEEK HARNESS │" - style 0-0 fg=bright-blue - style 2-9 fg=bright-blue bold - style 11-17 bold - style 95-95 fg=bright-blue -2| "│ Snapshot agent ready. │" - style 0-0 fg=bright-blue - style 2-22 fg=bright-black - style 95-95 fg=bright-blue -3| "│ main • deepseek-v4-flash • main-session │" - style 0-0 fg=bright-blue - style 2-44 dim - style 95-95 fg=bright-blue -4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯" - style 0-95 fg=bright-blue -5| -6| "▌ " - style 0-0 fg=green -7| "▌ ✓ workflow: tui-matrix " - style 0-0 fg=green - style 2-2 fg=green bold - style 3-23 bold -8| "▌ workflow \"tui-matrix\" completed (2 agents). " - style 0-0 fg=green -9| "▌ Return value: " - style 0-0 fg=green -10| "▌ { " - style 0-0 fg=green -11| "▌ \"reports\": [\"layout ok\", \"lifecycle ok\"], " - style 0-0 fg=green -12| "▌ \"verdict\": \"covered\" " - style 0-0 fg=green -13| "▌ } " - style 0-0 fg=green -14| "▌ " - style 0-0 fg=green -15| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -16| " " - style 1-1 inverse -17| "────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-95 dim -18| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" - style 0-24 dim - style 63-95 dim -19-35| diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index eb6d5e9965..0967e7f64c 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -23,15 +23,10 @@ const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const REFRESHING = process.env.DSH_SNAPSHOT === 'refresh' const CHECKPOINTS = [ - 'conversation-replay', 'conversation-streaming', - 'conversation-complete', 'code-mode-pending', - 'code-mode-complete', 'dynamic-workflow-pending', - 'dynamic-workflow-complete', 'cordis-tools-pending', - 'cordis-tools-complete', 'advanced-cards-collapsed', 'advanced-cards-expanded', 'question-dialog', @@ -194,25 +189,8 @@ const ADVANCED_CARD_TOOLS: Record = { } describe('TUI terminal-state snapshots', () => { - it('pins resumed conversation, streaming, completion, plans, tokens, and Markdown', async () => { - const harness = await setupSnapshot({ - beforeMount(session) { - appendUser(session, 'Explain **snapshot fidelity** with `cells`.') - appendAssistant(session, [ - { type: 'reasoning', text: 'Compare the terminal state, not write fragments.' }, - { type: 'text', text: '## Result\n\n- final viewport\n- semantic styles\n\n> deterministic and reviewable' }, - ], { inputTokens: 12_500, outputTokens: 640 }) - session.append('todo/write', { - todos: [ - { content: 'model the terminal', status: 'completed' }, - { content: 'capture advanced states', status: 'in_progress' }, - { content: 'verify PTY cleanup', status: 'pending' }, - ], - }) - }, - }) - await checkpoint('conversation-replay', harness.terminal) - + it('pins an in-flight reasoning and Markdown stream', async () => { + const harness = await setupSnapshot() await renderAfter(harness, () => { appendUser(harness.session, 'Show the live update.') harness.session.append('assistant/chunk', { @@ -237,15 +215,6 @@ describe('TUI terminal-state snapshots', () => { }) }) await checkpoint('conversation-streaming', harness.terminal) - - await renderAfter(harness, () => { - appendAssistant(harness.session, [ - { type: 'reasoning', text: 'Inspecting width and styles.' }, - { type: 'text', text: 'Streaming **visible state** is complete.' }, - ], { inputTokens: 800, outputTokens: 120 }) - harness.session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } }) - }) - await checkpoint('conversation-complete', harness.terminal) await disposeSnapshot(harness) }) @@ -260,13 +229,6 @@ describe('TUI terminal-state snapshots', () => { } await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) }) await checkpoint('code-mode-pending', harness.terminal, { includeScrollback: true }) - - await renderAfter(harness, () => { - appendToolResult(harness.session, call.id, [{ type: 'text', text: 'CODE_ONE\n+CODE_TWO' }], { - meta: { logs: ['CODE_ONE', 'CODE_TWO', 'combined: CODE_ONE+CODE_TWO'] }, - }) - }) - await checkpoint('code-mode-complete', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) @@ -290,14 +252,6 @@ describe('TUI terminal-state snapshots', () => { } await renderAfter(harness, () => { appendToolCalls(harness.session, [call]) }) await checkpoint('dynamic-workflow-pending', harness.terminal, { includeScrollback: true }) - - await renderAfter(harness, () => { - appendToolResult(harness.session, call.id, [{ - type: 'text', - text: 'workflow "tui-matrix" completed (2 agents).\nReturn value:\n{\n "reports": ["layout ok", "lifecycle ok"],\n "verdict": "covered"\n}', - }]) - }) - await checkpoint('dynamic-workflow-complete', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) @@ -314,13 +268,6 @@ describe('TUI terminal-state snapshots', () => { ] await renderAfter(harness, () => { appendToolCalls(harness.session, calls) }) await checkpoint('cordis-tools-pending', harness.terminal, { includeScrollback: true }) - - await renderAfter(harness, () => { - appendToolResult(harness.session, 'cordis-1', [{ type: 'text', text: '## tools\nrun_code\nworkflow\ncordis_mount\ncordis_unmount' }]) - appendToolResult(harness.session, 'cordis-2', [{ type: 'text', text: 'mounted dyn-1 (plugin "snapshot-marker", state: active)' }]) - appendToolResult(harness.session, 'cordis-3', [{ type: 'text', text: 'unmounted dyn-1 (plugin "snapshot-marker")' }]) - }) - await checkpoint('cordis-tools-complete', harness.terminal, { includeScrollback: true }) await disposeSnapshot(harness) }) From 81ca81e23dea201a92b9cc6de38dddf5d8cb790c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:42:16 +0800 Subject: [PATCH 142/163] test(tui): stabilize snapshot workspace width --- .../bash-terminal-card/terminal.golden.txt | 5 +- .../snapshots/code-mode/terminal.golden.txt | 6 +- .../terminal.golden.txt | 5 +- .../dynamic-workflow/terminal.golden.txt | 5 +- .../terminal.golden.txt | 5 +- .../parallel-file-reads/terminal.golden.txt | 65 +++++++++---------- .../snapshots/todo-plan/terminal.golden.txt | 6 +- examples/tui-agent/tests/tui.snapshot.ts | 4 +- 8 files changed, 52 insertions(+), 49 deletions(-) diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt index 30770dcccb..ca123479dc 100644 --- a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.golden.txt @@ -67,6 +67,7 @@ buffer style 1-1 inverse 28| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -29| "/workspace/project ↑3.0k ↓" - style 0-99 dim +29| "/workspace/project ↑3.0k ↓115 idle reasoning:on tools:compact" + style 0-58 dim + style 67-99 dim 30-35| diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt b/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt index a7f1457d7a..428bdf80b4 100644 --- a/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/code-mode/terminal.golden.txt @@ -73,7 +73,7 @@ buffer style 1-1 inverse 30| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -31| "/workspace/project ↑3.1k ↓158 idle" - style 0-93 dim - style 96-99 dim +31| "/workspace/project ↑3.1k ↓158 idle reasoning:on tools:compact" + style 0-49 dim + style 67-99 dim 32-35| diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt index 5c5afc10ca..3359f54ca5 100644 --- a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.golden.txt @@ -111,5 +111,6 @@ buffer style 1-1 inverse 48| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -49| "/workspace/project ↑" - style 0-99 dim +49| "/workspace/project ↑18 ↓18 idle reasoning:on tools:compact" + style 0-61 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt index 8589ac94ea..1970a3761d 100644 --- a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.golden.txt @@ -101,5 +101,6 @@ buffer style 1-1 inverse 45| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -46| "/workspace/project ↑3.5k ↓22" - style 0-99 dim +46| "/workspace/project ↑3.5k ↓227 idle reasoning:on tools:compact" + style 0-56 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt index fca6c6dd4a..4c1f3b2c62 100644 --- a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.golden.txt @@ -64,6 +64,7 @@ buffer style 1-1 inverse 29| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -30| "/workspace/project ↑2" - style 0-99 dim +30| "/workspace/project ↑2.9k ↓41 idle reasoning:on tools:compact" + style 0-62 dim + style 67-99 dim 31-35| diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt index 7906694d0a..5fe973c177 100644 --- a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.golden.txt @@ -1,7 +1,7 @@ -terminal 100x36 buffer=normal length=41 base=5 viewport=5 +terminal 100x36 buffer=normal length=39 base=3 viewport=3 lifecycle started=1 stopped=0 progress=inactive title "DSH TUI snapshot" -cursor hidden column=1 viewportRow=33 bufferRow=38 +cursor hidden column=1 viewportRow=33 bufferRow=36 buffer 0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" style 0-99 fg=bright-blue @@ -37,58 +37,55 @@ buffer style 0-0 fg=green style 2-2 fg=green bold style 3-13 bold -13| "▌ /workspace/project" +13| "▌ /workspace/project/a.txt " style 0-0 fg=green -14| "▌ /a.txt " +14| "▌ file " style 0-0 fg=green -15| "▌ file " +15| "▌ " style 0-0 fg=green -16| "▌ " +16| "▌ 1: alpha " style 0-0 fg=green -17| "▌ 1: alpha " +17| "▌ " style 0-0 fg=green -18| "▌ " +18| "▌ (End of file - total 1 lines) " style 0-0 fg=green -19| "▌ (End of file - total 1 lines) " +19| "▌ " style 0-0 fg=green -20| "▌ " +20| "▌ " style 0-0 fg=green -21| "▌ " +21| +22| "▌ " style 0-0 fg=green -22| -23| "▌ " - style 0-0 fg=green -24| "▌ ✓ Read b.txt " +23| "▌ ✓ Read b.txt " style 0-0 fg=green style 2-2 fg=green bold style 3-13 bold -25| "▌ /workspace/project" +24| "▌ /workspace/project/b.txt " style 0-0 fg=green -26| "▌ /b.txt " +25| "▌ file " style 0-0 fg=green -27| "▌ file " +26| "▌ " style 0-0 fg=green -28| "▌ " +27| "▌ 1: beta " style 0-0 fg=green -29| "▌ 1: beta " +28| "▌ " style 0-0 fg=green -30| "▌ " +29| "▌ (End of file - total 1 lines) " style 0-0 fg=green -31| "▌ (End of file - total 1 lines) " +30| "▌ " style 0-0 fg=green -32| "▌ " +31| "▌ " style 0-0 fg=green -33| "▌ " - style 0-0 fg=green -34| -35| " Assistant " +32| +33| " Assistant " style 1-9 fg=bright-magenta bold -36| " DONE " +34| " DONE " +35| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +36| " " + style 1-1 inverse 37| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -38| " " - style 1-1 inverse -39| "────────────────────────────────────────────────────────────────────────────────────────────────────" - style 0-99 dim -40| "/workspace/project ↑20 ↓6" - style 0-99 dim +38| "/workspace/project ↑20 ↓6 idle reasoning:on tools:compact" + style 0-55 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt b/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt index 2d6520f067..278015e244 100644 --- a/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt +++ b/examples/tui-agent/tests/snapshots/todo-plan/terminal.golden.txt @@ -76,6 +76,6 @@ buffer style 1-1 inverse 34| "────────────────────────────────────────────────────────────────────────────────────────────────────" style 0-99 dim -35| "/workspace/project ↑3.1k ↓145 idle" - style 0-93 dim - style 96-99 dim +35| "/workspace/project ↑3.1k ↓145 idle reasoning:on tools:compact" + style 0-49 dim + style 67-99 dim diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/examples/tui-agent/tests/tui.snapshot.ts index 453c587c2a..276c9c5f03 100644 --- a/examples/tui-agent/tests/tui.snapshot.ts +++ b/examples/tui-agent/tests/tui.snapshot.ts @@ -28,6 +28,8 @@ import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import { HeadlessTerminal } from '../../../packages/ui/tui/tests/headless-terminal.ts' const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') +// Keep pre-normalization layout widths identical across macOS and Linux. +const SNAPSHOT_TMP_ROOT = process.platform === 'win32' ? tmpdir() : '/tmp' const PROVIDERS = [{ id: 'deepseek', models: [{ id: 'deepseek-v4-flash' }] }] const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi @@ -204,7 +206,7 @@ async function runScenario(scenario: Scenario): Promise { const prompts = userPrompts(fixture) expect(prompts.length, `${scenario.name} must carry at least one recorded user prompt`).toBeGreaterThan(0) - const cwd = await mkdtemp(join(tmpdir(), `dsh-tui-snapshot-${scenario.name}-`)) + const cwd = await mkdtemp(join(SNAPSHOT_TMP_ROOT, `dsh-tui-snapshot-${scenario.name}-`)) let ctx: Context | undefined let controller: ReturnType | undefined const terminal = new HeadlessTerminal(100, 36) From 39b9c25b8992bbb9623a58820a7a8a3bab6d3175 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 01:59:54 +0800 Subject: [PATCH 143/163] test: refresh parallel tool call permission snapshot --- .../tests/snapshots/parallel-tool-calls/stdout.golden.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl index 6d7bc199e8..8680db3d30 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} From 3d19252e9643df4875b43669e7e22a32865ba857 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:03:35 +0800 Subject: [PATCH 144/163] test: refresh parallel tool call permission snapshot --- .../tests/snapshots/parallel-tool-calls/stdout.golden.jsonl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl index 6d7bc199e8..8680db3d30 100644 --- a/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/parallel-tool-calls/stdout.golden.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"loadSession":true,"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} -{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"Sets this session's sandbox and approval behavior.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}","configOptions":[{"id":"model","name":"Model","description":"Sets this session's provider and model.","category":"model","type":"select","currentValue":"[\"deepseek\",\"deepseek-v4-flash\"]","options":[{"value":"[\"deepseek\",\"deepseek-v4-flash\"]","name":"deepseek-v4-flash"},{"value":"[\"deepseek\",\"deepseek-v4-pro\"]","name":"deepseek-v4-pro"}]},{"id":"permission","name":"Permissions","description":"The session permission preset: each choice bundles a sandbox mode and an approval policy.","category":"mode","type":"select","currentValue":"danger-full-access","options":[{"value":"workspace-write","name":"workspace-write","description":"Write inside the workspace and permitted temporary directories; wider retries require approval."},{"value":"danger-full-access","name":"danger-full-access","description":"Full file access without approval prompts."}]}]}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_a","title":"Read a.txt","kind":"read","status":"in_progress","locations":[{"path":"a.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_read_b","title":"Read b.txt","kind":"read","status":"in_progress","locations":[{"path":"b.txt","line":1}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_read_a","status":"completed","content":[{"type":"content","content":{"type":"text","text":"{{cwd}}/a.txt\nfile\n\n1: alpha\n\n(End of file - total 1 lines)\n"}}]}}} From 3760f198f39222613f0079ff18be9b02af864c10 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:16:43 +0800 Subject: [PATCH 145/163] docs: distinguish readline and TUI demos --- AGENTS.md | 1 + README.i18n.yaml | 4 ++-- README.md | 5 ++++- README.zh.md | 5 ++++- docs/cookbook/extension-cookbook.i18n.yaml | 4 ++-- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/development.i18n.yaml | 4 ++-- docs/development.md | 14 +++++++++++++- docs/development.zh.md | 14 +++++++++++++- docs/i18n/translation-prompt.md | 6 +++--- packages/examples/stdio-demo/README.md | 4 ++-- 12 files changed, 48 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 50d157e191..3aed94d364 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,6 +58,7 @@ pnpm run doc-sync # all documentation gates; see the doc-sync script in pa pnpm run website:build # VitePress build (doubles as the site's dead-link check) pnpm run demo:echo # mock-model REPL, no key needed pnpm run demo:repl # real REPL coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # self-referential demo: the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP server agent (needs DEEPSEEK_API_KEY) ``` diff --git a/README.i18n.yaml b/README.i18n.yaml index 628d7168da..19c4ccf72d 100644 --- a/README.i18n.yaml +++ b/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -README.md: 92cbfaa8196b49d2b801776f0e0c178cb5f673dc -README.zh.md: 28604ad1e2926edd7d4eb91c8027785433a54d32 +README.md: aaa129272ee9346cebe2d59774d742fbe21af80a +README.zh.md: 42f2ed9b57bf008210042083977b7adbb7f1ab0e diff --git a/README.md b/README.md index 92cbfaa819..aaa129272e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,10 @@ This monorepo is built on the [Cordis](https://github.com/cordiverse/cordis) fra ```sh pnpm install pnpm run test # vitest -pnpm run demo:repl # interactive pi-tui coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:echo # keyless mock-model REPL +pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/README.zh.md b/README.zh.md index 28604ad1e2..42f2ed9b57 100644 --- a/README.zh.md +++ b/README.zh.md @@ -11,7 +11,10 @@ ```sh pnpm install pnpm run test # vitest -pnpm run demo:repl # interactive pi-tui coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:echo # keyless mock-model REPL +pnpm run demo:repl # readline coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:tui # full-screen TUI coding agent (needs DEEPSEEK_API_KEY) +pnpm run demo:cordis # self-referential agent demo (needs DEEPSEEK_API_KEY) pnpm run demo:acp # ACP server agent demo (needs DEEPSEEK_API_KEY) ``` diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 598e9827b1..4ebefda6eb 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 02dc22ff72807629a89d602f49752e6f429587b5 -extension-cookbook.zh.md: a204abdf6656312aea94640c60038af9f81f4f7b +extension-cookbook.md: c500aac230b6efc224c9d9e54bce75ef09b7173a +extension-cookbook.zh.md: d886b8ed40e32b2697e82172a86ca3b9653f287d diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 02dc22ff72..c500aac230 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Three complete examples load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool — the all-mock skeleton check, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + the bash tool suite behind a pi-tui coding interface with a readline fallback, `pnpm run demo:repl`), and [`examples/acp-agent`](../../examples/acp-agent) (an agent exposed as an ACP server over JSON-RPC stdio — the client-driver shape, `pnpm run demo:acp`). Each leaf is just its swappable backends plus an app-package entry: the stdio demos load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP demo loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine via the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle. +Five runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + coding tools through a line-oriented readline REPL, `pnpm run demo:repl`), [`examples/tui-agent`](../../examples/tui-agent) (the same coding composition through full-screen pi-tui, `pnpm run demo:tui`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). The terminal leaves load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine through [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index a204abdf66..d886b8ed40 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -三个完整示例从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具——全 mock 骨架检查,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + bash 工具套件,配合 pi-tui 编码界面,并为管道输入保留 readline 回退,`pnpm run demo:repl`)、[`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露为 ACP 服务器的 agent——客户端驱动形态,`pnpm run demo:acp`)。每个叶子只是其可替换后端加一个 app 包入口:stdio 演示加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 演示加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle 共享主干。 +五个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + coding 工具,通过面向行的 readline REPL 交互,`pnpm run demo:repl`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 pi-tui 复用相同的 coding 组装,`pnpm run demo:tui`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。终端叶子加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 1d687a3f90..3630b63815 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 512fdcd89b4d481dd2033bc4f4c2816a04b5feaa -development.zh.md: 9f2038a99b289d06f16d7d51f0d043caa34a300e +development.md: 79caf482d0d104ed170a57812dd4f34e2329de8b +development.zh.md: 75781932acedabd2e3593a89aed8360a9a18ca5b diff --git a/docs/development.md b/docs/development.md index 512fdcd89b..79caf482d0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,12 +109,24 @@ The echo demo does not need API credentials: pnpm run demo:echo ``` -The coding-agent demo uses pi-tui interactively, falls back to readline for pipes, and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: +The coding-agent REPL uses the line-oriented readline front door and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh pnpm run demo:repl ``` +The full-screen TUI reuses the coding-agent composition through the pi-tui front door and needs the same credentials: + +```sh +pnpm run demo:tui +``` + +The self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials: + +```sh +pnpm run demo:cordis +``` + The ACP server agent demo exposes the agent over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`: ```sh diff --git a/docs/development.zh.md b/docs/development.zh.md index 9f2038a99b..75781932ac 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -109,12 +109,24 @@ echo 演示不需要 API 凭证: pnpm run demo:echo ``` -coding-agent 演示在交互终端中使用 pi-tui,对管道输入回退到 readline,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: +coding-agent REPL 使用面向行的 readline 前端,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:repl ``` +全屏 TUI 通过 pi-tui 前端复用 coding-agent 组装,并需要相同的凭证: + +```sh +pnpm run demo:tui +``` + +自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证: + +```sh +pnpm run demo:cordis +``` + ACP 服务器 agent 演示通过 JSON-RPC stdio 暴露 agent,同样需要 `DEEPSEEK_API_KEY`: ```sh diff --git a/docs/i18n/translation-prompt.md b/docs/i18n/translation-prompt.md index 4dd877bea4..cd87c161de 100644 --- a/docs/i18n/translation-prompt.md +++ b/docs/i18n/translation-prompt.md @@ -123,9 +123,9 @@ Follow the Good versions; these sentence-level examples illustrate error categor - Good: `A green gate does not mean the translation is correct.` ### Code block comments — never translate -- Source code block contains: `# interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)` -- Bad: `# 交互式 pi-tui 编码 agent(需要 DEEPSEEK_API_KEY)` -- Good: `# interactive pi-tui coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) +- Source code block contains: `# readline coding agent (needs DEEPSEEK_API_KEY)` +- Bad: `# readline 编码 agent(需要 DEEPSEEK_API_KEY)` +- Good: `# readline coding agent (needs DEEPSEEK_API_KEY)` (byte-identical) ### Language switcher — English to Chinese - Source: `English | [中文](README.zh.md)` diff --git a/packages/examples/stdio-demo/README.md b/packages/examples/stdio-demo/README.md index f1d4ecd212..3721116a22 100644 --- a/packages/examples/stdio-demo/README.md +++ b/packages/examples/stdio-demo/README.md @@ -18,7 +18,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@deepseek-ai/dsh-stdio` | the line-oriented terminal channel, bound to `main` for pipes and automation; matching `agent/start-failed` errors print and exit nonzero | | `@deepseek-ai/dsh-tui` | the interactive pi-tui channel, bound to `main` for TTY pairs; matching `agent/start-failed` startup errors are printed before fullscreen mode and exit nonzero | -`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. +`@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, not baked in here: it is a Loader-only, subprocess-only dev plugin whose constructor needs `node --expose-internals` plus a live `loader`. The repository's terminal demo trees load it and their scripts pass `--expose-internals`. The leaf `cordis.yml` supplies only the **swappable backends** — an LLM adapter (`llm-deepseek` for the real model, or the mock `mock-llm` for a demo) and a bash executor (`bash-local`) — `hmr`, plus this app's [`Config`](#config). The whole plugin tree a run loads is therefore: this app's cluster, the spine inside `agent-spine-demo`, `hmr`, and the two leaf backends. @@ -45,7 +45,7 @@ Fresh stdio sessions use the process launch directory as `session.header.cwd`, s ## The bin -`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The `demo:echo` / `demo:repl` scripts use `--expose-internals`. +`dsh-stdio-demo [path-to-cordis.yml]` (default `./cordis.yml`) loads a gitignored `.env` from the cwd (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`), then drives the cordis Loader against the config and awaits the whole plugin tree before returning. Run it under `node --expose-internals`, or install the Loader's optional `node-addon-require-builtin` fallback, so the Loader can resolve the config's bare plugin specifiers (`@deepseek-ai/dsh-*`, npm packages). The repository's demo scripts using this bin pass `--expose-internals`. ## Example leaf `cordis.yml` From 9f1a69366bb4644f781df1508cd2879d205f343e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 02:18:59 +0800 Subject: [PATCH 146/163] docs: tighten identity bridge prose --- docs/config-catalog.md | 10 +- .../feature/2026-07-06-approval-seam.md | 32 +-- .../2026-06-20-unify-agent-and-session-id.md | 2 +- packages/subagent/subagent/src/index.ts | 4 +- packages/ui/acp/src/index.ts | 267 +++--------------- packages/ui/jsonrpc/README.md | 12 +- packages/ui/jsonrpc/src/server.ts | 59 ++-- 7 files changed, 86 insertions(+), 300 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3b3e034601..c8e1a04036 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -20,20 +20,14 @@ export interface AcpConfig { provider?: string /** Model name for created agents (must have a registered adapter). */ model?: string - /** - * Transport stream override. Production omits this (the plugin wires - * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an - * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive - * the bridge without a subprocess. Not part of the schemastery `Config` — - * it is a runtime-only seam, never set from a `cordis.yml`. - */ + /** Runtime-only transport override; production uses stdio. */ stream?: Stream } ``` Depends on: `Stream` (`@agentclientprotocol/sdk`) -Source: [`packages/ui/acp/src/index.ts:247`](../packages/ui/acp/src/index.ts) +Source: [`packages/ui/acp/src/index.ts:206`](../packages/ui/acp/src/index.ts) ## `@deepseek-ai/dsh-acp-demo` diff --git a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md index 0945228593..81e60a726f 100644 --- a/docs/rfc/implemented/feature/2026-07-06-approval-seam.md +++ b/docs/rfc/implemented/feature/2026-07-06-approval-seam.md @@ -49,39 +49,39 @@ The `escalation-rejected` twin ends in `{"outcome": "rejected"}` instead: nothin #### The seam: mechanism and policy split -After request validation and a successful `approval/asked` append, the answerer phase always resolves to a closed `ApprovalOutcome` — `allowed-once` / `rejected` / `cancelled` / `unavailable`. `ApprovalRequest` is a readonly same-process contract, so the service borrows its routing identity and cancellation signal instead of copying the record or capturing a parallel callback bundle. It dispatches the `approval/request` waterfall, races the request signal (abort settles `cancelled`; a late answer is discarded, never double-audited), contains a throwing answerer as `unavailable`, normalizes a rogue non-vocabulary return to `unavailable`, and lands the log-only audit pair `approval/asked`/`approval/decided` (paired by the branded `ApprovalRequestId`) on the request agent's session log. Request acceptance and either pre-commit audit append may still reject; returning a decision that could not be logged would violate the pair. Session owns post-commit observer containment, so a callback failure cannot turn an authoritative audit append into a rejected request or suppress the matching event. Grants are one-shot by definition: `allowed-once` authorizes the single asked-about action, never a class of future ones, and the service stores nothing between requests. `request()` also throws before appending anything when the agent's session has no open turn — the audit pair must be turn-enclosed, the turn being the durable log's commit/replay boundary (a bare event between turns is dropped as crash tail on reload); every ask path runs mid-turn already, and idle asks are a deferred design. +After validation and a successful `approval/asked` append, the service resolves the `approval/request` waterfall to `allowed-once`, `rejected`, `cancelled`, or `unavailable`. It borrows the readonly request identity and signal, treats abort as `cancelled`, contains answerer failures and invalid returns as `unavailable`, discards late answers, and appends the paired `approval/decided` event. Pre-commit audit failures reject; post-append observer failures cannot undo an authoritative event. `allowed-once` authorizes only the asked action, and `request()` rejects outside an open turn so the audit pair remains inside the durable commit boundary. -Answerers are the policy, and they are `approval/request` waterfall listeners. The waterfall buys exactly what the seam needs: with zero listeners the dispatch falls through to the caller-supplied default — `unavailable`, so fail-closed needs no configuration and no code in any deployment; a listener that recognizes the request's agent answers by returning an outcome without calling `next()` (the decision slot is single-occupancy, first answer wins — the same documented semantics as the `fs/write-intent` gate); a listener that does not recognize the agent MUST delegate via `next()` so another answerer or the default gets the question; and listeners dispose with their owning fiber, so an unloaded UI plugin degrades the next ask to `unavailable` instead of leaving a dangling channel. Registration order across sibling plugins is not load-order deterministic (the loader starts siblings concurrently), so a deployment composes ONE terminal answerer and reserves `prepend` listeners for decide-or-delegate gates. +Answerers are `approval/request` waterfall listeners. Zero listeners fall through to `unavailable`; a recognizing listener occupies the first-wins decision slot, while an unrecognized agent must delegate with `next()`. Listeners dispose with their fibers, so an unloaded channel fails closed. Because sibling registration order is not deterministic, a deployment composes one terminal answerer and reserves `prepend` for decide-or-delegate gates. -`ApprovalRequest` carries the asking `agent` (routes the question; receives the audit events), the `toolName`, the optional exact `callId`, the asker's human-readable `reason`, and the optional `signal`. The caller retains ownership and honors the readonly contract for the duration of `request()`. The vocabulary is deliberately self-contained — it names the tool-call by the `CallId` brand from `dsh-llm` and never imports `dsh-tools` — because `dsh-tools` depends on `dsh-user-approval` (the ask routing) and a `ToolCallView` import would close a package cycle. It deliberately does NOT carry tool arguments: a UI answerer attaches the prompt to the already-streamed tool call via `callId` instead of re-rendering the call. +`ApprovalRequest` carries the asking `agent`, `toolName`, optional exact `callId`, human-readable `reason`, and optional `signal`. It uses the `CallId` brand without importing `dsh-tools`, which depends on this seam. Tool arguments stay on the already-streamed call that a UI references by `callId`. #### Ask routing in dsh-tools -`ToolRegistry.execute()` resolves an `ask` decision through the seam before the shared deny path: `allowed-once` proceeds to guards and dispatch, and the three non-grants deny with distinct reasons — "the user rejected…", "…was cancelled", "…no approval channel is available" — so the model can tell a human "no" from an absent channel. The seam is consumed opportunistically (`ctx.get('approval')`, the `tool-bash`/`agent-loop` pattern), not statically injected: with no ApprovalService, or after one unmounts, the next ask fails closed without gating the registry's fiber. An agent-less execution also fails closed — without an agent there is no session to audit to and no UI to route to. +`ToolRegistry.execute()` resolves `ask` before dispatch: `allowed-once` proceeds, while rejection, cancellation, and channel absence produce distinct deny reasons. Opportunistic `ctx.get('approval')` consumption lets an absent or unmounted service fail closed without gating the registry fiber. Agent-less execution also fails closed because it has neither an audit session nor a UI owner. #### The per-session policy tier -The seam also owns the session-scoped approval policy — the approval knob of the two-knob per-session switching design ([the sandbox RFC](2026-07-06-sandbox.md) § Per-session modes is the pattern's home: one log-only event per knob, a pure fold, THE write path, ACP config-option advertisement, and turn-anchoring). `ApprovalPolicy` is `'ask' | 'never'`, and `effectiveApprovalPolicy(events) ?? Config.policy` (default `'ask'`) decides every request BEFORE any interactive answerer: the service resolves a `'never'` session to `'rejected'` INSIDE `request()`, before dispatching the waterfall at all — no listener registration, including a later `prepend`, can sit ahead of it — while `'ask'` dispatches unchanged and falls through to fail-closed `'unavailable'` when nobody answers. Visibility follows the switching design's two layers with one asymmetry: the prompt section states ONLY `'never'` (deterministic, availability-independent — "you will be prompted" would overclaim in a composition with no answerer, and absence under a logged header is exactly how the narrator reads `'ask'` back), the narrator injects at most one coalesced notice per switch, and the audit pair still lands on every ask, including the policy's auto-rejections. +The seam also owns the session-scoped `'ask' | 'never'` policy described by [the sandbox RFC](2026-07-06-sandbox.md). Effective policy is folded from logged switches over the deployment default. `'never'` resolves to `rejected` inside `request()` before any answerer can run; `'ask'` dispatches and otherwise falls through to `unavailable`. The prompt states only deterministic `'never'`, switch narration is coalesced, and every request still records the audit pair. #### The ACP answerer -The bridge registers the first real answerer: it resolves the owning session through `ownedRecord`, requiring the forward session-map record at `agent.session.id` to own that exact agent object, issues `session/request_permission` with the request's `callId` as the `toolCall` reference and the one-shot options `allow_once`/`reject_once`, and maps the response — selected `allow-once` → `allowed-once`, any other selection → `rejected` (an unknown optionId from a non-conforming client never grants), client `cancelled` → `cancelled`. A request for a foreign agent — or one without a `callId`, since the protocol prompt must attach to a tool call — delegates via `next()`. A rejected RPC (client gone mid-prompt) propagates to the service, which contains it as `unavailable`. Whether a call ASKS at all is policy — a hook or `tools/pre-execute` plugin returning `ask` — never the bridge's own judgment. +The ACP bridge answers only for an exact agent object owned by its forward session map. It attaches `session/request_permission` to the existing `callId`, advertises one-shot allow/reject options, maps cancellation separately, and never grants an unknown option. Foreign or call-less requests delegate; a failed client RPC becomes `unavailable`. Hooks and `tools/pre-execute` decide whether a call asks at all. The answerer routes through the bridge's exact-agent ownership check described by [the ACP support RFC](../../implemented/feature/2026-06-14-acp-agent-client-protocol.md), implementing the per-session permission ownership required by [the multi-session RFC](../../implemented/feature/2026-06-14-acp-multi-session.md). #### Audit, and what the model sees -`approval/asked` / `approval/decided` are log-only session events (the `hook/invoked`/`hook/result` precedent): durable, replayable, never in the model transcript. The model's entire view of an approval is the tool result the asker derives from the outcome — reconstructability holds because that result is an ordinary logged `tool/result`. Successful request completion commits one `decided` per `asked`, whatever the outcome, including an already-aborted signal (settled `cancelled` without dispatching), a contained answerer failure, or a session observer that throws after either event is already appended. An idle request appends neither event; a pre-commit append failure rejects, and failure of the second append can leave the already-committed `asked` without a `decided`. +`approval/asked` and `approval/decided` are durable log-only events; the model sees only the ordinary tool result derived from the outcome. Successful completion commits one `decided` per `asked`, including cancellation and contained answerer failure. Idle requests append neither event; a pre-commit failure rejects, while failure of the second append can leave an already-committed `asked` unmatched. #### Entities and dependencies -One package, no cycles: `dsh-user-approval` peers on `cordis`, `dsh-session` (event-map merge + append), `dsh-agent` (the `Agent` type), `dsh-llm` (`CallId`, via `dsh-brand`). `dsh-tools` and `dsh-acp` each peer on it; the escalation phase's asker lives in `dsh-tool-bash` (see [the sandbox RFC](2026-07-06-sandbox.md) § Escalation), so the sandbox family keeps its ZERO-edge relation (the executor contributes the per-call override mechanism, and transport seams never ask humans questions). The seam is one package, not the capability-seam three: the service body (dispatch + audit) has no replaceable implementation — the replaceable part is the answerer listeners, and those live with their owners (the bridge; future terminal UIs; test scripts). `@cordisjs/plugin-capability` stays orthogonal (a static grant registry answers "is this already authorized", not "ask the user now"), and `subagent-acp`'s child-side `permission` auto-answer is untouched — routing a child's approvals to the parent session is deferred (§ Deferred). +`dsh-user-approval` depends on Cordis plus the session, agent, and branded-call contracts; `dsh-tools` and `dsh-acp` consume it. The sandbox executor stays independent because `dsh-tool-bash` owns escalation requests. The fixed dispatch-and-audit service remains one package; replaceable answerers live with their channel owners. Static capability grants and `subagent-acp` child-side permission answers remain separate concerns. ### Testing -Unit tier: the service's outcome branches (fail-closed default, first-wins slot, delegation, containment, rogue-value normalization, abort-before and abort-during with late-answer discard, fresh ids, fiber-disposal degradation), scoped routing, post-append observer throws on both audit events, and the policy tier (both values × dispatch/decide, a `'never'` decision unbypassable even by an answerer prepended AFTER the service, audit pair intact) in `dsh-user-approval`; the ask routing matrix (grant dispatches; three non-grant reasons pinned verbatim; unmounted and agent-less degrades; the registry's own exhaustiveness backstop against a non-conforming stand-in) in `dsh-tools`; the answerer (wire shape of the prompt, outcome mapping, unknown-option conservatism, foreign-agent and call-less delegation) driven through a real bridge + scripted client in `dsh-acp`. +Unit tests pin outcomes, first-wins delegation, containment, cancellation, scoped routing, audit pairing, the unbypassable `'never'` policy, tool deny reasons, and ACP ownership/outcome mapping through a real scripted bridge. -Snapshot tier: the harness accepts scripted permission answers (`permissionAnswers` in a scenario's `input.json`, consumed FIFO; an unscripted prompt answers `cancelled`, fail closed). The seam's wire is recorded end to end in the sandbox example's suite: both escalation branches drive `session/request_permission` through this seam over scripted answers (grant and rejection), and the recorded `mode-switching` scenario pins the `'never'` prompt sentence and the policy-switch notice ([the sandbox RFC](2026-07-06-sandbox.md) § Testing). +Snapshots record allowed and rejected sandbox escalation through `session/request_permission`, plus the `'never'` prompt and policy-switch notice. Unscripted permission prompts cancel and fail closed. ## Deferred @@ -102,12 +102,10 @@ Snapshot tier: the harness accepts scripted permission answers (`permissionAnswe The implemented contract is pinned by the suites in Testing: -- With an ApprovalService and an answerer composed, a hook's `ask` reaches a human and `allowed-once` dispatches the tool; every other outcome denies with its distinct reason. -- A `'never'` session auto-rejects every ask without prompting anyone, states the policy in its prompt, and narrates switches (the shared switching mechanics are pinned in [the sandbox RFC](2026-07-06-sandbox.md)). -- Every unanswerable path fails closed to `unavailable`: no service, no listener, a foreign or agent-less request, a throwing answerer, a rogue return value, or a dead client connection. -- Every successful `request()` routes through its readonly agent identity and lands exactly one `approval/asked`/`approval/decided` pair on that agent's log, replayable and invisible to the model transcript; idle and pre-commit failures reject, while post-append observer failures cannot split the pair. -- Prompts route per-session through the bridge's ownership map; one session's prompt can never reach another session's editor. -- A deployment with no ApprovalService emits no approval prompt or approval audit events and denies every `ask` request. +- `allowed-once` dispatches one action; every other outcome denies with a distinct reason, and `'never'` rejects before prompting. +- Missing, foreign, agent-less, throwing, invalid, and disconnected answer paths fail closed. +- Successful requests route by exact agent ownership and append one replayable, model-invisible audit pair; idle and pre-commit failures reject. +- ACP ownership keeps prompts inside their session, while a deployment without the service emits no prompt or audit events. Costs and accepted limits: @@ -117,8 +115,6 @@ Costs and accepted limits: ## FAQ -Behavioral and usage questions only — every "why not X?" design question lives in [Alternatives considered](#alternatives-considered), whose job is exactly that. - - **What happens in a deployment with no answerer at all (headless, CI)?** Every ask falls through the empty waterfall to `unavailable` and the tool call denies with the "no approval channel is available" reason. Fail-closed is the zero-listener default, not a configuration. - **Can a grant persist — "always allow this"?** No. `allowed-once` authorizes the single asked-about action and the service stores nothing between requests; `allow_always` is deliberately not advertised until grant storage is designed (§ Deferred). - **What does the model see of an approval?** Only the tool result the asker derives from the outcome — the audit pair never enters the transcript. The three non-grant reasons are distinct, so the model can tell a human "no" from a dismissed prompt from a missing channel. diff --git a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md index 896e019779..9084befad5 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md +++ b/docs/rfc/implemented/simplification/2026-06-20-unify-agent-and-session-id.md @@ -28,7 +28,7 @@ The config-driven path keeps `agents[].id` as a stable configuration label, not - Agent create/resume and subagent creation carry one identity, and `Session` stores it in one place. - The creation transaction retains final-entry collision, exact-entry detach, rollback, and quiescence coverage without identity-specific lifecycle state. -- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC caches only local disposable-child parent lineage while leaving remote runs outside its local-session notification pair. +- ACP, stdio, hooks, bash ownership, persistence, and lineage use the shared `SessionId` directly. The ACP subagent backend mints its lifecycle id in the parent namespace because a child server's returned session id is only server-local; the ACP bridge verifies exact `Agent` ownership from the forward session map; and JSON-RPC forwards only lifecycle events whose service-snapshotted `local` flag is true, obtains the delegating parent from the scoped event carrier, and keeps no child identity or lineage cache. - The config-driven resume-or-create policy is explicit and covered across a durable restart. - A production listener search kept `agent/created`/`agent/disposed` and their publication semantics. - Typecheck, coverage, snapshots, doc-sync, module-graph verification, build, and hygiene pass. diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 8f3549ac7c..19779f9137 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -121,7 +121,7 @@ export interface SubagentRunInfo { readonly provider: string /** The child agent's id. */ readonly id: SessionId - /** Whether the provider exposed an exact published in-process child. */ + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ readonly local: boolean } @@ -133,7 +133,7 @@ export interface SubagentRunEndInfo { readonly provider: string /** The child agent's id. */ readonly id: SessionId - /** Whether the provider exposed an exact published in-process child. */ + /** Snapshot of whether `SubagentRun.localAgent` was present when start fulfilled. */ readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 95c5abb0d6..e20cd40f10 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -1,36 +1,7 @@ /** - * The Agent Client Protocol (ACP) bridge: a client-driver / UI plugin that - * exposes the harness agent as an ACP server over JSON-RPC stdio, so editors - * (Zed and other ACP clients) can drive it. The structured analogue of the - * readline `stdio-chat` plugin. - * - * This is NOT a loop change and NOT an ADR-0009 capability seam: it consumes - * the existing `agent/*` event taxonomy, the `dsh-agent` create/resume factory, - * and `dsh-session-persistence` (for `session/load`). It maps: - * - * - `initialize` → protocol-version negotiation, text-only capabilities - * - `session/new` → `ctx.agents.create({ sessionId, meta:{cwd} })` - * - `session/load` → `ctx.agents.resume(...)` then replay the event log - * - `session/prompt` → `agent.send()`, settle on the owning turn's end (a turn - * that ends in `error` rejects the RPC) - * - `session/cancel` → `agent.cancel()` (the queue-aware cancel: aborts a - * running step, clears queued + steering work, and drops a - * turn about to start) + settle the in-flight prompt - * - * Multi-session (RFC 011): N concurrent sessions per connection, each mapped to - * its own `ReactLoopAgent`. Sessions are keyed by their shared agent/session id; - * every `session/event` and `agent/*` event is routed strictly to its owning - * session record, and each `session/update` carries that id. Concurrent updates - * may alternate on the shared connection without crossing session attribution. - * Permission prompts use the same identity: the bridge answers - * `approval/request` for its own agents over `session/request_permission` (see - * the approval answerer below) — whether a call ASKS is policy (a hook or - * plugin returning `ask`), not the bridge's. - * - * stdout is the protocol: this plugin must run in an example that loads NO - * stdout logger (the console logger writes to stdout and would corrupt the - * JSON-RPC frames). The guarantee is config-only — see the package README and - * RFC 010 § Risks. + * Multi-session ACP bridge over JSON-RPC stdio. Creates or resumes agents, + * routes session-scoped events and approvals, and settles prompts by turn. + * Stdout is reserved for protocol frames. * * @module @deepseek-ai/dsh-acp */ @@ -104,27 +75,15 @@ import { } from './codec.ts' export const name = 'acp' -// Interface services back advertised loading, tool-owned presentation with a generic fallback, and interaction. -// TODO(acp-session-inject): remove `sessions`; the bridge never reads it, and ownership is already behind `agents`. +// Interface services back loading, presentation, interaction, and prompt assembly. export const inject = ['agents', 'sessionPersistence', 'tools', 'userInteraction', 'llm', 'systemPrompt'] -/** - * Build an ACP "invalid params" error whose human detail rides in the message. - * `RequestError.invalidParams(data, additionalMessage)` keeps the standard - * "Invalid params" message and appends `additionalMessage`, so we pass the - * detail as `additionalMessage` (and no structured `data`). - */ +/** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { return RequestError.invalidParams(undefined, detail) } -/** - * Build an ACP "internal error" whose human detail rides in the message. Used - * to reject a `session/prompt` whose turn ended in failure: a plain `Error` - * thrown from a method handler is flattened to a generic "Internal error" on - * the wire, so we wrap the detail in the SDK's `RequestError.internalError` - * (which appends `additionalMessage`) to surface *why* the turn failed. - */ +/** Preserve failed-turn detail; plain handler errors become a generic wire internal error. */ function internalError(detail: string): RequestError { return RequestError.internalError(undefined, detail) } @@ -249,13 +208,7 @@ export interface AcpConfig { provider?: string /** Model name for created agents (must have a registered adapter). */ model?: string - /** - * Transport stream override. Production omits this (the plugin wires - * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an - * in-memory `Stream` (e.g. an `ndJsonStream` over a `Duplex` pair) to drive - * the bridge without a subprocess. Not part of the schemastery `Config` — - * it is a runtime-only seam, never set from a `cordis.yml`. - */ + /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -292,30 +245,11 @@ interface ModelCatalogEntry { /** Per-session bridge state keyed by ACP session id. */ interface SessionRecord { agent: Agent - /** - * The owned-agent disposer (from the {@link AgentHandle} the factory returned). - * Teardown calls it to unregister this ONE agent, stop its loop, await - * quiescence, and remove its session — instead of leaving it for the bridge - * fiber to reclaim. - */ + /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ dispose: () => Promise - /** - * Resolves tool-owned presentation for THIS session's tool calls and remembers - * each in-flight call's `(name, args)` so the matching `tool/result` can find - * its tool. Per-session so two concurrent sessions never cross their in-flight - * tool state. - */ + /** Per-session tool presentation and call/result correlation. */ presenter: ToolPresenter - /** - * Whether THIS session renders shell tools as terminal cards — snapshotted - * from the client's `_meta.terminal_output` capability at session creation - * (`session/new`/`session/load`), NOT re-read live. A capability snapshot per - * session means the `tool_call` (which registers the terminal) and the matching - * `tool_call_update` (which streams its output) ALWAYS agree, even if a later - * `initialize` mutates the connection-level capability between them — otherwise - * a re-`initialize` mid-call could orphan a `terminal_output` (call non-terminal, - * result terminal) or clobber the card (call terminal, result non-terminal). - */ + /** Terminal capability snapshot shared by matching call and result updates. */ terminalEnabled: boolean /** Session-local provider/model selection and the current step snapshot. */ target: LlmTargetRef @@ -325,18 +259,7 @@ interface SessionRecord { reject: (error: Error) => void turn: number | undefined } | undefined - /** - * Config switches accepted while the session was IDLE, not yet anchored in - * its log. The turn-enclosure contract makes a bare between-turns append - * invalid (the JSONL backend treats a post-`turn/end` tail as crash - * garbage, and dev invariants throw), so an idle switch waits here and is - * anchored at the next turn's prompt-submit — before anything in that - * turn assembles a prompt or runs a call, and last write - * per knob wins (an idle flip-flop anchors as one event). Until anchored, - * the switch lives only in bridge memory: the set/new/load responses - * overlay it truthfully, and a restart before the next turn reverts it — - * which `session/load` then reports honestly from the log's fold. - */ + /** Last idle switch per knob, anchored before the next prompt assembles. */ pendingSwitches: { preset?: string } } @@ -347,21 +270,15 @@ interface SessionRecord { * correlation in a `finally` so presentation failure cannot starve settlement. */ export function apply(ctx: Context, config: AcpConfig): void { - // Capture the injected services NOW, during apply(), while we are inside this - // plugin's fiber (where `inject` grants access). The ACP method handlers run - // LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is - // NOT this fiber's injection scope — so reading `ctx.agents` / `ctx.logger` / - // `ctx.sessionPersistence` lazily inside a handler throws "cannot get property - // … without inject". Resolving the references here and closing over them keeps - // the handlers working regardless of which fiber later invokes them. + // ACP handlers execute outside this plugin's injection scope, so capture + // injected services during apply(); lazy service reads in a handler fail. const agents = ctx.agents const llm = ctx.llm const sessionPersistence = ctx.sessionPersistence const logger = ctx.logger const tools = ctx.tools const userInteraction = ctx.userInteraction - // A new ToolPresenter per session (and a throwaway per load replay), each given - // this warn sink so a throwing tool presenter is logged, not propagated. + // Presenter callbacks are contained so display failures cannot break protocol handling. const makePresenter = (agent?: Agent): ToolPresenter => new ToolPresenter(tools, (message) => { logger.warn(message) }, agent) /** Resolve a complete target only; partial config remains available to other request listeners. */ @@ -459,23 +376,12 @@ export function apply(ctx: Context, config: AcpConfig): void { } } - // TODO(derive-acp-session-id): derive event ids from `agent.session`, verify ownership, then remove the reverse map. - // Agent events currently carry only the Agent, so retain `SessionRecord.sessionId` and update both indexes together. - // Dropping the forward record lets the weak reverse entry expire. const sessions = new Map() - // Session ids whose `session/load` is mid-`resume()` (the slot is reserved - // before the async resume so a pipelined load/new for the SAME id can't create - // two agents). Distinct ids load concurrently; a given id loads once at a time. + // Reserve an id before resume so pipelined load/new requests cannot duplicate it. const loadingIds = new Set() - // Set once the bridge has torn down (disposal or client disconnect). An async - // `session/load` mid-`resume()` when teardown ran must observe this after its - // await and NOT install a record (which would resurrect a live agent/listeners - // after the bridge closed). Checked after every load await. + // Async creation checks this after awaits to avoid publishing after teardown. let closed = false - // Connection-level terminal capability from the latest `initialize`; false - // before initialization. Each `session/new` or `session/load` snapshots it in - // `SessionRecord.terminalEnabled`, so later initialization affects only future - // sessions. + // Each new or loaded session snapshots the latest connection capability. let terminalOutputCap = false // Assigned at the bottom, before any agent event can fire (a session only @@ -674,22 +580,13 @@ export function apply(ctx: Context, config: AcpConfig): void { currentValue, options: [ ...presets.names.map((name: string) => presets.optionOf(name)), - // The derived not-a-preset state: visible exactly while it IS the - // current value (a knob state outside the table), switchable FROM, - // never a target — set() below rejects it like any unknown name. + // `custom` echoes the current derived state but is never a target. ...currentValue === 'custom' ? [presets.optionOf('custom')] : [], ], }] } - /** - * Whether the session's log currently has an open turn — the last boundary - * event is a `turn/start`. Decides whether a config switch may append NOW - * (enclosed) or must wait for the next turn (see - * {@link SessionRecord.pendingSwitches}). Read from the LOG, not - * `agent.status`: status stays `running` across the gap between two queued - * turns, where a bare append would still land outside any turn. - */ + /** Whether the log has an open turn in which a config switch can be enclosed. */ const isTurnOpen = (agent: Agent): boolean => { const events = agent.session.events for (let index = events.length - 1; index >= 0; index -= 1) { @@ -700,12 +597,7 @@ export function apply(ctx: Context, config: AcpConfig): void { return false } - /** - * Anchor a record's pending switches into its (just-opened) turn, last - * write per knob — skipping a value the session already effectively has, - * so a net-zero idle flip-flop anchors NOTHING (the log records switches, - * not select clicks). - */ + /** Anchor last-write-wins idle switches into a just-opened turn. */ const flushPendingSwitches = (rec: SessionRecord): void => { const pending = rec.pendingSwitches rec.pendingSwitches = {} @@ -717,15 +609,8 @@ export function apply(ctx: Context, config: AcpConfig): void { presets.set(rec.agent.session, pending.preset) } - // Idle-accepted switches anchor at the next turn's prompt-submit: the turn - // is open (the seam fires inside it, per drained message — the first flush - // empties the slot, later ones no-op), the loop has not yet assembled - // anything for it, and — unlike appending from inside a `session/event` - // listener — this seam fires OUTSIDE any log emit, so peer listeners - // (the dev invariants, persistence) observe the anchored events in strict - // log order. A turn with no prompt (an idle inject's one-shot injection - // turn) leaves the switch pending — it runs no step, so nothing executes - // or assembles under a stale value. + // Prompt-submit is inside the new turn but before prompt assembly. Promptless + // injection turns leave the switch pending because they execute no request. ctx.on('agent/prompt-submit', (agent, _content, _source, next) => { const rec = ownedRecord(agent) if (rec !== undefined) flushPendingSwitches(rec) @@ -780,10 +665,8 @@ export function apply(ctx: Context, config: AcpConfig): void { agentOptions: agentOptions(config), setup: (agentCtx) => { installTarget(agentCtx, target) }, }) - // Creation is now asynchronous because it awaits the unpublished setup - // transaction. A client disconnect can therefore close this bridge - // after the entry check but before the handle resolves; never install a - // post-close record that quiesce() could not have seen. + // Agent creation may resolve after the bridge closes; dispose the handle + // instead of publishing a record that teardown could not observe. /* v8 ignore next 4 -- the in-memory transport rejects the in-flight RPC immediately on close; real stdio may let the handler resume */ if (closed) { @@ -991,10 +874,7 @@ export function apply(ctx: Context, config: AcpConfig): void { if (presets === undefined) { throw invalidParams(`unknown permission value ${JSON.stringify(params.value)}`) } - // A no-op switch (the value the session already shows — pending, - // else derived) is acknowledged FIRST and records nothing: - // clients re-push current selections on session start, and the - // derived 'custom' current is only ever valid as such an echo. + // A current-value echo is acknowledged without recording a switch. const current = rec.pendingSwitches.preset ?? presets.current(rec.agent.session.events) if (params.value === current) break if (!presets.names.includes(params.value)) { @@ -1236,27 +1116,16 @@ export function streamSessionEventUpdate( } /** - * Map a harness todo list to an ACP `plan` body. ACP's `PlanEntry` requires - * `content` + `priority` + `status`, but a {@link TodoItem} carries no priority, - * so synthesize a constant `'medium'` on every entry; `status` maps 1:1 (the - * harness status triple IS `PlanEntryStatus`). The ACP client REPLACES its whole - * plan on each `plan` update, matching the harness's whole-list-replace - * semantics, so no per-entry diffing is needed. - * @param todos - the harness todo list (the whole list, not a diff). - * @returns the ACP plan body, one entry per todo. + * Map a whole harness todo list to an ACP replacement plan, using medium + * priority because harness todos do not carry one. + * @param todos - complete harness todo list. + * @returns one ACP plan entry per todo. */ export function todosToPlan(todos: TodoItem[]): Plan { return { entries: todos.map((todo): PlanEntry => ({ content: todo.content, priority: 'medium', status: todo.status })) } } -/** - * Per-session terminal-rendering context threaded into - * {@link streamSessionEventUpdate}: whether the client advertised the - * `_meta.terminal_output` capability, and the session's workspace cwd (the - * default terminal-card header when a tool doesn't supply its own). Kept out of - * the pure translator's required params so the no-capability / no-presenter - * tests stay terse. - */ +/** Per-session terminal capability and workspace used while translating updates. */ export interface TerminalRendering { enabled: boolean /** The session workspace cwd (terminal-card header default); `undefined` when the session has none. */ @@ -1267,47 +1136,21 @@ export interface TerminalRendering { const noTerminalRendering: TerminalRendering = { enabled: false, cwd: undefined } /** - * Resolves tool-owned presentation for a session's tool-call events. A tool - * declares `presentCall`/`presentResult` (see `dsh-tools`) returning a - * `card`-tagged {@link ToolCallView}/{@link ToolResultView}; this looks them up - * by name in the registry and applies a generic fallback when a tool defines - * neither. The returned view is what {@link streamSessionEventUpdate} switches on. - * - * The `tool/result` session event does NOT carry the tool name or args — so to - * call a tool's `presentResult` (which needs both), the presenter remembers each - * `tool/call`'s `{ name, args, card }` keyed by callId and looks it up on the - * matching result. The map is bridge-LOCAL (not a change to the event schema or a - * core service): one presenter per live session - * (and a throwaway per `session/load` replay), and each entry is removed when its - * result arrives. In the normal loop a `tool/call` is always followed by a - * `tool/result` (the registry turns even a thrown tool into an isError result), - * so the map holds only currently-in-flight calls. The one exception is a step - * torn down mid-tool (an abort between `tool/call` and `tool/result`), which can - * leave a single stale entry per such call; this is bounded by the session - * lifetime (the whole presenter is dropped on teardown) and never affects - * correctness — a later result for a different callId is unaffected, and the - * stale entry's only cost is one map slot until the session ends. + * Resolve tool-owned call/result views with a generic fallback. Per-session + * state correlates results with call arguments; interrupted calls may retain an + * entry only until that session's presenter is discarded. */ export class ToolPresenter { private readonly pending = new Map() /** - * @param tools the registry to resolve tool definitions by name. - * @param onError invoked when a tool's `presentCall`/`presentResult` THROWS; - * the presenter swallows the error and falls back to the generic - * presentation so a buggy display callback can never fail a live turn or a - * `session/load` replay (docs/defensive-patterns.md "contain callback exceptions at the - * boundary"). Defaults to a no-op for callers that don't supply a logger. + * @param tools - registry used to resolve executing definitions. + * @param onError - contained presenter-error sink before generic fallback. + * @param agent - optional scoped registry view for the executing agent. */ constructor( private readonly tools: Pick, private readonly onError: (message: string) => void = () => {}, - /** - * The agent whose view resolves tool presentations: a scoped/shadowed - * tool presents with ITS OWN presentCall/presentResult — the same - * definition that executed — not a same-named global's. Absent (a replay - * with no live agent) the global view presents. - */ private readonly agent?: Agent, ) {} @@ -1316,10 +1159,8 @@ export class ToolPresenter { * for the matching result. * @param callId - the call id the matching `tool/result` will look up. * @param name - the tool name, resolved against the registry for `presentCall`. - * @param argsJson - the raw arguments JSON from the event; parsed for the view - * (a non-JSON string is surfaced raw). - * @returns the tool-owned view, or the generic fallback (title = tool name, - * kind `other`, parsed args as raw input) when the tool defines none or threw. + * @param argsJson - raw event arguments parsed for presentation. + * @returns the tool-owned view or generic fallback. */ call(callId: CallId, name: string, argsJson: string): ToolCallView { const args = parseToolArguments(argsJson) @@ -1331,10 +1172,7 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - // No tool-owned presentation: fall back to the tool name as the title, the - // full parsed args as the raw input, and kind `other` (the generic card). - // The kind is never sniffed from the name — the bridge does not special-case - // tool names; a tool that wants a richer kind declares `presentCall`. + // Tool names never imply presentation kind; richer cards are tool-owned. const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args } this.pending.set(callId, { name, args, card: view.card }) return view @@ -1343,14 +1181,11 @@ export class ToolPresenter { /** * Completed-state render intent for a `tool/result`; consumes the remembered * `(name, args, card)`. - * @param callId - the id of the matching `tool/call`; an unknown or late id - * falls back to the raw content. - * @param content - the result's content blocks (the fallback and fill-in body). + * @param callId - matching call id; unknown or late ids use raw content. + * @param content - result content used by the fallback and fill-in body. * @param isError - whether the result is an error, forwarded to `presentResult`. * @param meta - the result's machine-readable meta, forwarded when present. - * @returns the tool-owned view — an orphaned `terminal` result (no terminal - * call side) and a content-less `generic` are normalized — or the raw-content - * generic card when the tool defines no `presentResult` or threw. + * @returns a normalized tool-owned view or raw-content fallback. */ result(callId: CallId, content: ContentBlock[], isError: boolean, meta?: unknown): ToolResultView { const call = this.pending.get(callId) @@ -1420,25 +1255,11 @@ type AcpToolCallContent = | { type: 'diff'; path: string; oldText: string | null; newText: string } | { type: 'terminal'; terminalId: string } -/** - * Relativize a file card's TITLE path against the session workspace cwd, so a - * card reads `Read src/foo.ts` rather than `/abs/proj/src/foo.ts` — matching the - * reference ACP adapter's `toDisplayPath`. Only the TITLE is relativized; the - * card's `locations`/`diff` paths stay RAW (the editor opens the real path). The - * pure tool presenter can't see the session cwd, so this happens here where the - * bridge knows it. The rewrite is an exact substring replace of the known raw - * path (a card carries the same path in `locations[0]`/`diffs[0]`), never a - * heuristic. A path outside the workspace, or an absent/relative session cwd, is - * left unchanged. - */ +/** Relativize only in-workspace title text; location and diff paths stay raw. */ function displayTitle(title: string, rawPath: string | undefined, sessionCwd: string | undefined): string { if (rawPath === undefined || sessionCwd === undefined || !isAbsolute(rawPath) || !isAbsolute(sessionCwd)) return title const rel = relativePath(sessionCwd, rawPath) - // Only relativize a target that stays INSIDE the workspace. `relative` prefixes - // a `..` SEGMENT for a target above the cwd — test for the segment (`..` alone - // or `..…`), NOT a bare `..` char prefix, so a sibling like `..cache/x` - // (a real in-workspace name) still relativizes. Never relativize to the empty - // string (rawPath === cwd — a non-file target). + // Test the `..` segment, not a character prefix: `..cache/x` is in-workspace. if (rel.length === 0 || rel === '..' || rel.startsWith(`..${pathSep}`)) return title return title.split(rawPath).join(rel) } diff --git a/packages/ui/jsonrpc/README.md b/packages/ui/jsonrpc/README.md index 3d19b601c4..6666867598 100644 --- a/packages/ui/jsonrpc/README.md +++ b/packages/ui/jsonrpc/README.md @@ -1,26 +1,26 @@ # @deepseek-ai/dsh-jsonrpc -The **SDK server plugin** (`jsonrpc`): mounting it serves a stdio JSON-RPC server that lets an out-of-process SDK client (e.g. the Python `deepseek_harness` package) drive DeepSeek Harness agents without touching Cordis. The client speaks newline-delimited JSON-RPC on the process stdin/stdout ([`HarnessSdkServer`](src/server.ts): `initialize` → `session/prompt` → `shutdown`, with `session.event` / `session.finished` / `subagent.*` notifications over [`JsonRpcLineTransport`](src/transport.ts)). The SDK-client analogue of the [`acp`](../acp/README.md) bridge, split the same way: this package is the protocol plugin, while [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) is the app bin that boots a `cordis.yml` around it — which process serves this protocol is a config decision, not a hardcoded bin. This plugin is the serving face of the [single-executable distribution plan](../../../docs/rfc/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md). +The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-process SDK clients can drive harness agents. [`HarnessSdkServer`](src/server.ts) owns the protocol methods and notifications; [`jsonrpc-demo`](../../examples/jsonrpc-demo/README.md) supplies the surrounding `cordis.yml` application. ## Wiring -`inject: ['agents']`. The server gets or creates one agent per `sessionId` from the `initialize.provider`/`initialize.model` pair. It forwards subagent completions only when the lifecycle payload's `local` flag was snapshotted from the provider's exact in-process child; reusable provider names, child ids, and durable lineage never establish locality. A registered owner for the provider route wins; an unowned `deepseek` route mounts `dsh-llm-deepseek` using `$DEEPSEEK_API_KEY` and `$DEEPSEEK_BASE_URL`, while any other unowned provider fails initialization. Persistence, tools, and other adapters come from the surrounding `cordis.yml`. +`inject: ['agents']`. The server gets or creates one agent per `sessionId`. It forwards subagent completions only when the service-snapshotted lifecycle `local` flag is true; provider names, child ids, and durable lineage never establish locality. A registered adapter wins, an unowned `deepseek` route mounts `dsh-llm-deepseek`, and any other unowned provider fails initialization. Other capabilities come from the surrounding `cordis.yml`. ## Config -No `cordis.yml`-settable keys. The `JsonRpcConfig` fields (`input`, `output`, `exit`) are runtime-only test seams so a spec can drive the server over in-memory streams without a subprocess or a killed test process; production always serves the process stdio and exits via `process.exit`. +There are no `cordis.yml` keys. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport seams; production uses process stdio and `process.exit`. ## stdout is the protocol -The process stdout this plugin runs in carries only JSON-RPC frames. The tree that loads it must load NO stdout logger (a console logger corrupts the frames) — the guarantee is config-only, same as the ACP bridge. Diagnostics go to stderr. +Stdout carries only JSON-RPC frames. The deployment must not compose a stdout logger; diagnostics belong on stderr. ## Shutdown and exit semantics -The plugin owns the PROTOCOL-level exit: a `shutdown` request is answered first (the response frame flushes), then the plugin disposes its own fiber — running the effect disposer: an idempotent `server.shutdown()` (every SDK-created agent disposed to quiescence, event subscriptions detached) plus `transport.close()` — and exits the process with code 0. Own-fiber disposal is deliberate: the request's `server.shutdown()` already flushed all SDK-owned session state, and the process exit that follows is the teardown of the rest of the tree. Process-level exits (stdin EOF → 0, SIGTERM → 0, SIGINT → 130) belong to the app bin, which disposes the whole root context. Fiber disposal WITHOUT a `shutdown` request (HMR-style unload) just stops serving — it never exits the process. +The plugin answers `shutdown`, disposes SDK-owned agents and subscriptions to quiescence, closes the transport, then exits with code 0. EOF and signal exits belong to the app bin, which disposes the root context. Unloading only this plugin stops serving without exiting the process. ## Wire notes -`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime` (SDK clients key on it, independent of this package's name). A session accepts at most one in-flight `session/prompt`; an overlapping prompt for the same `sessionId` fails immediately through the standard handler-error response, while other sessions remain independent and the same session can be reused after the active prompt settles. Persistence roots and the deployment persona come from `cordis.yml`; the wire exposes only parameters the server applies. +`initialize.serverInfo.name` is the wire-stable `deepseek-harness-sdk-runtime`. A session accepts one in-flight prompt; overlap fails immediately, other sessions remain independent, and the session is reusable after settlement. Persistence roots and persona come from `cordis.yml`. ## Model Experience diff --git a/packages/ui/jsonrpc/src/server.ts b/packages/ui/jsonrpc/src/server.ts index 5246795a31..8949ccb06a 100644 --- a/packages/ui/jsonrpc/src/server.ts +++ b/packages/ui/jsonrpc/src/server.ts @@ -1,13 +1,6 @@ /** - * `HarnessSdkServer`: the JSON-RPC method surface the `dsh-jsonrpc` plugin - * serves to out-of-process SDK clients (e.g. the Python `deepseek_harness` - * package). Requests: `initialize` → `session/prompt`* → `shutdown`. - * Notifications pushed to the host: `session.event` (every durable session - * event, verbatim), `session.finished` (per prompt turn settle), - * `subagent.started` / `subagent.finished` (child-session lineage and run - * outcomes). The server owns only the SDK-facing session map — the harness - * itself is the context the plugin mounts in; plugins, persistence, and - * the LLM adapter set all come from the external `cordis.yml`. + * JSON-RPC method and notification surface for out-of-process harness SDKs. + * The surrounding context owns plugins, persistence, and configured adapters. * * @module @deepseek-ai/dsh-jsonrpc/server */ @@ -23,7 +16,7 @@ import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { JsonRpcTransportPeer } from './transport.ts' -/** Parameters of the `initialize` request (once per process, before any prompt). */ +/** Parameters for the process-wide SDK handshake. */ export interface InitializeParams { /** Working directory recorded on every SDK-created session's header. */ cwd: string @@ -33,16 +26,13 @@ export interface InitializeParams { model: string } -/** Result of the `initialize` request: the server's identity for the SDK handshake. */ +/** Wire-stable server identity returned by initialization. */ export interface InitializeResult { /** Wire-stable server identity (`deepseek-harness-sdk-runtime`) and version. */ serverInfo: { name: string; version: string } } -/** - * Parameters of a `session/prompt` request: one user turn on one SDK session, - * with at most one in flight per session. - */ +/** One user turn on one SDK session. */ export interface SessionPromptParams { /** The SDK-side session id; an unknown id lazily creates the agent+session pair. */ sessionId: string @@ -50,7 +40,7 @@ export interface SessionPromptParams { contentBlocks: ContentBlock[] } -/** Result of a `session/prompt` request: the prompt ran to turn settle (outcome rides on `session.finished`). */ +/** Prompt acceptance after turn settlement; outcome rides on `session.finished`. */ export interface SessionPromptResult { /** Always `true`; the turn outcome is the paired `session.finished` notification. */ accepted: true @@ -62,20 +52,12 @@ interface SessionRecord { activePrompt: boolean } -/** Recover the delegating parent carried by every service-owned subagent lifecycle event. */ +/** Recover the delegating parent from the service-owned scoped carrier. */ function subagentParentOf(carrier: Scoped): Agent { - // SubagentService emits this lifecycle pair only through scopeTarget(this, parent). return carrierKeyOf(carrier) as Agent } -/** - * The SDK server over a booted harness context. Constructing it subscribes to - * session and subagent lifecycle events, forwarding durable session - * events and SDK-facing completion notifications while retaining local-run - * identity across child disposal. The subscriptions live until - * {@link shutdown}. One instance serves one transport peer for the process - * lifetime — there is no re-`initialize`. - */ +/** SDK server whose subscriptions and created agents live until {@link shutdown}. */ export class HarnessSdkServer { private cwd = process.cwd() private provider = 'deepseek' @@ -125,10 +107,9 @@ export class HarnessSdkServer { } /** - * Record cwd and provider/model, mounting the DeepSeek adapter only when the - * `deepseek` provider route has no configured owner. - * @param params - the SDK handshake parameters. - * @returns the server identity for the handshake. + * Configure the SDK route, mounting the DeepSeek fallback only when unowned. + * @param params - SDK handshake parameters. + * @returns server identity for the handshake. */ async initialize(params: InitializeParams): Promise { this.cwd = resolve(params.cwd) @@ -142,13 +123,9 @@ export class HarnessSdkServer { } /** - * Handle `session/prompt`: get-or-create the session's agent, send the - * content as the user message, await turn settle (quiescence), then notify - * `session.finished` with the settled turn's outcome. A session accepts at - * most one prompt at a time; an overlapping request fails immediately while - * other sessions remain independent. - * @param params - the target session id and prompt content. - * @returns `{ accepted: true }` after the turn settled. + * Run one prompt to settlement; overlap on the same session fails. + * @param params - target session and user content. + * @returns acceptance after the turn settled. */ async prompt(params: SessionPromptParams): Promise { const rec = await this.getOrCreateSession(params.sessionId) @@ -171,11 +148,9 @@ export class HarnessSdkServer { } /** - * Handle `shutdown`: dispose every SDK-created agent handle (awaiting loop - * quiescence), unmount the adapter fiber this server mounted (if any), and - * detach the event subscriptions. The CONTEXT stays up — the bin disposes it - * as part of process exit. - * @returns an empty object (the JSON-RPC result). + * Dispose server-owned agents, adapter, and subscriptions to quiescence. + * The surrounding context remains running. + * @returns empty JSON-RPC result. */ shutdown(): Promise> { this.shutdownTask ??= this.performShutdown() From 1e4de0deaa5d9fbe0b725c90b098001a3fd8ecf9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:02:42 +0800 Subject: [PATCH 147/163] fix(sdk): pin generated project build tool --- packages/sdk/helper/src/project/npm-dependency-policy.ts | 2 +- packages/sdk/helper/tests/documents.spec.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/sdk/helper/src/project/npm-dependency-policy.ts b/packages/sdk/helper/src/project/npm-dependency-policy.ts index a8877826e2..727bd6648a 100644 --- a/packages/sdk/helper/src/project/npm-dependency-policy.ts +++ b/packages/sdk/helper/src/project/npm-dependency-policy.ts @@ -23,7 +23,7 @@ const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly> = { '@cordisjs/plugin-timer': '^1.1.2', '@types/node': '^22.20.0', cordis: '^4.0.0-rc.7', - tsdown: '^0.22.2', + tsdown: '0.22.2', tsx: '^4.22.4', typescript: '^6.0.3', } diff --git a/packages/sdk/helper/tests/documents.spec.ts b/packages/sdk/helper/tests/documents.spec.ts index 1ba8b9bd68..1b8050b90f 100644 --- a/packages/sdk/helper/tests/documents.spec.ts +++ b/packages/sdk/helper/tests/documents.spec.ts @@ -282,6 +282,7 @@ describe('package manager strategies', () => { section: 'devDependencies', spec: '^4.0.0-rc.7', }) expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') + expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2') expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3') expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project') }) From 4851d9570496a8ed5573873c85d18c054a78fad3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:49:18 +0800 Subject: [PATCH 148/163] docs: propose follow-up simplification RFCs --- docs/rfc/INDEX.md | 4 ++ ...026-07-04-prune-dead-core-spine-surface.md | 1 + ...19-fold-compaction-package-split.i18n.yaml | 6 +++ ...026-07-19-fold-compaction-package-split.md | 37 ++++++++++++++++++ ...-07-19-fold-compaction-package-split.zh.md | 37 ++++++++++++++++++ ...6-07-19-make-jsonrpc-directional.i18n.yaml | 6 +++ .../2026-07-19-make-jsonrpc-directional.md | 38 +++++++++++++++++++ .../2026-07-19-make-jsonrpc-directional.zh.md | 38 +++++++++++++++++++ ...-19-retire-subagent-mock-package.i18n.yaml | 6 +++ ...2026-07-19-retire-subagent-mock-package.md | 35 +++++++++++++++++ ...6-07-19-retire-subagent-mock-package.zh.md | 35 +++++++++++++++++ ...-use-one-session-surface-manager.i18n.yaml | 6 +++ ...6-07-19-use-one-session-surface-manager.md | 35 +++++++++++++++++ ...7-19-use-one-session-surface-manager.zh.md | 35 +++++++++++++++++ 14 files changed, 319 insertions(+) create mode 100644 docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml create mode 100644 docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md create mode 100644 docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index fba361469c..99ea97d16a 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -21,6 +21,10 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | +| [Fold the single compaction backend into its service package](proposed/simplification/2026-07-19-fold-compaction-package-split.md) | 2026-07-19 | +| [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 | +| [Retire the standalone subagent mock package](proposed/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | +| [Use one surface manager per session](proposed/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | ### Architecture diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md index cca7c57b34..f874784a6d 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md @@ -25,6 +25,7 @@ The production corpus is `packages/*/*/src`, example sources/config, and runtime | `CompactionResult.startSeq`, `summarySeq`, `endSeq`, and `summary` | The production consumer reads only shadowed range/seq/token accounting; the durable log owns summary and event identity. | Remove the four result echoes while keeping both shared transcript renderers. | | `BasicCompactService` estimation/summarization visibility | No outside production caller invokes the five methods; the implemented RFC names only `estimateContentTokens()` and `summarize()` as subclass hooks. | Make those two `protected` and the three orchestration-only estimators private. | | `CodeLogEntry.source`/`level` and `RunCodeMeta.dispatches` | Every production consumer maps logs to text; no presenter/model path reads the other fields or the persisted dispatch count. | Make code-runtime logs strings (or text-only entries) and remove result-meta dispatch plumbing; keep the local counter that mints deterministic dispatch ids. | +| `CodeRuntime.language` and `CodeRuntime.isolation` | The worker backend supplies the only production values, while Code Mode and every other production caller invoke only `run()`. | Remove the unread descriptors while preserving the worker's language, isolation, budgets, cancellation, and disposal behavior. | | `ToolNotFoundError.toolName`, `SystemPrompt.config`, and `BashTask.command` | Each stored public value has no production reader. | Drop the unread field while retaining error messages, resolved configuration behavior, and task lifecycle. | | Backend package-root implementation helpers | The exact inventory below is called only through relative same-package imports. Production namespace imports mount the retained plugin contract without reading these properties; named root consumers are tests. | Retain each adapter/provider/service and its config/error contract; stop exporting the listed helper functions/constants at package roots. | | Consumer package-root implementation helpers | The exact inventory below has only same-package production callers. Production namespace imports mount plugin contracts without reading helper properties; named root consumers are tests. | Retain plugin contracts and stable error codes; move tests to package-local modules or public behavior and stop exporting the listed helpers at package roots. | diff --git a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml new file mode 100644 index 0000000000..2aabfdf8cc --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-fold-compaction-package-split.md: 37be6de84356549d27a8fc32b6589130eb1f27b8 +2026-07-19-fold-compaction-package-split.zh.md: f6609add00826ba472f8c82e0a67613ca201fd30 diff --git a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md new file mode 100644 index 0000000000..37be6de843 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md @@ -0,0 +1,37 @@ +# RFC: Fold the single compaction backend into its service package + +Status: proposed + +English | [中文](2026-07-19-fold-compaction-package-split.zh.md) + +## Problem + +Compaction is split between `@deepseek-ai/dsh-compact`, which owns an abstract two-method service and shared types, and `@deepseek-ai/dsh-compact-basic`, which owns the only complete implementation. Shipped configurations load only the basic package, and no production package independently consumes the interface package except that implementation. + +The split adds a package manifest, README, project boundary, dependency edge, abstract forwarding class, generated catalog entries, and composition wiring without demonstrating backend substitution. The [capability-seam decision](../../implemented/architecture/2026-06-13-capability-seams.md) requires a real interface, implementation, and consumer rather than a preemptive split; the [compaction decision](../../implemented/feature/2026-06-18-compaction-capability-seam.md) records that its independent consumer was deferred. + +## Proposal + +Move the basic implementation into `@deepseek-ai/dsh-compact` and remove `@deepseek-ai/dsh-compact-basic`. Keep `ctx.compact`, `CompactionResult`, the shared transcript and tool-pairing helpers, the existing configuration, and the concrete compaction algorithm in one package. + +Preserve `summarize()` as a protected customization hook. A deployment-specific summarizer can subclass or intercept the existing LLM call without requiring a second capability package. Reintroduce an interface package only when a second complete backend and an independent consumer need substitution. + +Amend the implemented compaction decision and the [recallable-compaction proposal](../feature/2026-07-06-recallable-compaction.md) if this proposal is accepted so package ownership has one durable description. + +## Alternatives considered + +**Keep the split because a remote or recall backend may arrive.** A possible future implementation does not justify the current package boundary. Recall adds a consumer of compaction results, not necessarily another implementation, and a remote summarizer can use the protected hook. + +**Move the implementation package name onto the interface package.** Keeping `compact-basic` as the surviving name would make the product service appear to be one optional backend. `compact` is the stable service identity already used by `ctx.compact` and is the clearer single-package owner. + +## Acceptance criteria + +- `@deepseek-ai/dsh-compact-basic` and its workspace/package metadata are removed. +- `@deepseek-ai/dsh-compact` owns the current configuration, plugin class, algorithm, types, events, and shared helpers. +- Existing deployments can load the surviving package with equivalent configuration and model-visible behavior. +- Automatic and manual compaction preserve cancellation, locking, token accounting, tool pairing, durable events, provenance, retry convergence, and transcript rendering. +- Loader composition, unit, runaway-turn, cancellation, snapshot, and real-model compaction tests pass; generated catalogs and module graphs are current. + +## Risks + +This is an intentional pre-release package-name contraction. Embedders loading `@deepseek-ai/dsh-compact-basic` must switch packages, and future backend substitution would require extracting a boundary again. The cost is acceptable only while one complete implementation exists; acceptance should be revisited if a second backend lands first. diff --git a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md new file mode 100644 index 0000000000..f6609add00 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md @@ -0,0 +1,37 @@ +# RFC: 将唯一的压缩后端并入服务包 + +Status: proposed + +[English](2026-07-19-fold-compaction-package-split.md) | 中文 + +## 问题 + +压缩(compaction)目前拆分在两个包中:`@deepseek-ai/dsh-compact` 拥有一个含两个方法的抽象服务和共享类型,`@deepseek-ai/dsh-compact-basic` 拥有唯一的完整实现。交付配置只加载 basic 包,除了该实现外,没有生产包独立消费接口包。 + +该拆分增加了一份包(package)manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有体现后端替换需求。[能力服务边界决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 + +## 提案 + +把 basic 实现移入 `@deepseek-ai/dsh-compact`,并删除 `@deepseek-ai/dsh-compact-basic`。`ctx.compact`、`CompactionResult`、共享 transcript(文本记录)和工具配对辅助方法、现有配置以及具体压缩算法都由一个包负责。 + +保留 `summarize()` 作为受保护的自定义钩子。部署专用的摘要器可以通过继承或拦截现有 LLM(大语言模型)调用完成定制,无需第二个能力包。只有在第二个完整后端与独立消费方确实需要替换实现时,才重新提取接口包。 + +如果本提案获准,应同步修订已实现的压缩决策与[可回忆压缩提案](../feature/2026-07-06-recallable-compaction.md),使包所有权只有一处持久说明。 + +## 备选方案 + +**为可能出现的远程或回忆后端保留拆分。** 一种可能的未来实现不足以支撑当前包边界。回忆功能会增加压缩结果的消费方,但不一定增加另一种实现;远程摘要器也可以使用受保护钩子。 + +**让接口包并入实现包名。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 + +## 验收标准 + +- 删除 `@deepseek-ai/dsh-compact-basic` 及其工作区和包元数据。 +- `@deepseek-ai/dsh-compact` 拥有当前配置、插件类、算法、类型、事件和共享辅助方法。 +- 现有部署可以使用等效配置加载保留的包,模型可见行为不变。 +- 自动压缩和手动压缩保留取消、锁、token 用量、工具配对、持久事件、来源、重试收敛和 transcript 渲染行为。 +- Loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 + +## 风险 + +这是一项有意实施的预发布包名收缩。加载 `@deepseek-ai/dsh-compact-basic` 的嵌入方必须切换包,未来的后端替换也需要重新提取边界。只有在仍然只有一个完整实现时,这项代价才可接受;如果第二个后端先行落地,应重新评估是否接纳本提案。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml new file mode 100644 index 0000000000..b515d611f5 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-make-jsonrpc-directional.md: 4531cb251c4d12c9f2d700c9297bdee87e284779 +2026-07-19-make-jsonrpc-directional.zh.md: fb9bd06a9817357112b1eba2f41130ce2368d0b9 diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md new file mode 100644 index 0000000000..4531cb251c --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -0,0 +1,38 @@ +# RFC: Make JSON-RPC completion and transport directional + +Status: proposed + +English | [中文](2026-07-19-make-jsonrpc-directional.zh.md) + +## Problem + +The JSON-RPC bridge models both endpoints as symmetric peers although the shipped protocol is directional. The TypeScript server accepts requests and emits responses or notifications, but its transport also implements unused outbound requests and inbound notification dispatch. The Python SDK sends requests and receives responses or notifications, but it also queues unused inbound server requests and exposes response helpers. + +`session/prompt` also reports one settled turn through two protocol shapes. The server emits `session.finished` and then returns the constant `{ accepted: true }`; the Python SDK discards that response and waits for the notification to recover the status. Because the response is written only after the handler returns, the notification necessarily precedes the constant response on the same stream. + +The unused halves add pending-request maps, generated IDs, request queues, close-time rejection paths, response helpers, and a second completion waiter without serving a production caller. + +## Proposal + +Specialize each endpoint to its actual role. The TypeScript transport will retain inbound requests, outbound responses, and outbound notifications. The Python client will retain outbound requests and inbound responses or notifications. Delete the opposite-direction request machinery from each side. + +Return the settled outcome directly from `session/prompt` as `{ status, reason }` after `agent.whenIdle()`. Delete `session.finished`, the constant acceptance response, and the Python post-response completion loop. `session.event` and subagent notifications still stream before the response, and durable session events remain the source for final-response reconstruction. + +## Alternatives considered + +**Keep a generic symmetric JSON-RPC peer for future methods.** Server-initiated requests may eventually support interactive permissions, but no typed method or production consumer exists. The pre-release protocol can add the smallest required direction when that feature is designed instead of carrying an unexercised peer today. + +**Keep `session.finished` for streaming clients.** Turn settlement is not incremental data: the request response already marks the same boundary and follows all earlier notifications on the ordered stream. A second terminal notification creates two representations that clients must reconcile. + +## Acceptance criteria + +- The TypeScript endpoint cannot originate requests or consume notifications. +- The Python endpoint cannot originate notifications or consume server requests. +- `session/prompt` returns the authoritative `ok`, `error`, or `aborted` outcome and reason after turn settlement. +- Session events and subagent lifecycle notifications emitted during the turn arrive before the response. +- Same-session overlap rejection, framing, multibyte input, handler errors, flush, shutdown ordering, and final-response reconstruction retain their behavior. +- TypeScript bridge tests, Python SDK tests, built JSON-RPC coverage, snapshots, and generated API documentation pass. + +## Risks + +This deliberately narrows the pre-release wire protocol. Raw clients listening only for `session.finished`, or embedders using the unused symmetric transport methods, must move to the prompt response. A future server-initiated request requires a new typed protocol addition rather than reusing generic dormant machinery. diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md new file mode 100644 index 0000000000..fb9bd06a98 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -0,0 +1,38 @@ +# RFC: 让 JSON-RPC 完成结果与传输方向单一化 + +Status: proposed + +[English](2026-07-19-make-jsonrpc-directional.md) | 中文 + +## 问题 + +JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协议具有固定方向。TypeScript 服务端接收请求并发出响应或通知,其传输层却还实现了未使用的出站请求和入站通知分发。Python SDK 发送请求并接收响应或通知,却还会把未使用的服务端入站请求放入队列,并公开响应辅助方法。 + +`session/prompt` 还会用两种协议结构报告同一个已结束轮次。服务端先发出 `session.finished`,再返回常量 `{ accepted: true }`;Python SDK 丢弃该响应,转而等待通知以取得状态。响应只有在处理函数返回后才会写入,因此在同一条有序流上,通知必然先于这个常量响应。 + +这些未使用的双向能力引入了待处理请求表、生成 ID、请求队列、关闭时的拒绝路径、响应辅助方法和第二套完成等待逻辑,却没有任何生产调用方使用。 + +## 提案 + +按实际角色收窄两个端点。TypeScript 传输层只保留入站请求、出站响应和出站通知。Python 客户端只保留出站请求以及入站响应或通知。删除两侧与实际方向相反的请求机制。 + +在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久会话事件仍是最终响应重建的真源。 + +## 备选方案 + +**为未来方法保留通用的对称 JSON-RPC 对等端。** 服务端发起的请求将来可能用于交互式权限,但当前没有类型化方法或生产消费方。该功能完成设计后,预发布协议可以增加所需的最小方向,无需提前保留未使用的对等端能力。 + +**为流式客户端保留 `session.finished`。** 轮次结束不是增量数据:请求响应已经标识同一个边界,并且在有序流中位于先前所有通知之后。第二条终止通知会产生两种结果表示,迫使客户端进行协调。 + +## 验收标准 + +- TypeScript 端点无法发起请求,也不消费通知。 +- Python 端点无法发起通知,也不消费服务端请求。 +- 轮次结束后,`session/prompt` 返回权威的 `ok`、`error` 或 `aborted` 状态及其原因。 +- 轮次中发出的会话事件与 subagent 生命周期通知都先于响应到达。 +- 同一会话的重叠拒绝、分帧、多字节输入、处理器错误、flush、关闭顺序与最终响应重建保持原有行为。 +- TypeScript 桥接测试、Python SDK 测试、构建后 JSON-RPC 覆盖、快照和生成的 API 文档全部通过。 + +## 风险 + +本提案会刻意收窄预发布协议格式。仅监听 `session.finished` 的原始客户端,以及使用未使用对称传输方法的嵌入方,都必须改为读取请求响应。未来若需要服务端发起请求,应新增类型化协议,而不是复用休眠的通用机制。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml new file mode 100644 index 0000000000..37c3c8b508 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-retire-subagent-mock-package.md: 2731d35448cbc4dc9db4c92579c35202a53eefdd +2026-07-19-retire-subagent-mock-package.zh.md: 919c56502227157465277cc07c23fb7096763684 diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md new file mode 100644 index 0000000000..2731d35448 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md @@ -0,0 +1,35 @@ +# RFC: Retire the standalone subagent mock package + +Status: proposed + +English | [中文](2026-07-19-retire-subagent-mock-package.zh.md) + +## Problem + +`@deepseek-ai/dsh-subagent-mock` is a configurable test double packaged as a workspace plugin. Its only external consumers are the `tool-subagent` unit suite and the tool-catalog generator. No runtime package, example, snapshot configuration, or real provider loads it. + +That narrow fixture carries a manifest, exports, peer and development dependencies, project references, package README obligations, Loader composition tests, module-graph membership, and documentation-gate exceptions. The tool-catalog generator mounts it only to make the real subagent tool register its schema; it never executes a child. + +## Proposal + +Delete `packages/support/subagent-mock`. Move the scripted provider behavior actually used by `tool-subagent` into a package-local test fixture while continuing to exercise the real `SubagentService`, provider registry, and tool implementation. + +Have the tool-catalog generator register the minimal provider descriptor required before mounting `ToolSubagent`. Remove the package references, manifest dependency, graph node, README allowlists, and mock-specific Loader tests. + +## Alternatives considered + +**Keep a reusable mock package for future tests.** Reuse has not materialized outside one test file and one generator. A future second consumer can extract a fixture once its shared contract is known; packaging all configurable reply, cancellation, result, and Loader behavior today makes test infrastructure look like a supported backend. + +**Generate the subagent schema without mounting the real tool.** Hand-constructing or importing the schema would weaken the catalog's check that the production registry and tool composition expose the documented shape. The generator should keep mounting the real service and tool with only the child boundary replaced. + +## Acceptance criteria + +- `packages/support/subagent-mock` and every workspace, graph, dependency, and documentation entry for it are removed. +- `tool-subagent` tests retain every scripted reply, structured-result, cancellation, foreground/background, and task-integration case they currently exercise through the real service and tool. +- Tool-catalog generation mounts the production subagent registry and tool with a minimal local provider and produces a byte-identical catalog. +- No runtime or example package gains a dependency on test-only fixtures. +- Focused subagent tests, catalog and graph generation, module-graph verification, build, hygiene, and the full pre-push suite pass. + +## Risks + +Relocating the fixture could accidentally replace too much production composition with a stub. The local fixture must implement only the nondeterministic child boundary; capability checks, lifecycle, task handling, and tool output remain under production code. Mock Loader and HMR coverage can disappear because no deployed composition consumes the package afterward. diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md new file mode 100644 index 0000000000..919c565022 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md @@ -0,0 +1,35 @@ +# RFC: 撤销独立的 subagent mock 包 + +Status: proposed + +[English](2026-07-19-retire-subagent-mock-package.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-subagent-mock` 是一个以工作区插件形式发布的可配置测试替身。它仅有两个外部消费方:`tool-subagent` 单元测试和工具目录生成器。运行时包、示例、快照配置和真实提供方都不会加载它。 + +这个用途狭窄的 fixture(测试前置数据)需要维护 manifest(元数据清单)、导出、对等依赖(peer dependency)与开发依赖、项目引用、包(package)README 契约、Loader 组合测试、模块图成员关系以及文档门禁例外。工具目录生成器挂载它,只是为了让真实 subagent 工具注册 schema;生成器从不执行子 agent。 + +## 提案 + +删除 `packages/support/subagent-mock`。把 `tool-subagent` 实际使用的脚本化提供方行为移入该包的本地测试 fixture,同时继续测试真实的 `SubagentService`、提供方注册表和工具实现。 + +工具目录生成器在挂载 `ToolSubagent` 前,只注册所需的最小提供方描述。删除该包的项目引用、manifest 依赖、图节点、README 允许列表和 mock 专用 Loader 测试。 + +## 备选方案 + +**为未来测试保留可复用 mock 包。** 除一个测试文件和一个生成器外,复用需求并未出现。未来产生第二个消费方时,可以在共享契约明确后再提取 fixture;当前把所有可配置回复、取消、结果与 Loader 行为打包,会使测试基础设施看起来像受支持的后端。 + +**不挂载真实工具,直接生成 subagent schema。** 手工构造或直接导入 schema,会削弱目录生成器对生产注册表与工具组合是否公开文档结构的校验。生成器应继续挂载真实服务与工具,只替换不确定的子 agent 边界。 + +## 验收标准 + +- 删除 `packages/support/subagent-mock`,并移除其全部工作区、图、依赖和文档条目。 +- `tool-subagent` 测试保留当前通过真实服务与工具覆盖的全部脚本化回复、结构化结果、取消、前台与后台运行以及任务集成用例。 +- 工具目录生成器使用最小本地提供方挂载生产 subagent 注册表与工具,并生成字节级一致的目录。 +- 运行时包与示例包都不会新增对测试专用 fixture 的依赖。 +- 聚焦 subagent 测试、目录与图生成、模块图校验、构建、hygiene 和完整 pre-push 门禁全部通过。 + +## 风险 + +迁移 fixture 时,可能会误将过多生产组合替换成测试替身。本地 fixture 只能实现不确定的 subagent 边界;能力检查、生命周期、任务处理与工具输出仍由生产代码负责。由于之后不再有部署组合消费该包,可以删除 mock 的 Loader 与 HMR 覆盖。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml new file mode 100644 index 0000000000..adee0c347d --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-use-one-session-surface-manager.md: f351c05cf5cb5d408aeb22c8c3e6ac5c575fc24a +2026-07-19-use-one-session-surface-manager.zh.md: 20c1f04f7b4197549f23145aa2c6015cd44f38cc diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md new file mode 100644 index 0000000000..f351c05cf5 --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md @@ -0,0 +1,35 @@ +# RFC: Use one surface manager per session + +Status: proposed + +English | [中文](2026-07-19-use-one-session-surface-manager.zh.md) + +## Problem + +`Session` maintains two `SurfaceManager` instances over the same append-only event log. `surfaceValidator` eagerly validates seed and append candidates, while the lazy `_surface` independently folds committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface is read, every later event advances duplicate node and replacement-generation state. + +The [session surface decision](../../implemented/architecture/2026-06-18-session-surface.md) calls for one ordered surface and one representation to validate. The second manager does not create an independent authority or protect a different failure boundary; it repeats the canonical fold and gives the two views a state-drift opportunity. + +## Proposal + +Keep one `SurfaceManager` per `Session`. Seed and append acceptance continue to call `validateNext()` before committing an event, and the public surface view reads `nodes` and `replaceGeneration` from that same manager. + +Expose only the readonly surface contract from `Session.surface`; candidate validation remains owned by `Session`. Retain `foldSurface()` as the detached full-log replay function used by offline validation and reconstruction. + +## Alternatives considered + +**Keep acceptance and projection state separate.** Separate instances appear to isolate public reads from validation, but ordinary callers already receive borrowed surface state and cannot mutate it through the declared readonly contract. A cast that mutates the returned node array already corrupts derived history; duplicating the manager is not a sound runtime trust boundary. + +**Recompute the public surface from the full log on every access.** This removes cached duplicate state but gives up incremental derivation and makes repeated request construction scale with complete session history. + +## Acceptance criteria + +- A live `Session` owns exactly one incremental `SurfaceManager`. +- Seed and append candidates are validated before publication with no partial surface mutation on rejection. +- `session.surface`, derived messages, compaction, and workspace context observe the same nodes and replacement generation as the acceptance path. +- `foldSurface()` remains available for detached replay and agrees with the live manager for every accepted prefix. +- Session surface, seed, request reconstruction, compaction tool-pairing, and workspace-context tests pass. + +## Risks + +Sharing one manager makes the readonly borrowed-state contract more important because a hostile cast could corrupt both validation and projection state. The implementation should return a narrowed view and keep mutation methods inaccessible through `Session.surface`; JavaScript callers that deliberately bypass the type contract remain outside the supported same-process boundary. diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md new file mode 100644 index 0000000000..20c1f04f7b --- /dev/null +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md @@ -0,0 +1,35 @@ +# RFC: 每个会话只使用一个表层管理器 + +Status: proposed + +[English](2026-07-19-use-one-session-surface-manager.md) | 中文 + +## 问题 + +`Session` 针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。`surfaceValidator` 主动校验种子事件与追加候选事件,延迟创建的 `_surface` 则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态。 + +[会话表层决策](../../implemented/architecture/2026-06-18-session-surface.md)要求系统只保留一个有序表层,并使用一种表示完成校验。第二个管理器既不形成独立真源,也不保护不同的失败边界;它只会重复规范折叠,并使两个视图可能出现状态偏差。 + +## 提案 + +每个 `Session` 只保留一个 `SurfaceManager`。种子事件与追加事件的接纳流程仍在提交事件之前调用 `validateNext()`,公共表层视图则从同一个管理器读取 `nodes` 与 `replaceGeneration`。 + +`Session.surface` 只公开只读表层契约,候选事件校验仍由 `Session` 负责。保留 `foldSurface()`,用于离线校验与重建时执行分离的完整日志回放。 + +## 备选方案 + +**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但普通调用方目前取得的就是借用的表层状态,无法通过声明的只读契约修改它。通过类型断言修改返回的节点数组,本就会破坏派生历史;复制管理器并不能构成可靠的运行时信任边界。 + +**每次读取都根据完整日志重新计算公共表层。** 该方案不再缓存重复状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。 + +## 验收标准 + +- 每个活跃 `Session` 只拥有一个增量 `SurfaceManager`。 +- 种子事件与追加候选事件都在发布前完成校验,拒绝事件时不会留下只修改一半的表层状态。 +- `session.surface`、派生消息、压缩和工作区上下文观察到的节点与替换代数,和接纳路径使用的状态完全一致。 +- `foldSurface()` 仍可用于分离回放,并且对任意已接纳前缀都与活跃管理器一致。 +- 会话表层、种子、请求重建、压缩工具配对和工作区上下文测试全部通过。 + +## 风险 + +共享一个管理器会提高只读借用状态契约的重要性,因为恶意类型断言可能同时破坏校验状态和投影视图。实现应返回收窄后的视图,避免通过 `Session.surface` 暴露修改方法;刻意绕过类型契约的 JavaScript 调用方不属于受支持的同进程边界。 From 0c911172e959d16fdf54a0cd1d927f4cb23c997b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:45:04 +0800 Subject: [PATCH 149/163] docs: drop rejected compaction RFC --- docs/rfc/INDEX.md | 1 - ...19-fold-compaction-package-split.i18n.yaml | 6 --- ...026-07-19-fold-compaction-package-split.md | 37 ------------------- ...-07-19-fold-compaction-package-split.zh.md | 37 ------------------- 4 files changed, 81 deletions(-) delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 99ea97d16a..4dc9b229ff 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -21,7 +21,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | Title | First proposed | |---|---| | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | -| [Fold the single compaction backend into its service package](proposed/simplification/2026-07-19-fold-compaction-package-split.md) | 2026-07-19 | | [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 | | [Retire the standalone subagent mock package](proposed/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | | [Use one surface manager per session](proposed/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | diff --git a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml deleted file mode 100644 index 2aabfdf8cc..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-19-fold-compaction-package-split.md: 37be6de84356549d27a8fc32b6589130eb1f27b8 -2026-07-19-fold-compaction-package-split.zh.md: f6609add00826ba472f8c82e0a67613ca201fd30 diff --git a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md deleted file mode 100644 index 37be6de843..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.md +++ /dev/null @@ -1,37 +0,0 @@ -# RFC: Fold the single compaction backend into its service package - -Status: proposed - -English | [中文](2026-07-19-fold-compaction-package-split.zh.md) - -## Problem - -Compaction is split between `@deepseek-ai/dsh-compact`, which owns an abstract two-method service and shared types, and `@deepseek-ai/dsh-compact-basic`, which owns the only complete implementation. Shipped configurations load only the basic package, and no production package independently consumes the interface package except that implementation. - -The split adds a package manifest, README, project boundary, dependency edge, abstract forwarding class, generated catalog entries, and composition wiring without demonstrating backend substitution. The [capability-seam decision](../../implemented/architecture/2026-06-13-capability-seams.md) requires a real interface, implementation, and consumer rather than a preemptive split; the [compaction decision](../../implemented/feature/2026-06-18-compaction-capability-seam.md) records that its independent consumer was deferred. - -## Proposal - -Move the basic implementation into `@deepseek-ai/dsh-compact` and remove `@deepseek-ai/dsh-compact-basic`. Keep `ctx.compact`, `CompactionResult`, the shared transcript and tool-pairing helpers, the existing configuration, and the concrete compaction algorithm in one package. - -Preserve `summarize()` as a protected customization hook. A deployment-specific summarizer can subclass or intercept the existing LLM call without requiring a second capability package. Reintroduce an interface package only when a second complete backend and an independent consumer need substitution. - -Amend the implemented compaction decision and the [recallable-compaction proposal](../feature/2026-07-06-recallable-compaction.md) if this proposal is accepted so package ownership has one durable description. - -## Alternatives considered - -**Keep the split because a remote or recall backend may arrive.** A possible future implementation does not justify the current package boundary. Recall adds a consumer of compaction results, not necessarily another implementation, and a remote summarizer can use the protected hook. - -**Move the implementation package name onto the interface package.** Keeping `compact-basic` as the surviving name would make the product service appear to be one optional backend. `compact` is the stable service identity already used by `ctx.compact` and is the clearer single-package owner. - -## Acceptance criteria - -- `@deepseek-ai/dsh-compact-basic` and its workspace/package metadata are removed. -- `@deepseek-ai/dsh-compact` owns the current configuration, plugin class, algorithm, types, events, and shared helpers. -- Existing deployments can load the surviving package with equivalent configuration and model-visible behavior. -- Automatic and manual compaction preserve cancellation, locking, token accounting, tool pairing, durable events, provenance, retry convergence, and transcript rendering. -- Loader composition, unit, runaway-turn, cancellation, snapshot, and real-model compaction tests pass; generated catalogs and module graphs are current. - -## Risks - -This is an intentional pre-release package-name contraction. Embedders loading `@deepseek-ai/dsh-compact-basic` must switch packages, and future backend substitution would require extracting a boundary again. The cost is acceptable only while one complete implementation exists; acceptance should be revisited if a second backend lands first. diff --git a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md b/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md deleted file mode 100644 index f6609add00..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-fold-compaction-package-split.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# RFC: 将唯一的压缩后端并入服务包 - -Status: proposed - -[English](2026-07-19-fold-compaction-package-split.md) | 中文 - -## 问题 - -压缩(compaction)目前拆分在两个包中:`@deepseek-ai/dsh-compact` 拥有一个含两个方法的抽象服务和共享类型,`@deepseek-ai/dsh-compact-basic` 拥有唯一的完整实现。交付配置只加载 basic 包,除了该实现外,没有生产包独立消费接口包。 - -该拆分增加了一份包(package)manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有体现后端替换需求。[能力服务边界决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 - -## 提案 - -把 basic 实现移入 `@deepseek-ai/dsh-compact`,并删除 `@deepseek-ai/dsh-compact-basic`。`ctx.compact`、`CompactionResult`、共享 transcript(文本记录)和工具配对辅助方法、现有配置以及具体压缩算法都由一个包负责。 - -保留 `summarize()` 作为受保护的自定义钩子。部署专用的摘要器可以通过继承或拦截现有 LLM(大语言模型)调用完成定制,无需第二个能力包。只有在第二个完整后端与独立消费方确实需要替换实现时,才重新提取接口包。 - -如果本提案获准,应同步修订已实现的压缩决策与[可回忆压缩提案](../feature/2026-07-06-recallable-compaction.md),使包所有权只有一处持久说明。 - -## 备选方案 - -**为可能出现的远程或回忆后端保留拆分。** 一种可能的未来实现不足以支撑当前包边界。回忆功能会增加压缩结果的消费方,但不一定增加另一种实现;远程摘要器也可以使用受保护钩子。 - -**让接口包并入实现包名。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 - -## 验收标准 - -- 删除 `@deepseek-ai/dsh-compact-basic` 及其工作区和包元数据。 -- `@deepseek-ai/dsh-compact` 拥有当前配置、插件类、算法、类型、事件和共享辅助方法。 -- 现有部署可以使用等效配置加载保留的包,模型可见行为不变。 -- 自动压缩和手动压缩保留取消、锁、token 用量、工具配对、持久事件、来源、重试收敛和 transcript 渲染行为。 -- Loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 - -## 风险 - -这是一项有意实施的预发布包名收缩。加载 `@deepseek-ai/dsh-compact-basic` 的嵌入方必须切换包,未来的后端替换也需要重新提取边界。只有在仍然只有一个完整实现时,这项代价才可接受;如果第二个后端先行落地,应重新评估是否接纳本提案。 From b5e8e4e9c1a7a96f88a33e40fe45f9c039b12b5d Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:49:59 +0800 Subject: [PATCH 150/163] fix(tui): neutralize terminal controls at display boundary Model responses, replayed session data, tool presenter output, question metadata, configuration, and diagnostics all cross into ANSI-aware pi-tui renderers. Passing their C0 or C1 controls through unchanged lets an otherwise ordinary transcript emit OSC, CSI, cursor, or title operations in the user terminal. Introduce one displayText boundary that preserves line-feed layout but renders every other C0/C1 control as visible \\xNN text before application styling is applied. Route transcript blocks, streaming output, tool cards, diffs, plans, dialogs, headers, cwd/title data, notices, errors, and pre-mount startup failures through that boundary while leaving pi-tui and the theme responsible for legitimate terminal control sequences. Pin the contract at three levels: a settled headless-terminal golden spans the main untrusted display sources, unit coverage checks the pre-fullscreen failure path, and the real Loader/PTY conversation streams hostile OSC, cursor, and C1 probes and proves only their inert textual forms reach the terminal stream. --- .../tests/fixtures/tui-scripted-llm.ts | 3 +- .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 6 + packages/ui/tui/README.md | 2 + packages/ui/tui/src/index.ts | 88 +++++++++------ .../snapshots/untrusted-controls.golden.txt | 106 ++++++++++++++++++ packages/ui/tui/tests/tui.snapshot.ts | 80 +++++++++++++ packages/ui/tui/tests/tui.spec.ts | 4 +- 7 files changed, 250 insertions(+), 39 deletions(-) create mode 100644 packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 2147dfb222..c55b3d355c 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -2,7 +2,8 @@ import type { Context } from 'cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' -const INITIAL_TEXT = 'I need one decision before I continue.' +const CONTROL_PROBE = '\u001b]2;MODEL_CONTROLLED\u0007\u001b[999CMODEL_CURSOR\u009b31mMODEL_C1' +const INITIAL_TEXT = `I need one decision before I continue. ${CONTROL_PROBE}` const FINAL_TEXT = 'Decision received. Scripted TUI run complete.' function textChunks(text: string): StreamChunk[] { diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 26cee8aea2..f9b2ec1d43 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -153,6 +153,12 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('streams a response, answers a user-question dialog, completes the tool round-trip, and exits cleanly', async () => { const output = await runTuiLoaderSmoke({ config: scriptedConfigPath, scenario: 'conversation' }) expect(output).toContain('I need one decision before I continue.') + expect(output).toContain(String.raw`\x1b]2;MODEL_CONTROLLED\x07`) + expect(output).toContain(String.raw`\x1b[999CMODEL_CURSOR`) + expect(output).toContain(String.raw`\x9b31mMODEL_C1`) + expect(output).not.toContain('\u001B]2;MODEL_CONTROLLED\u0007') + expect(output).not.toContain('\u001B[999CMODEL_CURSOR') + expect(output).not.toContain('\u009B31mMODEL_C1') expect(output).toContain('How should the scripted run proceed?') expect(output).toContain('Safe') expect(output).toContain('Decision received. Scripted TUI run complete.') diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index 865a20a469..cf4cc609ce 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -6,6 +6,8 @@ This package owns interactive terminal presentation and input only. It injects ` The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. +Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling. + While the agent is running, editor submissions call `agent.steer()`; otherwise they call `agent.send()`. Ctrl+C or Escape cancels a running turn. Ctrl+O expands tool cards, Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle. `/help`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` provide the same actions without key chords. ## Config diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index b9b74e7c9a..612f2f3099 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -179,6 +179,17 @@ function ansi(open: string, close: string, enabled: boolean): (text: string) => return enabled ? text => `\x1b[${open}m${text}\x1b[${close}m` : text => text } +const TERMINAL_CONTROL_PATTERN = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f]/gu + +/** + * Escape external C0/C1 controls before pi-tui adds application-owned ANSI. + * Line feeds remain structural so transcript and tool output retain their layout. + */ +function displayText(text: string): string { + return text.replace(TERMINAL_CONTROL_PATTERN, control => + `\\x${control.charCodeAt(0).toString(16).padStart(2, '0')}`) +} + /** * Theme-agnostic palette built from the standard 16-color ANSI set plus SGR * attributes, which every terminal remaps to its active color scheme. Body @@ -281,11 +292,11 @@ class HeaderComponent implements Component { render(width: number): string[] { const usable = Math.max(1, width - 4) const title = `${this.palette.bold(this.palette.accent('DEEPSEEK'))} ${this.palette.bold('HARNESS')}` - const model = this.agent.options.model ?? 'model unset' - const detail = `${this.agent.id} • ${model} • ${this.agent.session.id}` + const model = displayText(this.agent.options.model ?? 'model unset') + const detail = `${displayText(this.agent.id)} • ${model} • ${displayText(this.agent.session.id)}` const top = this.palette.accent(`╭${'─'.repeat(Math.max(0, width - 2))}╮`) const bottom = this.palette.accent(`╰${'─'.repeat(Math.max(0, width - 2))}╯`) - const lines = [title, this.palette.muted(this.welcome), this.palette.dim(detail)] + const lines = [title, this.palette.muted(displayText(this.welcome)), this.palette.dim(detail)] .flatMap(line => wrapTextWithAnsi(line, usable)) .map((line) => { const clipped = truncateToWidth(line, usable, '') @@ -330,8 +341,8 @@ class GutterBox implements Component { class UserMessageComponent extends GutterBox { constructor(text: string, palette: Palette, mdTheme: MarkdownTheme, label = 'You') { super(value => palette.accent(value)) - this.addChild(new Text(palette.bold(palette.accent(label)), 0, 0)) - this.addChild(new Markdown(text, 0, 0, mdTheme, { color: value => palette.text(value) }, { + this.addChild(new Text(palette.bold(palette.accent(displayText(label))), 0, 0)) + this.addChild(new Markdown(displayText(text), 0, 0, mdTheme, { color: value => palette.text(value) }, { preserveOrderedListMarkers: true, preserveBackslashEscapes: true, })) @@ -341,8 +352,8 @@ class UserMessageComponent extends GutterBox { class AssistantMessageComponent extends Container { constructor(content: readonly ContentBlock[], showReasoning: boolean, palette: Palette, mdTheme: MarkdownTheme) { super() - const reasoning = textBlocks(content, 'reasoning').trim() - const text = textBlocks(content, 'text').trim() + const reasoning = displayText(textBlocks(content, 'reasoning').trim()) + const text = displayText(textBlocks(content, 'text').trim()) if (reasoning && showReasoning) { this.addChild(new Spacer(1)) this.addChild(new Text(palette.italic(palette.muted('Reasoning')), 1, 0)) @@ -422,19 +433,19 @@ function parseArguments(raw: string): ParsedArguments { } function pretty(value: unknown): string { - if (typeof value === 'string') return value + if (typeof value === 'string') return displayText(value) // The lib declaration narrows `unknown` to a string-returning overload, but // JSON.stringify returns undefined for runtime values such as symbols. const serialized = JSON.stringify(value, null, 2) as string | undefined - return serialized ?? String(value) + return displayText(serialized ?? String(value)) } function diffLines(diff: FileDiff, palette: Palette): string[] { - const lines = [palette.bold(diff.path)] + const lines = [palette.bold(displayText(diff.path))] if (diff.oldText !== null) { - for (const line of diff.oldText.split('\n')) lines.push(palette.removed(`- ${line}`)) + for (const line of displayText(diff.oldText).split('\n')) lines.push(palette.removed(`- ${line}`)) } - for (const line of diff.newText.split('\n')) lines.push(palette.added(`+ ${line}`)) + for (const line of displayText(diff.newText).split('\n')) lines.push(palette.added(`+ ${line}`)) return lines } @@ -460,10 +471,10 @@ class ToolCardComponent implements Component { const view = this.definition.presentCall(this.parsed.value) if (view !== undefined) return view } catch (error: unknown) { - return { card: 'generic', title: this.name, rawInput: `Presenter failed: ${String(error)}` } + return { card: 'generic', title: displayText(this.name), rawInput: `Presenter failed: ${String(error)}` } } } - return { card: 'generic', title: this.name, rawInput: this.parsed.value } + return { card: 'generic', title: displayText(this.name), rawInput: this.parsed.value } } updateResult(event: Extract['data']): void { @@ -492,7 +503,7 @@ class ToolCardComponent implements Component { const isError = this.result?.isError ?? false const glyph = this.result === undefined ? this.palette.warning('◌') : isError ? this.palette.error('✕') : this.palette.success('✓') const body = this.renderBody() - const title = truncateToWidth(`${glyph} ${this.title()}`, Math.max(1, width - 4), '') + const title = truncateToWidth(`${glyph} ${displayText(this.title())}`, Math.max(1, width - 4), '') const visibleBody = this.expanded || body.length <= this.maxOutputLines ? body : [...body.slice(0, this.maxOutputLines), this.palette.dim(`… ${body.length - this.maxOutputLines} more lines (Ctrl+O to expand)`)] @@ -514,17 +525,19 @@ class ToolCardComponent implements Component { if (view.card === 'terminal') { const pending = this.callView.card === 'terminal' ? this.callView : undefined const lines: string[] = [] - if (pending?.description) lines.push(this.palette.muted(pending.description)) - if (pending?.cwd) lines.push(this.palette.dim(pending.cwd)) + if (pending?.description) lines.push(this.palette.muted(displayText(pending.description))) + if (pending?.cwd) lines.push(this.palette.dim(displayText(pending.cwd))) if (this.resultView?.card === 'terminal') { - if (this.resultView.output) lines.push(...this.resultView.output.split('\n')) + if (this.resultView.output) lines.push(...displayText(this.resultView.output).split('\n')) if (this.resultView.exitCode !== undefined) lines.push(this.palette.dim(`[exit ${this.resultView.exitCode}]`)) - if (this.resultView.signal !== undefined) lines.push(this.palette.error(`[signal ${this.resultView.signal}]`)) + if (this.resultView.signal !== undefined) { + lines.push(this.palette.error(`[signal ${displayText(this.resultView.signal)}]`)) + } } else if (this.result === undefined) { // A pending terminal view is the call view itself; TerminalCallView requires a title. - lines.push(this.palette.code(`$ ${(pending as TerminalCallView).title}`)) + lines.push(this.palette.code(`$ ${displayText((pending as TerminalCallView).title)}`)) } else { - lines.push(...contentText(this.result.content).split('\n')) + lines.push(...displayText(contentText(this.result.content)).split('\n')) } return lines.filter(Boolean) } @@ -536,7 +549,7 @@ class ToolCardComponent implements Component { } const content = view.content ?? this.result?.content const lines: string[] = [] - if (content !== undefined) lines.push(...contentText(content).split('\n')) + if (content !== undefined) lines.push(...displayText(contentText(content)).split('\n')) const rawInput = this.result === undefined && this.callView.card === 'generic' ? this.callView.rawInput : undefined @@ -565,7 +578,8 @@ class TodoComponent implements Component { : todo.status === 'in_progress' ? this.palette.warning('●') : this.palette.dim('○') - const text = todo.status === 'completed' ? this.palette.muted(todo.content) : todo.content + const content = displayText(todo.content) + const text = todo.status === 'completed' ? this.palette.muted(content) : content lines.push(truncateToWidth(` ${prefix} ${text}`, width, '')) } return ['', ...lines] @@ -584,8 +598,8 @@ function formatCwd(cwd: string | undefined): string { const home = homedir() const rel = relative(resolve(home), resolve(cwd)) if (rel === '') return '~' - if (rel !== '..' && !rel.startsWith(`..${sep}`)) return `~${sep}${rel}` - return cwd + if (rel !== '..' && !rel.startsWith(`..${sep}`)) return displayText(`~${sep}${rel}`) + return displayText(cwd) } function sessionTokens(session: Session): { input: number; output: number } { @@ -702,7 +716,7 @@ class QuestionDialog implements Component, Focusable { render(width: number): string[] { this.input.focused = this.focused const innerWidth = Math.max(1, width - 4) - const title = this.question.header ?? 'Question' + const title = displayText(this.question.header ?? 'Question') const topLabel = ` ${title} ` const top = `╭${topLabel}${'─'.repeat(Math.max(0, width - visibleWidth(topLabel) - 2))}╮` const lines: string[] = [this.palette.accent(top)] @@ -710,7 +724,7 @@ class QuestionDialog implements Component, Focusable { const clipped = truncateToWidth(line, innerWidth, '') lines.push(`${this.palette.accent('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${this.palette.accent('│')}`) } - for (const line of wrapTextWithAnsi(this.palette.bold(this.question.question), innerWidth)) push(line) + for (const line of wrapTextWithAnsi(this.palette.bold(displayText(this.question.question)), innerWidth)) push(line) push('') if (this.mode === 'custom') { for (const line of this.input.render(innerWidth)) push(line) @@ -729,8 +743,10 @@ class QuestionDialog implements Component, Focusable { const mark = this.question.multiSelect ? this.selected.has(index) ? this.palette.success('[x]') : '[ ]' : index === this.selectedIndex ? this.palette.accent('●') : this.palette.dim('○') - const description = option.description ? this.palette.muted(` — ${option.description}`) : '' - const line = `${cursor} ${mark} ${option.label}${description}` + const description = option.description + ? this.palette.muted(` — ${displayText(option.description)}`) + : '' + const line = `${cursor} ${mark} ${displayText(option.label)}${description}` push(index === this.selectedIndex ? this.palette.selected(line) : line) } if (options.length > this.maxVisible) push(this.palette.dim(`${this.selectedIndex + 1}/${options.length}`)) @@ -826,7 +842,7 @@ export function createTuiChat( ui.addChild(editor) ui.addChild(footer) ui.setFocus(editor) - runtime.terminal.setTitle(resolved.title) + runtime.terminal.setTitle(displayText(resolved.title)) const requestRender = (): void => { footer.invalidate() @@ -836,7 +852,7 @@ export function createTuiChat( const appendNotice = (message: string, kind: 'info' | 'warning' | 'error' = 'info'): void => { const color = kind === 'error' ? palette.error : kind === 'warning' ? palette.warning : palette.muted chat.addChild(new Spacer(1)) - chat.addChild(new Text(color(message), 1, 0)) + chat.addChild(new Text(color(displayText(message)), 1, 0)) requestRender() } @@ -876,7 +892,7 @@ export function createTuiChat( const renderEvent = (event: SessionEvent, options: { addHistory: boolean; renderChunks: boolean }): void => { switch (event.type) { case 'user/message': { - const text = contentText(event.data.content).trim() + const text = displayText(contentText(event.data.content).trim()) if (text) { chat.addChild(new Spacer(1)) chat.addChild(new UserMessageComponent(text, palette, mdTheme)) @@ -885,7 +901,7 @@ export function createTuiChat( break } case 'steering/message': { - const text = contentText(event.data.content).trim() + const text = displayText(contentText(event.data.content).trim()) if (text) { chat.addChild(new Spacer(1)) chat.addChild(new UserMessageComponent(text, palette, mdTheme, 'Steering')) @@ -893,11 +909,11 @@ export function createTuiChat( break } case 'context/message': { - const text = contentText(event.data.content).trim() + const text = displayText(contentText(event.data.content).trim()) if (text) { const source = event.data.source.kind === 'plugin' ? event.data.source.plugin : event.data.source.kind chat.addChild(new Spacer(1)) - chat.addChild(new Text(palette.dim(`Context · ${source}`), 1, 0)) + chat.addChild(new Text(palette.dim(`Context · ${displayText(source)}`), 1, 0)) chat.addChild(new Text(palette.muted(text), 1, 0)) } break @@ -1291,7 +1307,7 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi }, 'ui-tui') }, onFailed: (error) => { - runtime.terminal.write(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`) + runtime.terminal.write(displayText(`ui-tui: agent "${agentId}" failed to start: ${error.message}\n`)) runtime.exit(1) }, }) diff --git a/packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt b/packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt new file mode 100644 index 0000000000..1a64b277d5 --- /dev/null +++ b/packages/ui/tui/tests/snapshots/untrusted-controls.golden.txt @@ -0,0 +1,106 @@ +terminal 100x34 buffer=normal length=40 base=6 viewport=6 +lifecycle started=1 stopped=0 progress=inactive +title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" +cursor hidden column=100 viewportRow=33 bufferRow=39 +buffer +0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮" + style 0-99 fg=bright-blue +1| "│ DEEPSEEK HARNESS │" + style 0-0 fg=bright-blue + style 2-9 fg=bright-blue bold + style 11-17 bold + style 99-99 fg=bright-blue +2| "│ Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │" + style 0-0 fg=bright-blue + style 2-61 fg=bright-black + style 99-99 fg=bright-blue +3| "│ main • deepseek-v4-flash • main-session │" + style 0-0 fg=bright-blue + style 2-44 dim + style 99-99 fg=bright-blue +4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯" + style 0-99 fg=bright-blue +5| +6| "▌ " + style 0-0 fg=bright-blue +7| "▌ You " + style 0-0 fg=bright-blue + style 2-4 fg=bright-blue bold +8| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-0 fg=bright-blue +9| "▌ " + style 0-0 fg=bright-blue +10| +11| " Reasoning " + style 1-9 fg=bright-black italic +12| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-62 fg=bright-black italic +13| +14| " Assistant " + style 1-9 fg=bright-magenta bold +15| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " +16| +17| "▌ " + style 0-0 fg=green +18| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-0 fg=green + style 2-2 fg=green bold + style 3-61 bold +19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 0-0 fg=green + style 2-65 fg=bright-black +20| "▌ /unsafe/\\x1b╭ Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m ─────────╮ " + style 0-0 fg=green + style 2-13 dim + style 14-85 fg=bright-blue +21| "▌ Unsafe outpu│ Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │ " + style 0-0 fg=green + style 14-14 fg=bright-blue + style 16-76 bold + style 85-85 fg=bright-blue +22| "▌ [signal SIG\\│ │ " + style 0-0 fg=green + style 2-13 fg=red + style 14-14 fg=bright-blue + style 85-85 fg=bright-blue +23| "▌ │ › ● Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m — Un │ " + style 0-0 fg=green + style 14-14 fg=bright-blue + style 16-16 fg=bright-blue inverse + style 17-17 inverse + style 18-18 fg=bright-blue inverse + style 19-78 inverse + style 79-83 fg=bright-black inverse + style 85-85 fg=bright-blue +24| " │ ↑↓ navigate • Enter select • C custom • Esc cancel │ " + style 14-14 fg=bright-blue + style 16-65 dim + style 85-85 fg=bright-blue +25| " Context · uns╰──────────────────────────────────────────────────────────────────────╯ " + style 1-13 dim + style 14-85 fg=bright-blue +26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-60 fg=bright-black +27| +28| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-75 fg=yellow +29| +30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-63 fg=red +31| +32| " Unsafe live error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m " + style 1-63 fg=red +33| +34| "Plan" + style 0-3 fg=bright-blue bold +35| " ● Unsafe todo \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m" + style 2-2 fg=yellow +36| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +37| " " + style 1-1 inverse +38| "────────────────────────────────────────────────────────────────────────────────────────────────────" + style 0-99 dim +39| "/workspace/project ↑0 ↓0 idle reasoning:on tools:compact" + style 0-24 dim + style 67-99 dim diff --git a/packages/ui/tui/tests/tui.snapshot.ts b/packages/ui/tui/tests/tui.snapshot.ts index 0967e7f64c..e0016184fd 100644 --- a/packages/ui/tui/tests/tui.snapshot.ts +++ b/packages/ui/tui/tests/tui.snapshot.ts @@ -29,6 +29,7 @@ const CHECKPOINTS = [ 'cordis-tools-pending', 'advanced-cards-collapsed', 'advanced-cards-expanded', + 'untrusted-controls', 'question-dialog', 'question-dialog-validation', 'surface-before-compaction', @@ -188,6 +189,9 @@ const ADVANCED_CARD_TOOLS: Record = { })), } +const CONTROL_PROBE = '\u001b]2;snapshot-controlled\u0007\t\u007f\u009b31m' +const DISPLAYED_CONTROL_PROBE = String.raw`\x1b]2;snapshot-controlled\x07\x09\x7f\x9b31m` + describe('TUI terminal-state snapshots', () => { it('pins an in-flight reasoning and Markdown stream', async () => { const harness = await setupSnapshot() @@ -298,6 +302,82 @@ describe('TUI terminal-state snapshots', () => { await disposeSnapshot(harness) }) + it('renders terminal controls as inert text across transcripts, tools, dialogs, diagnostics, and title', async () => { + const tools = { + unsafe: visualTool( + 'unsafe', + () => ({ + card: 'terminal', + title: `Unsafe title ${CONTROL_PROBE}`, + description: `Unsafe description ${CONTROL_PROBE}`, + cwd: `/unsafe/${CONTROL_PROBE}`, + }), + () => ({ + card: 'terminal', + output: `Unsafe output ${CONTROL_PROBE}`, + signal: `SIG${CONTROL_PROBE}`, + }), + ), + } + const harness = await setupSnapshot({ + tools, + config: { + welcome: `Unsafe welcome ${CONTROL_PROBE}`, + title: `Unsafe terminal title ${CONTROL_PROBE}`, + }, + beforeMount(session) { + appendUser(session, `Unsafe user ${CONTROL_PROBE}`) + appendAssistant(session, [ + { type: 'reasoning', text: `Unsafe reasoning ${CONTROL_PROBE}` }, + { type: 'text', text: `Unsafe assistant ${CONTROL_PROBE}` }, + ]) + appendToolCalls(session, [{ id: 'unsafe-1', name: 'unsafe', arguments: { value: CONTROL_PROBE } }]) + appendToolResult(session, 'unsafe-1', [{ type: 'text', text: `Unsafe fallback ${CONTROL_PROBE}` }]) + session.append('todo/write', { + todos: [{ content: `Unsafe todo ${CONTROL_PROBE}`, status: 'in_progress' }], + }) + session.append('context/message', { + content: [{ type: 'text', text: `Unsafe context ${CONTROL_PROBE}` }], + source: { kind: 'plugin', plugin: `unsafe-${CONTROL_PROBE}` }, + }, { surfaceOp: 'append' }) + session.append('prompt/blocked', { + content: [{ type: 'text', text: 'blocked' }], + source: { kind: 'user' }, + reason: `Unsafe policy ${CONTROL_PROBE}`, + }) + session.append('turn/end', { + turn: 7, + reason: { kind: 'error', step: 2, message: `Unsafe turn error ${CONTROL_PROBE}` }, + }) + }, + }, { columns: 100, rows: 34 }) + expect(harness.terminal.title).toContain(DISPLAYED_CONTROL_PROBE) + expect(harness.terminal.title).not.toContain('\u001b') + expect(harness.terminal.title).not.toContain('\u009b') + + const controller = new AbortController() + const beforeQuestion = harness.terminal.frames + const answer = harness.ctx.userInteraction.ask({ + questions: [{ + id: 'unsafe-question', + header: `Unsafe header ${CONTROL_PROBE}`, + question: `Unsafe question ${CONTROL_PROBE}`, + options: [{ label: `Unsafe option ${CONTROL_PROBE}`, description: `Unsafe detail ${CONTROL_PROBE}` }], + }], + signal: controller.signal, + }) + const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + await harness.terminal.waitForFrame(beforeQuestion) + await renderAfter(harness, () => { + harness.ctx.emit('agent/error', harness.agent, 8, 3, new Error(`Unsafe live error ${CONTROL_PROBE}`)) + }) + await checkpoint('untrusted-controls', harness.terminal, { includeScrollback: true }) + + controller.abort() + await rejected + await disposeSnapshot(harness) + }) + it('pins a constrained multi-select question and its validation state', async () => { const harness = await setupSnapshot({ config: { diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 5fc11cbe40..94a2447d5f 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -856,8 +856,8 @@ describe('terminal mounting', () => { ctx.agents.reportStartFailure(AgentId('other'), new Error('other failed')) expect(terminal.output).toBe('') expect(exit).not.toHaveBeenCalled() - ctx.agents.reportStartFailure(AgentId('main'), new Error('resume failed')) - expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume failed\n') + ctx.agents.reportStartFailure(AgentId('main'), new Error('resume \u001b]2;failure-controlled\u0007')) + expect(terminal.output).toBe('ui-tui: agent "main" failed to start: resume \\x1b]2;failure-controlled\\x07\n') expect(exit).toHaveBeenCalledWith(1) const session = ctx.sessions.create(SessionId('must-not-start')) From e8ea0c3b7e021789aca0aaa4201998b6a1958bd3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:51:20 +0800 Subject: [PATCH 151/163] test(tui): make cwd coverage location-independent formatCwd has distinct branches for the home directory itself, a descendant of home, an unset cwd, and a path outside home. The descendant branch was exercised only indirectly because the test harness defaulted to process.cwd(), so the per-file coverage gate changed with the checkout location and failed in detached worktrees under /private/tmp even though every behavior assertion passed. Derive the home-root case from homedir() and add an explicit synthetic child with platform-aware path joins. Keep the unset and outside-home cases unchanged. The suite now drives every formatting branch regardless of where the repository is checked out, and the expected child form follows the host path separator on macOS, Linux, and Windows. --- packages/ui/tui/tests/tui.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/ui/tui/tests/tui.spec.ts b/packages/ui/tui/tests/tui.spec.ts index 94a2447d5f..96f03647be 100644 --- a/packages/ui/tui/tests/tui.spec.ts +++ b/packages/ui/tui/tests/tui.spec.ts @@ -1,3 +1,5 @@ +import { homedir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import type { Terminal } from '@earendil-works/pi-tui' @@ -341,8 +343,9 @@ describe('pi-tui chat lifecycle and transcript', () => { }) it('formats large token totals and cwd variants', async () => { + const home = homedir() const homeResult = await setup({ - cwd: process.env.HOME ?? process.cwd(), + cwd: home, beforeMount(session) { appendAssistant(session, [{ type: 'text', text: 'home' }], { inputTokens: 25_000, outputTokens: 10_000 }) }, @@ -350,6 +353,10 @@ describe('pi-tui chat lifecycle and transcript', () => { expect(homeResult.terminal.output).toContain('~ ↑25k ↓10k') await dispose(homeResult) + const childResult = await setup({ cwd: join(home, 'projects', 'dsh-tui') }) + expect(childResult.terminal.output).toContain(join('~', 'projects', 'dsh-tui')) + await dispose(childResult) + const unsetResult = await setup({ cwd: null }) expect(unsetResult.terminal.output).toContain('cwd unset') await dispose(unsetResult) From 89416002736122864b0eb1530350ad78ef090b26 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 10:59:23 +0800 Subject: [PATCH 152/163] docs: expand simplification RFC implementation plans --- .../2026-07-19-make-jsonrpc-directional.i18n.yaml | 4 ++-- .../simplification/2026-07-19-make-jsonrpc-directional.md | 8 ++++++++ .../2026-07-19-make-jsonrpc-directional.zh.md | 8 ++++++++ .../2026-07-19-use-one-session-surface-manager.i18n.yaml | 4 ++-- .../2026-07-19-use-one-session-surface-manager.md | 8 ++++++++ .../2026-07-19-use-one-session-surface-manager.zh.md | 8 ++++++++ 6 files changed, 36 insertions(+), 4 deletions(-) diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml index b515d611f5..e04aed96a6 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-make-jsonrpc-directional.md: 4531cb251c4d12c9f2d700c9297bdee87e284779 -2026-07-19-make-jsonrpc-directional.zh.md: fb9bd06a9817357112b1eba2f41130ce2368d0b9 +2026-07-19-make-jsonrpc-directional.md: 2a9579c53c111887e9d93cf02cc832304a496795 +2026-07-19-make-jsonrpc-directional.zh.md: 4f3793f1b4de3b4f69c4219c10fe5b3b360c8692 diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md index 4531cb251c..2a9579c53c 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.md @@ -18,6 +18,14 @@ Specialize each endpoint to its actual role. The TypeScript transport will retai Return the settled outcome directly from `session/prompt` as `{ status, reason }` after `agent.whenIdle()`. Delete `session.finished`, the constant acceptance response, and the Python post-response completion loop. `session.event` and subagent notifications still stream before the response, and durable session events remain the source for final-response reconstruction. +## Implementation plan + +1. In `packages/ui/jsonrpc/src/server.ts`, replace `SessionPromptResult.accepted` with `status: 'ok' | 'error' | 'aborted'` and the captured `TurnEndReason`. `HarnessSdkServer.prompt()` will return `completed` as `ok`, `aborted` as `aborted`, and every other current or merge-extensible reason as `error`; reaching idle without a `turn/end` remains an invariant error. Remove only `session.finished`, leaving `session.event`, `subagent.started`, and `subagent.finished` unchanged. +2. In `packages/ui/jsonrpc/src/transport.ts`, replace `JsonRpcTransportPeer` with a server-side notification surface and retain `onRequest()`, `notify()`, `start()`, `flush()`, and `close()`. Remove generated request IDs, the pending-response map, outbound `request()`, inbound response and notification dispatch, and close-time pending-request rejection. Incoming response- and notification-shaped frames will be ignored, while request result, method-not-found, and handler-error responses retain their current behavior and remain ordered after notifications emitted by the awaited handler. +3. In `python/sdk/src/deepseek_harness/client.py`, `models.py`, and `__init__.py`, remove `IncomingRequest`, `_requests`, `notify()`, `next_request()`, `respond()`, and `respond_error()`. Add a public validated `SessionPromptResponse` carrying status and reason, return it from `session_prompt()`, and keep an explicit reader guard that ignores unexpected server-request frames instead of allowing them to match a response waiter. +4. In `python/sdk/src/deepseek_harness/api.py`, build `TurnResult.status` and a new `TurnResult.reason` from `SessionPromptResponse`, then delete the `session.finished` branch and second completion loop. Keep the subscription open during the request and preserve `_request_raw()`'s final notification drain so the last `turn/end` event and any subagent notification written before the response are collected before `Session.run()` reconstructs the final assistant message. +5. Replace the symmetric transport-pair cases in `packages/ui/jsonrpc/tests/transport.spec.ts` with raw client-input/server-output coverage, and update `server.spec.ts`, `plugin-apply.spec.ts`, and `built-scope-carrier.e2e.ts` for direct outcomes, ordering, overlap, shutdown, and the narrowed fake. Update `python/sdk/tests/test_client.py` for response-based settlement, unexpected-request-frame handling, callback and concurrency behavior, and the removed public helpers. Update the JSON-RPC and bilingual Python SDK READMEs, export JSDoc and declarations, `scripts/smoke-python-runtime.py`, and the Python single-executable snapshot. + ## Alternatives considered **Keep a generic symmetric JSON-RPC peer for future methods.** Server-initiated requests may eventually support interactive permissions, but no typed method or production consumer exists. The pre-release protocol can add the smallest required direction when that feature is designed instead of carrying an unexercised peer today. diff --git a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md index fb9bd06a98..4f3793f1b4 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-19-make-jsonrpc-directional.zh.md @@ -18,6 +18,14 @@ JSON-RPC 桥接层把两个端点都建模为对称的对等端,但实际协 在 `agent.whenIdle()` 完成后,由 `session/prompt` 直接返回 `{ status, reason }` 作为轮次结果。删除 `session.finished`、常量接纳响应以及 Python 中响应后的完成等待循环。`session.event` 与 subagent 通知仍在响应前流式发出,持久会话事件仍是最终响应重建的真源。 +## 实施计划 + +1. 在 `packages/ui/jsonrpc/src/server.ts` 中,用 `status: 'ok' | 'error' | 'aborted'` 和捕获的 `TurnEndReason` 替换 `SessionPromptResult.accepted`。`HarnessSdkServer.prompt()` 把 `completed` 映射为 `ok`,把 `aborted` 映射为 `aborted`,把其他当前或可合并扩展的原因映射为 `error`;进入空闲状态却没有 `turn/end` 仍视为不变量错误。只删除 `session.finished`,保持 `session.event`、`subagent.started` 和 `subagent.finished` 不变。 +2. 在 `packages/ui/jsonrpc/src/transport.ts` 中,用服务端通知接口替换 `JsonRpcTransportPeer`,并保留 `onRequest()`、`notify()`、`start()`、`flush()` 和 `close()`。删除生成的请求 ID、待处理响应表、出站 `request()`、入站响应与通知分发,以及关闭时对待处理请求的拒绝逻辑。入站响应结构和通知结构将被忽略;请求结果、方法不存在与处理器错误响应保持原有行为,并继续排在被等待处理器发出的通知之后。 +3. 在 `python/sdk/src/deepseek_harness/client.py`、`models.py` 和 `__init__.py` 中,删除 `IncomingRequest`、`_requests`、`notify()`、`next_request()`、`respond()` 和 `respond_error()`。新增公开且经过校验的 `SessionPromptResponse` 来携带状态与原因,由 `session_prompt()` 返回该对象,并保留明确的读取保护:忽略意外的服务端请求帧,避免它们命中响应等待器。 +4. 在 `python/sdk/src/deepseek_harness/api.py` 中,根据 `SessionPromptResponse` 构造 `TurnResult.status` 和新增的 `TurnResult.reason`,再删除 `session.finished` 分支与第二个完成循环。请求期间保持订阅打开,并保留 `_request_raw()` 最后的通知排空步骤,确保写在响应前的最后一条 `turn/end` 事件与任何 subagent 通知,都会在 `Session.run()` 重建最终助手消息之前被收集。 +5. 用原始客户端输入与服务端输出覆盖替换 `packages/ui/jsonrpc/tests/transport.spec.ts` 中的对称传输对用例,并更新 `server.spec.ts`、`plugin-apply.spec.ts` 和 `built-scope-carrier.e2e.ts`,覆盖直接结果、顺序、重叠、关闭和收窄后的伪实现。更新 `python/sdk/tests/test_client.py`,覆盖基于响应的结束流程、意外请求帧处理、回调与并发行为,以及已删除的公开辅助方法。同步更新 JSON-RPC README、双语 Python SDK README、导出 JSDoc 与声明、`scripts/smoke-python-runtime.py` 和 Python 单可执行文件快照。 + ## 备选方案 **为未来方法保留通用的对称 JSON-RPC 对等端。** 服务端发起的请求将来可能用于交互式权限,但当前没有类型化方法或生产消费方。该功能完成设计后,预发布协议可以增加所需的最小方向,无需提前保留未使用的对等端能力。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml index adee0c347d..185d7af4ac 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-use-one-session-surface-manager.md: f351c05cf5cb5d408aeb22c8c3e6ac5c575fc24a -2026-07-19-use-one-session-surface-manager.zh.md: 20c1f04f7b4197549f23145aa2c6015cd44f38cc +2026-07-19-use-one-session-surface-manager.md: 0e4fa14b21f5054e1ace425712506c5c060f2251 +2026-07-19-use-one-session-surface-manager.zh.md: 26126310eefaf56aa45efc428a54e65f0cf35947 diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md index f351c05cf5..0e4fa14b21 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md @@ -16,6 +16,14 @@ Keep one `SurfaceManager` per `Session`. Seed and append acceptance continue to Expose only the readonly surface contract from `Session.surface`; candidate validation remains owned by `Session`. Retain `foldSurface()` as the detached full-log replay function used by offline validation and reconstruction. +## Implementation plan + +1. In `packages/core/session/src/surface.ts`, export a structural `SessionSurface` contract containing only readonly `nodes` and `replaceGeneration`, and make `SurfaceManager` implement it. Re-export that type from `packages/core/session/src/index.ts` so `Session.surface` no longer exposes `validateNext()` through its declaration. +2. In `Session`, replace `surfaceValidator` and lazy `_surface` with one eagerly constructed `surfaceManager`. Route seed and append validation through that manager and return it from `get surface(): SessionSurface`; `deriveMessages()` will read the same nodes and generation. `validateNext()` may synchronize already committed log entries, but it must only plan the uncommitted candidate. The candidate reaches manager state after `log.push()` and the next delta synchronization, so rejection by surface validation or pre-commit `internal/dispatch` cannot leave phantom state. +3. Keep `foldSurface()` and the transition functions in `surface.ts` unchanged. Compile and exercise the direct consumers in `packages/compact/compact/src/tool-pairing.ts`, `packages/compact/compact-basic/src/region.ts`, and `packages/context/workspace-context/src/state.ts`; they continue to consume only nodes and replacement generation. +4. Extend `packages/core/session/tests/surface.spec.ts` to read the public view before an invalid candidate, prove that nodes and generation remain at the accepted prefix after rejection, append a later valid event, and compare every resulting prefix with `foldSurface()`. Add an `internal/dispatch` veto case and a type-level `SessionSurface` assertion in `session.spec.ts`, while retaining the seeded replay, delta-growth, replacement, generation, and derived-cache cases. +5. Run the request-reconstruction, compaction tool-pairing, compaction range, and workspace-context regression suites that consume the surface. In the implementation PR, update `packages/core/session/README.md`, `docs/core-data-structures/session.md`, the implemented session-surface RFC and its Chinese counterpart, the translation record, `scripts/type-equiv.manifest.json`, and the generated RFC index before moving this RFC pair to `implemented/`. + ## Alternatives considered **Keep acceptance and projection state separate.** Separate instances appear to isolate public reads from validation, but ordinary callers already receive borrowed surface state and cannot mutate it through the declared readonly contract. A cast that mutates the returned node array already corrupts derived history; duplicating the manager is not a sound runtime trust boundary. diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md index 20c1f04f7b..26126310ee 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md +++ b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md @@ -16,6 +16,14 @@ Status: proposed `Session.surface` 只公开只读表层契约,候选事件校验仍由 `Session` 负责。保留 `foldSurface()`,用于离线校验与重建时执行分离的完整日志回放。 +## 实施计划 + +1. 在 `packages/core/session/src/surface.ts` 中,导出结构化的 `SessionSurface` 契约,只包含只读的 `nodes` 与 `replaceGeneration`,并让 `SurfaceManager` 实现该契约。从 `packages/core/session/src/index.ts` 重新导出这个类型,使 `Session.surface` 的声明不再暴露 `validateNext()`。 +2. 在 `Session` 中,用一个主动创建的 `surfaceManager` 替换 `surfaceValidator` 与延迟创建的 `_surface`。种子事件与追加事件都通过该管理器校验,`get surface(): SessionSurface` 返回同一个对象,`deriveMessages()` 也读取同一份节点与代数。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只能制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验拒绝或提交前 `internal/dispatch` 否决都不会留下虚假状态。 +3. 保持 `foldSurface()` 与 `surface.ts` 中的状态转换函数不变。编译并验证 `packages/compact/compact/src/tool-pairing.ts`、`packages/compact/compact-basic/src/region.ts` 和 `packages/context/workspace-context/src/state.ts` 中的直接消费方;它们仍然只读取节点与替换代数。 +4. 扩展 `packages/core/session/tests/surface.spec.ts`:先读取公共视图,再提交无效候选事件,证明拒绝后节点与代数仍停留在已接纳前缀;随后追加有效事件,并把每个结果前缀与 `foldSurface()` 比较。在 `session.spec.ts` 中新增 `internal/dispatch` 否决用例与类型层面的 `SessionSurface` 断言,同时保留种子回放、增量增长、替换、代数和派生缓存用例。 +5. 运行消费表层的请求重建、压缩工具配对、压缩范围与工作区上下文回归套件。在实现 PR 中,先更新 `packages/core/session/README.md`、`docs/core-data-structures/session.md`、已实现会话表层 RFC 及其中文对应文件、翻译记录、`scripts/type-equiv.manifest.json` 和生成的 RFC 索引,再把本 RFC 双语文件移入 `implemented/`。 + ## 备选方案 **继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但普通调用方目前取得的就是借用的表层状态,无法通过声明的只读契约修改它。通过类型断言修改返回的节点数组,本就会破坏派生历史;复制管理器并不能构成可靠的运行时信任边界。 From bca74015130ed89295d8d83f33979c491a637854 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:12:26 +0800 Subject: [PATCH 153/163] docs: retain rejected compaction RFC --- docs/rfc/INDEX.md | 1 + ...19-fold-compaction-package-split.i18n.yaml | 6 +++ ...026-07-19-fold-compaction-package-split.md | 37 +++++++++++++++++++ ...-07-19-fold-compaction-package-split.zh.md | 37 +++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml create mode 100644 docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md create mode 100644 docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 4dc9b229ff..ec88f76c2d 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -233,6 +233,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune the unimplemented subagent seam vocabulary](rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md) | 2026-07-04 | | [Collapse workflows to the exercised foreground core](rejected/simplification/2026-07-12-collapse-workflow-to-foreground-core.md) | 2026-07-12 | | [Prune unused skill registry surface](rejected/simplification/2026-07-12-prune-unused-skill-registry-surface.md) | 2026-07-12 | +| [Fold the single compaction backend into its service package](rejected/simplification/2026-07-19-fold-compaction-package-split.md) | 2026-07-19 | ### Architecture diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml new file mode 100644 index 0000000000..d2a15cc2c2 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-19-fold-compaction-package-split.md: 7c7a2da85beb956f8d6c24f813fc33aa350b5c0e +2026-07-19-fold-compaction-package-split.zh.md: 37d75671d57226742608a525ea711b34780e65c6 diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md new file mode 100644 index 0000000000..7c7a2da85b --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.md @@ -0,0 +1,37 @@ +# RFC: Fold the single compaction backend into its service package + +Status: rejected — More compaction backends are planned, so the interface and basic implementation packages remain separate. + +English | [中文](2026-07-19-fold-compaction-package-split.zh.md) + +## Problem + +Compaction is split between `@deepseek-ai/dsh-compact`, which owns an abstract two-method service and shared types, and `@deepseek-ai/dsh-compact-basic`, which owns the only complete implementation. Shipped configurations load only the basic package, and no production package independently consumes the interface package except that implementation. + +The split adds a package manifest, README, project boundary, dependency edge, abstract forwarding class, generated catalog entries, and composition wiring without demonstrating backend substitution. The [capability-seam decision](../../implemented/architecture/2026-06-13-capability-seams.md) requires a real interface, implementation, and consumer rather than a preemptive split; the [compaction decision](../../implemented/feature/2026-06-18-compaction-capability-seam.md) records that its independent consumer was deferred. + +## Proposal + +Move the basic implementation into `@deepseek-ai/dsh-compact` and remove `@deepseek-ai/dsh-compact-basic`. Keep `ctx.compact`, `CompactionResult`, the shared transcript and tool-pairing helpers, the existing configuration, and the concrete compaction algorithm in one package. + +Preserve `summarize()` as a protected customization hook. A deployment-specific summarizer can subclass or intercept the existing LLM call without requiring a second capability package. Reintroduce an interface package only when a second complete backend and an independent consumer need substitution. + +Amend the implemented compaction decision and the [recallable-compaction proposal](../../proposed/feature/2026-07-06-recallable-compaction.md) if this proposal is accepted so package ownership has one durable description. + +## Alternatives considered + +**Keep the split because a remote or recall backend may arrive.** A possible future implementation does not justify the current package boundary. Recall adds a consumer of compaction results, not necessarily another implementation, and a remote summarizer can use the protected hook. + +**Move the implementation package name onto the interface package.** Keeping `compact-basic` as the surviving name would make the product service appear to be one optional backend. `compact` is the stable service identity already used by `ctx.compact` and is the clearer single-package owner. + +## Acceptance criteria + +- `@deepseek-ai/dsh-compact-basic` and its workspace/package metadata are removed. +- `@deepseek-ai/dsh-compact` owns the current configuration, plugin class, algorithm, types, events, and shared helpers. +- Existing deployments can load the surviving package with equivalent configuration and model-visible behavior. +- Automatic and manual compaction preserve cancellation, locking, token accounting, tool pairing, durable events, provenance, retry convergence, and transcript rendering. +- Loader composition, unit, runaway-turn, cancellation, snapshot, and real-model compaction tests pass; generated catalogs and module graphs are current. + +## Risks + +This is an intentional pre-release package-name contraction. Embedders loading `@deepseek-ai/dsh-compact-basic` must switch packages, and future backend substitution would require extracting a boundary again. The cost is acceptable only while one complete implementation exists; acceptance should be revisited if a second backend lands first. diff --git a/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md new file mode 100644 index 0000000000..37d75671d5 --- /dev/null +++ b/docs/rfc/rejected/simplification/2026-07-19-fold-compaction-package-split.zh.md @@ -0,0 +1,37 @@ +# RFC: 将唯一的压缩后端并入服务包 + +Status: rejected — 计划增加更多压缩后端,因此接口包与 basic 实现包继续分离。 + +[English](2026-07-19-fold-compaction-package-split.md) | 中文 + +## 问题 + +压缩(compaction)目前拆分在两个包中:`@deepseek-ai/dsh-compact` 拥有一个含两个方法的抽象服务和共享类型,`@deepseek-ai/dsh-compact-basic` 拥有唯一的完整实现。交付配置只加载 basic 包,除了该实现外,没有生产包独立消费接口包。 + +该拆分增加了一份包(package)manifest(元数据清单)、README、项目边界、依赖边、抽象转发类、生成目录项和组合接线,却没有体现后端替换需求。[能力服务边界决策](../../implemented/architecture/2026-06-13-capability-seams.md)要求接口、实现和消费方都必须真实存在,而不能预先拆分;[压缩决策](../../implemented/feature/2026-06-18-compaction-capability-seam.md)也记录了独立消费方仍被推迟。 + +## 提案 + +把 basic 实现移入 `@deepseek-ai/dsh-compact`,并删除 `@deepseek-ai/dsh-compact-basic`。`ctx.compact`、`CompactionResult`、共享 transcript(文本记录)和工具配对辅助方法、现有配置以及具体压缩算法都由一个包负责。 + +保留 `summarize()` 作为受保护的自定义钩子。部署专用的摘要器可以通过继承或拦截现有 LLM(大语言模型)调用完成定制,无需第二个能力包。只有在第二个完整后端与独立消费方确实需要替换实现时,才重新提取接口包。 + +如果本提案获准,应同步修订已实现的压缩决策与[可回忆压缩提案](../../proposed/feature/2026-07-06-recallable-compaction.md),使包所有权只有一处持久说明。 + +## 备选方案 + +**为可能出现的远程或回忆后端保留拆分。** 一种可能的未来实现不足以支撑当前包边界。回忆功能会增加压缩结果的消费方,但不一定增加另一种实现;远程摘要器也可以使用受保护钩子。 + +**让接口包并入实现包名。** 如果保留 `compact-basic` 作为最终名称,产品服务会看起来像一个可选后端。`compact` 已经是 `ctx.compact` 使用的稳定服务标识,更适合作为单包所有者。 + +## 验收标准 + +- 删除 `@deepseek-ai/dsh-compact-basic` 及其工作区和包元数据。 +- `@deepseek-ai/dsh-compact` 拥有当前配置、插件类、算法、类型、事件和共享辅助方法。 +- 现有部署可以使用等效配置加载保留的包,模型可见行为不变。 +- 自动压缩和手动压缩保留取消、锁、token 用量、工具配对、持久事件、来源、重试收敛和 transcript 渲染行为。 +- Loader 组合、单元、失控轮次、取消、快照和真实模型压缩测试全部通过;生成目录与模块图保持最新。 + +## 风险 + +这是一项有意实施的预发布包名收缩。加载 `@deepseek-ai/dsh-compact-basic` 的嵌入方必须切换包,未来的后端替换也需要重新提取边界。只有在仍然只有一个完整实现时,这项代价才可接受;如果第二个后端先行落地,应重新评估是否接纳本提案。 From 765dfb217414295a81e0609fa704ba64316e52ee Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:36:07 +0800 Subject: [PATCH 154/163] refactor(session): use one surface manager --- docs/cordis-catalog/events.md | 8 +-- docs/cordis-catalog/services.md | 2 +- docs/core-data-structures/session.md | 13 ++++- docs/event-producer-consumer.md | 8 +-- docs/rfc/INDEX.md | 2 +- .../2026-06-18-session-surface.md | 4 +- ...-use-one-session-surface-manager.i18n.yaml | 4 +- ...6-07-19-use-one-session-surface-manager.md | 37 ++++++++++++++ ...7-19-use-one-session-surface-manager.zh.md | 37 ++++++++++++++ ...6-07-19-use-one-session-surface-manager.md | 43 ---------------- ...7-19-use-one-session-surface-manager.zh.md | 43 ---------------- .../compact-basic/tests/compact-basic.spec.ts | 2 +- packages/core/session/README.md | 3 +- packages/core/session/src/index.ts | 32 +++++------- packages/core/session/src/surface.ts | 10 +++- packages/core/session/tests/session.spec.ts | 51 ++++++++++++++++++- packages/core/session/tests/surface.spec.ts | 15 +++++- scripts/type-equiv.manifest.json | 1 + website/zh-CN/api/harness/events.md | 8 +-- website/zh-CN/api/harness/sessions.md | 18 +++---- 20 files changed, 201 insertions(+), 140 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml (61%) create mode 100644 docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md create mode 100644 docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 9e74c728ad..1fc8f99af8 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -257,7 +257,7 @@ Creation announcement during session publication. A synchronous throw vetoes and 'session/created'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:46`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/src/index.ts) ### `session/disposed` — emit @@ -267,7 +267,7 @@ Emitted once when an announced session leaves the store, including publication r 'session/disposed'(this: Scoped, session: Session): void ``` -Source: [`packages/core/session/src/index.ts:56`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/src/index.ts) ### `session/event` — emit @@ -279,7 +279,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:68`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/src/index.ts) ### `session/flush` — parallel @@ -289,7 +289,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await 'session/flush'(this: Scoped, session: Session): Promise | void ``` -Source: [`packages/core/session/src/index.ts:78`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/src/index.ts) ## `subagent/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 3b854a4312..dd637444f0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -224,7 +224,7 @@ list(): Session[] fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` -Source: [`packages/core/session/src/index.ts:585`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/src/index.ts) ## `ctx.skills` — `SkillService` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 0e882bae33..b0aa7d4fc5 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -194,9 +194,20 @@ Required for `SurfaceEventType` events — every message-producing event must de The same provenance distinction applies here: only `assistant/message` may carry a present empty `sourceEventSeqs`; omission does not assert that its source stream was empty. +### `SessionSurface` — the live readonly surface projection + +`Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. + +```ts type-equiv +export interface SessionSurface { + readonly nodes: readonly number[] + readonly replaceGeneration: number +} +``` + ### `SurfaceFoldReplacement` and `SurfaceFoldResult` — a complete surface replay -`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. `SurfaceManager` uses the same transitions for its incremental cache without retaining replacement history. Its `replaceGeneration` increments for each replacement so incremental consumers can distinguish pure tail growth from a rewrite. +`foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite. ```ts type-equiv export interface SurfaceFoldReplacement { diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 655be6ecab..eaf56ee579 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -26,10 +26,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:70`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`) | [`fs-policy`](../packages/fs/fs-policy) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:53`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:40`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`invariants`](../packages/support/invariants), [`llm-replay`](../packages/support/llm-replay) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:46`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:56`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:68`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:78`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:47`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:57`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`session-persistence`](../packages/session-persistence/session-persistence) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:69`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/ui/acp), [`invariants`](../packages/support/invariants), [`jsonrpc`](../packages/ui/jsonrpc), [`session-persistence`](../packages/session-persistence/session-persistence), [`stdio`](../packages/ui/stdio), [`token-meter`](../packages/llm/token-meter), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:79`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence) | | `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:112`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`jsonrpc`](../packages/ui/jsonrpc) | | `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:86`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`tool-subagent`](../packages/subagent/tool-subagent) | | `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:92`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`tool-subagent`](../packages/subagent/tool-subagent) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ec88f76c2d..48774d96d7 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -23,7 +23,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 | | [Retire the standalone subagent mock package](proposed/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | -| [Use one surface manager per session](proposed/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | ### Architecture @@ -117,6 +116,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | | [Simplify session-log representation](implemented/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | +| [Use one surface manager per session](implemented/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md index 7c6c5b00d0..d639fe2917 100644 --- a/docs/rfc/implemented/architecture/2026-06-18-session-surface.md +++ b/docs/rfc/implemented/architecture/2026-06-18-session-surface.md @@ -31,7 +31,7 @@ export type SurfaceOp = ### SurfaceManager: delta-based, not full rebuild -A `SurfaceManager` class (private to `Session`) maintains one ordered `number[]` of event seqs. It tracks `_lastProcessedSeq` and processes only the new events since the last access rather than rescanning the entire log. Because the log is append-only, prior events never change; a seeded log is simply the initial suffix folded on first access. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no link objects or seq-to-node map duplicate the order. +A `Session` owns one `SurfaceManager` that maintains an ordered `number[]` of event seqs. The manager validates each seed or append candidate without applying it before commit, then processes only committed events since its previous synchronization rather than rescanning the entire log. `Session.surface` exposes the same manager through the readonly `SessionSurface` contract, so acceptance, derived history, compaction, and workspace context share one incremental state. Replace locates its inclusive endpoints by array position and splices the replacement seq into that range; no second manager, link objects, or seq-to-node map duplicates the order. Delta processing is O(1) when no new events and O(new events) when new events arrive. @@ -60,7 +60,7 @@ Every surface-eligible event must carry `surfaceOp` or it would disappear from d ## Consequences -- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array; `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). +- **`packages/core/session`**: `surface.ts` (`SurfaceManager`) maintains one ordered seq array for candidate acceptance and live projection; `SessionSurface` is its readonly public view. `SurfaceOp`/`SurfaceIntent` and the top-level session-event fields record how entries join it. `append()` requires a `SurfaceIntent` for surface events, `deriveMessages()` walks the surface as the sole derivation path, and `repair.ts` emits surface-aware closers. The seed constructor rejects a surface-eligible seed event missing its `surfaceOp` marker (see § Invariants). - **`packages/core/agent-loop`**: All surface-capable appends pass surface opts. Chunk seqs are collected for `assistant/message` provenance; `tool/call` seqs are captured for `tool/result` provenance. - **`packages/session-persistence/session-persistence-sqlite`**: Two new nullable TEXT columns (`source_event_seqs`, `surface_op`) on the `events` table; `SCHEMA_VERSION` bumped (bump-and-reject, no migration). - **`packages/support/invariants`**: Surface-related validation rules. diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml similarity index 61% rename from docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml rename to docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml index 185d7af4ac..55a113d22b 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-use-one-session-surface-manager.md: 0e4fa14b21f5054e1ace425712506c5c060f2251 -2026-07-19-use-one-session-surface-manager.zh.md: 26126310eefaf56aa45efc428a54e65f0cf35947 +2026-07-19-use-one-session-surface-manager.md: 0c0fd70717ff4b49e8817e88592d0781d9ccbd09 +2026-07-19-use-one-session-surface-manager.zh.md: 6d11c9bb475b0ee5a568f96678d0251f1160dda3 diff --git a/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md new file mode 100644 index 0000000000..0c0fd70717 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.md @@ -0,0 +1,37 @@ +# RFC: Use one surface manager per session + +Status: implemented + +English | [中文](2026-07-19-use-one-session-surface-manager.zh.md) + +## Problem + +`Session` maintained two `SurfaceManager` instances over the same append-only event log. One validated seed and append candidates, while a second lazy instance independently folded committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface had been read, every later event advanced duplicate node and replacement-generation state without creating a separate authority or failure boundary. + +## Decision + +Each `Session` owns one eagerly constructed `SurfaceManager`. Seed and append acceptance call `validateNext()` on that manager before committing an event, and `session.surface` returns the same object through this readonly contract: + +```ts +export interface SessionSurface { + readonly nodes: readonly number[] + readonly replaceGeneration: number +} +``` + +Candidate validation remains atomic. `validateNext()` may synchronize committed log entries, but it only plans the uncommitted candidate. The candidate enters manager state after `log.push()` and the next delta synchronization, so surface validation failures and pre-commit `internal/dispatch` vetoes leave no phantom node or replacement generation. + +`foldSurface()` remains the detached full-log replay function for offline validation and reconstruction. It uses the same transitions and agrees with the live manager for every committed prefix without sharing mutable state. + +## Alternatives considered + +**Keep acceptance and projection state separate.** Separate instances appeared to isolate public reads from validation, but callers already receive borrowed surface state and the declared readonly contract prevents ordinary mutation. Duplicating the manager was not a runtime trust boundary. + +**Recompute the public surface from the full log on every access.** This removed duplicate cached state but gave up incremental derivation and made repeated request construction scale with complete session history. + +## Consequences + +- Acceptance, `session.surface`, derived messages, compaction, and workspace context observe one incremental state. +- `Session.surface` exposes no validation method, while its object identity and borrowed readonly node array remain stable. +- A hostile cast can still corrupt borrowed state; JavaScript callers that deliberately bypass the readonly contract remain outside the supported same-process boundary. +- Surface, seed, dispatch-veto, request-reconstruction, compaction, and workspace-context tests exercise the shared manager and detached replay paths. diff --git a/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md new file mode 100644 index 0000000000..6d11c9bb47 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-use-one-session-surface-manager.zh.md @@ -0,0 +1,37 @@ +# RFC: 每个会话只使用一个表层管理器 + +Status: implemented + +[English](2026-07-19-use-one-session-surface-manager.md) | 中文 + +## 问题 + +`Session` 曾针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。一个实例负责校验种子事件和追加候选事件,另一个延迟创建的实例则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态,却没有形成独立真源或失败边界。 + +## 决策 + +每个 `Session` 主动创建并只持有一个 `SurfaceManager`。种子事件与追加事件的接纳流程在提交事件之前调用该管理器的 `validateNext()`,`session.surface` 则通过以下只读契约返回同一个对象: + +```ts +export interface SessionSurface { + readonly nodes: readonly number[] + readonly replaceGeneration: number +} +``` + +候选事件校验仍保持原子性。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验失败或提交前 `internal/dispatch` 否决都不会留下虚假节点或替换代数。 + +`foldSurface()` 仍是离线校验与重建使用的分离式完整日志回放函数。它使用相同的状态转换,并且对每个已提交前缀都与活跃管理器一致,但不共享可变状态。 + +## 备选方案 + +**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但调用方取得的本来就是借用的表层状态,声明的只读契约会阻止普通修改。复制管理器并不能构成运行时信任边界。 + +**每次读取都根据完整日志重新计算公共表层。** 该方案能消除重复缓存状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。 + +## 影响 + +- 接纳流程、`session.surface`、派生消息、压缩和工作区上下文观察同一份增量状态。 +- `Session.surface` 不暴露校验方法,同时保持对象标识和借用的只读节点数组稳定。 +- 恶意类型断言仍可破坏借用状态;刻意绕过只读契约的 JavaScript 调用方不属于受支持的同进程边界。 +- 表层、种子、调度否决、请求重建、压缩和工作区上下文测试覆盖共享管理器与分离回放路径。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md deleted file mode 100644 index 0e4fa14b21..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.md +++ /dev/null @@ -1,43 +0,0 @@ -# RFC: Use one surface manager per session - -Status: proposed - -English | [中文](2026-07-19-use-one-session-surface-manager.zh.md) - -## Problem - -`Session` maintains two `SurfaceManager` instances over the same append-only event log. `surfaceValidator` eagerly validates seed and append candidates, while the lazy `_surface` independently folds committed events for `session.surface`, derived messages, compaction, and workspace context. Once the public surface is read, every later event advances duplicate node and replacement-generation state. - -The [session surface decision](../../implemented/architecture/2026-06-18-session-surface.md) calls for one ordered surface and one representation to validate. The second manager does not create an independent authority or protect a different failure boundary; it repeats the canonical fold and gives the two views a state-drift opportunity. - -## Proposal - -Keep one `SurfaceManager` per `Session`. Seed and append acceptance continue to call `validateNext()` before committing an event, and the public surface view reads `nodes` and `replaceGeneration` from that same manager. - -Expose only the readonly surface contract from `Session.surface`; candidate validation remains owned by `Session`. Retain `foldSurface()` as the detached full-log replay function used by offline validation and reconstruction. - -## Implementation plan - -1. In `packages/core/session/src/surface.ts`, export a structural `SessionSurface` contract containing only readonly `nodes` and `replaceGeneration`, and make `SurfaceManager` implement it. Re-export that type from `packages/core/session/src/index.ts` so `Session.surface` no longer exposes `validateNext()` through its declaration. -2. In `Session`, replace `surfaceValidator` and lazy `_surface` with one eagerly constructed `surfaceManager`. Route seed and append validation through that manager and return it from `get surface(): SessionSurface`; `deriveMessages()` will read the same nodes and generation. `validateNext()` may synchronize already committed log entries, but it must only plan the uncommitted candidate. The candidate reaches manager state after `log.push()` and the next delta synchronization, so rejection by surface validation or pre-commit `internal/dispatch` cannot leave phantom state. -3. Keep `foldSurface()` and the transition functions in `surface.ts` unchanged. Compile and exercise the direct consumers in `packages/compact/compact/src/tool-pairing.ts`, `packages/compact/compact-basic/src/region.ts`, and `packages/context/workspace-context/src/state.ts`; they continue to consume only nodes and replacement generation. -4. Extend `packages/core/session/tests/surface.spec.ts` to read the public view before an invalid candidate, prove that nodes and generation remain at the accepted prefix after rejection, append a later valid event, and compare every resulting prefix with `foldSurface()`. Add an `internal/dispatch` veto case and a type-level `SessionSurface` assertion in `session.spec.ts`, while retaining the seeded replay, delta-growth, replacement, generation, and derived-cache cases. -5. Run the request-reconstruction, compaction tool-pairing, compaction range, and workspace-context regression suites that consume the surface. In the implementation PR, update `packages/core/session/README.md`, `docs/core-data-structures/session.md`, the implemented session-surface RFC and its Chinese counterpart, the translation record, `scripts/type-equiv.manifest.json`, and the generated RFC index before moving this RFC pair to `implemented/`. - -## Alternatives considered - -**Keep acceptance and projection state separate.** Separate instances appear to isolate public reads from validation, but ordinary callers already receive borrowed surface state and cannot mutate it through the declared readonly contract. A cast that mutates the returned node array already corrupts derived history; duplicating the manager is not a sound runtime trust boundary. - -**Recompute the public surface from the full log on every access.** This removes cached duplicate state but gives up incremental derivation and makes repeated request construction scale with complete session history. - -## Acceptance criteria - -- A live `Session` owns exactly one incremental `SurfaceManager`. -- Seed and append candidates are validated before publication with no partial surface mutation on rejection. -- `session.surface`, derived messages, compaction, and workspace context observe the same nodes and replacement generation as the acceptance path. -- `foldSurface()` remains available for detached replay and agrees with the live manager for every accepted prefix. -- Session surface, seed, request reconstruction, compaction tool-pairing, and workspace-context tests pass. - -## Risks - -Sharing one manager makes the readonly borrowed-state contract more important because a hostile cast could corrupt both validation and projection state. The implementation should return a narrowed view and keep mutation methods inaccessible through `Session.surface`; JavaScript callers that deliberately bypass the type contract remain outside the supported same-process boundary. diff --git a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md b/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md deleted file mode 100644 index 26126310ee..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-use-one-session-surface-manager.zh.md +++ /dev/null @@ -1,43 +0,0 @@ -# RFC: 每个会话只使用一个表层管理器 - -Status: proposed - -[English](2026-07-19-use-one-session-surface-manager.md) | 中文 - -## 问题 - -`Session` 针对同一份仅追加事件日志维护两个 `SurfaceManager` 实例。`surfaceValidator` 主动校验种子事件与追加候选事件,延迟创建的 `_surface` 则独立折叠已提交事件,供 `session.surface`、派生消息、压缩(compaction)和工作区上下文使用。一旦读取公共表层,之后的每个事件都会推进两份重复的节点状态与替换代数状态。 - -[会话表层决策](../../implemented/architecture/2026-06-18-session-surface.md)要求系统只保留一个有序表层,并使用一种表示完成校验。第二个管理器既不形成独立真源,也不保护不同的失败边界;它只会重复规范折叠,并使两个视图可能出现状态偏差。 - -## 提案 - -每个 `Session` 只保留一个 `SurfaceManager`。种子事件与追加事件的接纳流程仍在提交事件之前调用 `validateNext()`,公共表层视图则从同一个管理器读取 `nodes` 与 `replaceGeneration`。 - -`Session.surface` 只公开只读表层契约,候选事件校验仍由 `Session` 负责。保留 `foldSurface()`,用于离线校验与重建时执行分离的完整日志回放。 - -## 实施计划 - -1. 在 `packages/core/session/src/surface.ts` 中,导出结构化的 `SessionSurface` 契约,只包含只读的 `nodes` 与 `replaceGeneration`,并让 `SurfaceManager` 实现该契约。从 `packages/core/session/src/index.ts` 重新导出这个类型,使 `Session.surface` 的声明不再暴露 `validateNext()`。 -2. 在 `Session` 中,用一个主动创建的 `surfaceManager` 替换 `surfaceValidator` 与延迟创建的 `_surface`。种子事件与追加事件都通过该管理器校验,`get surface(): SessionSurface` 返回同一个对象,`deriveMessages()` 也读取同一份节点与代数。`validateNext()` 可以同步已提交的日志事件,但对尚未提交的候选事件只能制定变更计划。候选事件在 `log.push()` 之后、下一次增量同步时才进入管理器状态,因此表层校验拒绝或提交前 `internal/dispatch` 否决都不会留下虚假状态。 -3. 保持 `foldSurface()` 与 `surface.ts` 中的状态转换函数不变。编译并验证 `packages/compact/compact/src/tool-pairing.ts`、`packages/compact/compact-basic/src/region.ts` 和 `packages/context/workspace-context/src/state.ts` 中的直接消费方;它们仍然只读取节点与替换代数。 -4. 扩展 `packages/core/session/tests/surface.spec.ts`:先读取公共视图,再提交无效候选事件,证明拒绝后节点与代数仍停留在已接纳前缀;随后追加有效事件,并把每个结果前缀与 `foldSurface()` 比较。在 `session.spec.ts` 中新增 `internal/dispatch` 否决用例与类型层面的 `SessionSurface` 断言,同时保留种子回放、增量增长、替换、代数和派生缓存用例。 -5. 运行消费表层的请求重建、压缩工具配对、压缩范围与工作区上下文回归套件。在实现 PR 中,先更新 `packages/core/session/README.md`、`docs/core-data-structures/session.md`、已实现会话表层 RFC 及其中文对应文件、翻译记录、`scripts/type-equiv.manifest.json` 和生成的 RFC 索引,再把本 RFC 双语文件移入 `implemented/`。 - -## 备选方案 - -**继续分离接纳状态与投影视图。** 两个独立实例看似能够隔离公共读取和校验,但普通调用方目前取得的就是借用的表层状态,无法通过声明的只读契约修改它。通过类型断言修改返回的节点数组,本就会破坏派生历史;复制管理器并不能构成可靠的运行时信任边界。 - -**每次读取都根据完整日志重新计算公共表层。** 该方案不再缓存重复状态,但会放弃增量派生,使每次请求构造都随完整会话历史增长。 - -## 验收标准 - -- 每个活跃 `Session` 只拥有一个增量 `SurfaceManager`。 -- 种子事件与追加候选事件都在发布前完成校验,拒绝事件时不会留下只修改一半的表层状态。 -- `session.surface`、派生消息、压缩和工作区上下文观察到的节点与替换代数,和接纳路径使用的状态完全一致。 -- `foldSurface()` 仍可用于分离回放,并且对任意已接纳前缀都与活跃管理器一致。 -- 会话表层、种子、请求重建、压缩工具配对和工作区上下文测试全部通过。 - -## 风险 - -共享一个管理器会提高只读借用状态契约的重要性,因为恶意类型断言可能同时破坏校验状态和投影视图。实现应返回收窄后的视图,避免通过 `Session.surface` 暴露修改方法;刻意绕过类型契约的 JavaScript 调用方不属于受支持的同进程边界。 diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index caaac783b6..01ce91a4e9 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -353,7 +353,7 @@ describe('compaction region transaction', () => { it('lands a framed, replayable checkpoint with exact pricing provenance', async () => { const compact = service() const session = conversation(3) - const before = session.surface.nodes + const before = [...session.surface.nodes] const result = await compact.compactRegion( before[0]!, before[3]!, diff --git a/packages/core/session/README.md b/packages/core/session/README.md index 46b6ecb703..ba4e8ea94a 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -35,7 +35,7 @@ Plain class (not a Cordis Service). Create via `ctx.sessions.create()`. - `session.append(type, data, opts?)` snapshots and freezes durable data and surface metadata, validates marker shape, provenance, and complete replacement coverage, commits synchronously, then notifies observers with independent failure containment. Reentrant attached-session appends reject, and runtime checks cover widened unions and loaded logs. - `session.deriveMessages()` incrementally projects each new surface entry once and returns a fresh array over shared frozen messages. Assistant projections preserve provider/model provenance and adapter-private replay state. A surface rewrite rebuilds the projection; there is no raw-log fallback. - `session.deriveEventMessage(event)` is the canonical per-event projection used by reconstruction and invariants. -- `session.surface` lazily folds only new `surfaceOp` markers; `replaceGeneration` changes on every rewrite. +- `session.surface` exposes the readonly `SessionSurface` view owned by the session's single incremental surface manager; `replaceGeneration` changes on every committed rewrite. - `session.events` is a cached frozen snapshot invalidated by append; accepted events remain deeply frozen. - `session.seq`, `session.id` — current sequence and readonly typed identity. - `session.header: SessionHeader` — detached, deep-frozen creation metadata (`version`, `id`, `createdAt`, optional `cwd`/`parentSession`/`seedLength`). Construction validates the durable record and requires its id to match `session.id`. @@ -48,6 +48,7 @@ Durable values need one accepted representation, not a check followed by a secon - `SurfaceOp` — how an event entered the ordered surface: `'append'` (normal tail append) or `{ op: 'replace', start, end }` (replace entries from `start` through `end` inclusive — both must be valid surface seqs; `start === end` replaces one entry). Used by compaction to shadow old events without deleting them. - `SurfaceIntent` — `{ surfaceOp: SurfaceOp; sourceEventSeqs?: number[] }`, the required third parameter to `session.append()` for surface-eligible types. +- `SessionSurface` — the readonly live `nodes` and `replaceGeneration` projection exposed by `session.surface`; candidate validation remains private to `Session`. - `foldSurface(events)` — replay the canonical surface contract into detached current event sequences and actual replacement ranges. The same pass rejects non-contiguous seqs, misplaced or malformed metadata, empty or duplicate provenance, non-earlier sources, invalid positional ranges, and replacements that fail to cite every shadowed surface entry; `SurfaceManager` shares the atomic transition while retaining only its incremental sequence cache. - `isSurfaceEvent(event)` / `isSurfaceEligibleType(type)` — the first narrows a `SessionEvent` to a fully formed surface event; the second detects a surface-eligible event missing its marker when validating a seed or loaded log. diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index ef84a6bfbc..b8b76ec2ed 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -16,13 +16,14 @@ import { SESSION_FORMAT_VERSION, SessionId } from './types.ts' import type { ContextEnvelope, CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts' import { snapshotJsonValue } from './json.ts' import { SurfaceManager } from './surface.ts' +import type { SessionSurface } from './surface.ts' import { foldRequestHeader } from './request-header.ts' export * from './types.ts' export { isJsonValue, snapshotJsonValue } from './json.ts' export type { JsonValue } from './json.ts' export { interruptedTurnClosers } from './repair.ts' -export type { SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' +export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' @@ -251,22 +252,12 @@ export function renderContextContent( */ export class Session { private log: SessionEvent[] = [] - /** Incremental acceptance state, kept separate from the public lazy view. */ - private readonly surfaceValidator = new SurfaceManager(this.log) - - /** - * Derived surface — a cached order of message-producing event sequences. - * Lazily rebuilt from `surfaceOp` markers in the log; processes only new - * events (delta) on each access — the log is append-only, so prior events - * never change. - * Undefined until first accessed (including after fork/seed). - */ - private _surface: SurfaceManager | undefined + /** Single incremental owner of surface acceptance and projection state. */ + private readonly surfaceManager = new SurfaceManager(this.log) /** The ordered surface over this session's event log. */ - get surface(): SurfaceManager { - if (!this._surface) this._surface = new SurfaceManager(this.log) - return this._surface + get surface(): SessionSurface { + return this.surfaceManager } /** @@ -309,7 +300,7 @@ export class Session { // live append and a full-log fold. The candidate is planned before it // enters `log`, so a failure cannot partially mutate the surface. try { - this.surfaceValidator.validateNext(snapshot) + this.surfaceManager.validateNext(snapshot) } catch (error: unknown) { throw new Error(`invalid seed event at index ${index}: ${error instanceof Error ? error.message : 'invalid surface metadata'}`) } @@ -402,7 +393,7 @@ export class Session { data: dataSnapshot, ...(surfaceMetadataSnapshot as { surfaceOp?: unknown; sourceEventSeqs?: unknown }), } as unknown as SessionEvent) - this.surfaceValidator.validateNext(event as SessionEvent) + this.surfaceManager.validateNext(event as SessionEvent) if (entry !== undefined) entry.appending = true try { @@ -468,7 +459,7 @@ export class Session { * * CACHED: each surface node is projected exactly once, when first seen — a * call costs O(new nodes), and a surface rewrite (a `replace`; - * {@link SurfaceManager.replaceGeneration}) rebuilds. The returned array is + * {@link SessionSurface.replaceGeneration}) rebuilds. The returned array is * a fresh snapshot per call (later appends never grow an array a caller * already holds); the `Message` objects in it are SHARED and **deep-frozen**. * Their content reuses the already frozen durable event data, so the cache @@ -476,8 +467,9 @@ export class Session { * @returns a fresh array of the shared, frozen derived history. */ deriveMessages(): Message[] { - const nodes = this.surface.nodes - const generation = this.surface.replaceGeneration + const surface = this.surface + const nodes = surface.nodes + const generation = surface.replaceGeneration if (generation !== this.derivedGeneration) { this.derived = [] this.derivedNodes = 0 diff --git a/packages/core/session/src/surface.ts b/packages/core/session/src/surface.ts index d4f43bc303..cf03ae685d 100644 --- a/packages/core/session/src/surface.ts +++ b/packages/core/session/src/surface.ts @@ -55,6 +55,14 @@ export interface SurfaceFoldResult { replacements: SurfaceFoldReplacement[] } +/** Readonly live projection of the message-producing session events. */ +export interface SessionSurface { + /** Current surface event sequences in model-visible order. */ + readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ + readonly replaceGeneration: number +} + /** Mutable state shared by complete and incremental folds. */ interface SurfaceFoldState { nodes: number[] @@ -244,7 +252,7 @@ export function foldSurface(events: readonly SessionEvent[]): SurfaceFoldResult } /** Incremental ordered surface view and append-boundary validator. */ -export class SurfaceManager { +export class SurfaceManager implements SessionSurface { /** Shared transition state; replacement history is not retained. */ private _state = createFoldState() /** Last processed seq; -1 folds a seeded log on first access. */ diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index 16a74283be..3b15157bde 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,10 +1,18 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' -import type { CreateSessionOptions, SessionEventType, SessionHeader, TodoItem } from '@deepseek-ai/dsh-session' +import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session' describe('Session', () => { + it('exposes one stable readonly surface view', () => { + const session = new Session(SessionId('surface-view')) + const surface = session.surface + + expectTypeOf(surface).toEqualTypeOf() + expect(surface).toBe(session.surface) + }) + it('derives message history from the event log', () => { const session = new Session(SessionId('s1')) session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -1031,6 +1039,45 @@ describe('SessionStore', () => { expect(observed).toEqual([appended]) }) + it('does not publish a surface transition rejected by internal dispatch', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('surface-dispatch-veto')) + session.append('user/message', { + content: [{ type: 'text', text: 'source' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + const surface = session.surface + let reject = true + ctx.on('internal/dispatch', (_mode, name) => { + if (name === 'session/event' && reject) { + reject = false + throw new Error('reject surface candidate') + } + }) + + expect(() => session.append('assistant/message', { + provenance: { provider: 'mock', model: 'mock' }, + turn: 1, + step: 1, + content: [{ type: 'text', text: 'replacement' }], + }, { + surfaceOp: { op: 'replace', start: 0, end: 0 }, + sourceEventSeqs: [0], + })).toThrow('reject surface candidate') + + expect(session.events).toHaveLength(1) + expect(surface.nodes).toEqual([0]) + expect(surface.replaceGeneration).toBe(0) + + session.append('user/message', { + content: [{ type: 'text', text: 'next' }], + source: { kind: 'user' }, + }, { surfaceOp: 'append' }) + expect(surface.nodes).toEqual([0, 1]) + expect(surface.replaceGeneration).toBe(0) + }) + it('resolves session/event dispatch before commit so instrumentation failure cannot hide a logged event', async () => { const ctx = new Context() await ctx.plugin(SessionStore) diff --git a/packages/core/session/tests/surface.spec.ts b/packages/core/session/tests/surface.spec.ts index fe222f8f2f..d239533476 100644 --- a/packages/core/session/tests/surface.spec.ts +++ b/packages/core/session/tests/surface.spec.ts @@ -142,6 +142,11 @@ describe('SurfaceManager', () => { it('leaves incremental state unchanged when candidate validation fails', () => { const s = new Session(SessionId('atomic-validation')) s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const surface = s.surface + const nodes = surface.nodes + + expect(nodes).toEqual(foldSurface(s.events).nodes) + expect(surface.replaceGeneration).toBe(0) expect(() => s.append( 'assistant/message', @@ -150,8 +155,16 @@ describe('SurfaceManager', () => { )).toThrow(/missing 0/) expect(s.events).toHaveLength(1) + expect(s.surface).toBe(surface) + expect(surface.nodes).toEqual([0]) + expect(surface.replaceGeneration).toBe(0) + expect(surface.nodes).toEqual(foldSurface(s.events).nodes) + s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - expect(s.surface.nodes).toEqual([0, 1]) + expect(surface.nodes).toBe(nodes) + expect(surface.nodes).toEqual([0, 1]) + expect(surface.replaceGeneration).toBe(0) + expect(surface.nodes).toEqual(foldSurface(s.events).nodes) }) it('foldSurface rejects a surface-eligible event without its mandatory marker', () => { diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index c16245f7cf..cfa345ebdc 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -47,6 +47,7 @@ { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceEventType", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceOp", "source": "packages/core/session/src/types.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceIntent", "source": "packages/core/session/src/types.ts" }, + { "doc": "docs/core-data-structures/session.md", "symbol": "SessionSurface", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldReplacement", "source": "packages/core/session/src/surface.ts" }, { "doc": "docs/core-data-structures/session.md", "symbol": "SurfaceFoldResult", "source": "packages/core/session/src/surface.ts" }, diff --git a/website/zh-CN/api/harness/events.md b/website/zh-CN/api/harness/events.md index 976629fedb..8d494739ba 100644 --- a/website/zh-CN/api/harness/events.md +++ b/website/zh-CN/api/harness/events.md @@ -324,7 +324,7 @@ Creation announcement during session publication. A synchronous throw vetoes and - `session` — the session just entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L46) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L47) ### session/disposed @@ -338,7 +338,7 @@ Emitted once when an announced session leaves the store, including publication r - `session` — the session that is no longer live in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L56) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L57) ### session/event @@ -353,7 +353,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before - `session` — the session whose log grew. - `event` — the appended event, exactly as recorded. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L68) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L69) ### session/flush @@ -367,7 +367,7 @@ Awaited parallel durability checkpoint: every listener runs and the caller await - `session` — the session whose buffered events must reach durable storage. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L78) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L79) ## subagent/* diff --git a/website/zh-CN/api/harness/sessions.md b/website/zh-CN/api/harness/sessions.md index e39006dd88..712852fc49 100644 --- a/website/zh-CN/api/harness/sessions.md +++ b/website/zh-CN/api/harness/sessions.md @@ -7,7 +7,7 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L585) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L577) ### ctx.sessions.create(id?, options?) @@ -23,7 +23,7 @@ For an agent whose session must be torn down IN ORDER with its loop (so the loop **Returns** the live session, already entered and announced. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L614) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L606) ### ctx.sessions.prepare(id?, options?) @@ -38,7 +38,7 @@ Build a session WITHOUT entering it into the store — validate the id/cwd and c **Returns** the constructed session, NOT yet in the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L643) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L635) ### ctx.sessions.enter(session) @@ -53,7 +53,7 @@ Re-checks the id for a duplicate: `prepare` and `enter` are public cross-package **Returns** the detach disposer (publication hooks + store removal). When called from a synchronous `session/created` listener, removal and disposal wait until that creation dispatch unwinds. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L687) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L679) ### ctx.sessions.announce(session) @@ -65,7 +65,7 @@ Emit `session/created` exactly once for an entered session (with the carrier ent - `session` — the entered session to announce to listeners. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L742) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L734) ### ctx.sessions.flush(session) @@ -79,7 +79,7 @@ Dispatch the awaited `session/flush` durability checkpoint for `session`, with t **Returns** resolves when every flush listener has settled; after all settle, rejects with the first registered listener failure if any listener failed. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L794) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L786) ### ctx.sessions.get(id) @@ -93,7 +93,7 @@ Look up a live session. **Returns** the session, or undefined when no live session has that id. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L818) ### ctx.sessions.list() @@ -105,7 +105,7 @@ All live sessions, in creation order. **Returns** a fresh array; mutating it does not affect the store. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L834) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L826) ### ctx.sessions.fork(source, boundary?, childSessionId?) @@ -121,4 +121,4 @@ Create a live child session from a turn-enclosed prefix of a live source. `bound **Returns** The created live child session. -[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L851) +[Source](https://github.com/deepseek-harness/deepseek-harness/blob/master/packages/core/session/src/index.ts#L843) From 85fc58943e34a1d207cde9e2343ba80b9b5afb72 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:37:41 +0800 Subject: [PATCH 155/163] docs(session-persistence): tighten SQLite permission prose --- docs/config-catalog.md | 11 ++++++----- .../session-persistence-sqlite/README.md | 4 +++- .../session-persistence-sqlite/src/index.ts | 16 +++++++++------- .../tests/sqlite.spec.ts | 4 ++-- 4 files changed, 20 insertions(+), 15 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a442b5a197..6069263b8e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -656,10 +656,11 @@ Requires: `sessions` export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). Missing directories and the database - * are created with owner-only permissions; existing path modes are preserved. - * Parent directories writable by another principal are outside the backend's - * database-integrity boundary. + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing database + * fail initialization. The backend does not protect integrity when another + * principal can replace the database entry in its parent directory. */ path: string /** @@ -682,7 +683,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:53`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:54`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 17713dda8f..602bd4e33f 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -10,7 +10,9 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). On filesystems with POSIX modes, missing directories are created as `0700` and a missing database is exclusively created as `0600` before SQLite opens it, causing new WAL sidecars to inherit owner-only access. Existing directories, database files, and sidecars keep their modes; ordinary filesystem access errors still fail initialization. This default prevents incidental exposure through the process umask; it does not protect database integrity when another principal can modify entries in an existing parent directory. `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. +The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. + +On filesystems with POSIX modes, the backend creates missing directories as `0700` and exclusively creates a missing database as `0600` before SQLite opens it. New WAL sidecars receive the database's owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through the process umask, but do not protect database integrity when another principal can replace the database entry in its parent directory. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 4203f38a87..3515309f9a 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -36,9 +36,10 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { } /** - * Create a missing database owner-only while preserving an existing file's - * mode. `DatabaseSync` cannot adopt this handle, so a parent directory writable - * by another principal is outside the backend's database-integrity boundary. + * Exclusively create a missing database file with owner-only permissions. + * Existing files retain their modes, and errors other than `EEXIST` propagate. + * `DatabaseSync` reopens by path, so this does not protect integrity when + * another principal can replace the database entry in its parent directory. */ async function createDatabaseFile(path: string): Promise { try { @@ -53,10 +54,11 @@ async function createDatabaseFile(path: string): Promise { export interface Config { /** * Filesystem path to the SQLite database file. The special value `:memory:` - * opens an in-process database (tests). Missing directories and the database - * are created with owner-only permissions; existing path modes are preserved. - * Parent directories writable by another principal are outside the backend's - * database-integrity boundary. + * opens an in-process database (tests). On filesystems with POSIX modes, + * missing directories and databases are created owner-only; existing path + * modes are preserved. Filesystem setup errors other than an existing database + * fail initialization. The backend does not protect integrity when another + * principal can replace the database entry in its parent directory. */ path: string /** diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index a07bcb7913..4a6b141e04 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -390,7 +390,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) describe('SessionPersistenceSqlite: edge cases', () => { - it('creates a new database and WAL sidecars owner-only without changing an existing directory mode', async () => { + it('creates a new database and WAL sidecars with owner-only modes without changing its parent mode', async () => { if (process.platform === 'win32') return const path = await freshDbPath() const dir = dirname(path) @@ -421,7 +421,7 @@ describe('SessionPersistenceSqlite: edge cases', () => { await fiber.dispose() }) - it('surfaces database pre-creation errors independently of process privileges', async () => { + it('surfaces an invalid database path during pre-creation', async () => { const path = await freshDbPath() const b = await backend(`${path}\0`) From 176973cb7b618a82eec4d24f7497352ea5da4acc Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:51:22 +0800 Subject: [PATCH 156/163] docs(session-persistence): clarify SQLite permission limits --- docs/config-catalog.md | 7 ++++--- .../session-persistence-sqlite/README.md | 2 +- .../session-persistence-sqlite/src/index.ts | 10 ++++++---- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6069263b8e..05fa3eab67 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -659,8 +659,9 @@ export interface Config { * opens an in-process database (tests). On filesystems with POSIX modes, * missing directories and databases are created owner-only; existing path * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect integrity when another - * principal can replace the database entry in its parent directory. + * fail initialization. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. */ path: string /** @@ -683,7 +684,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:54`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) +Source: [`packages/session-persistence/session-persistence-sqlite/src/index.ts:55`](../packages/session-persistence/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-query` diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 602bd4e33f..e9066d3748 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -12,7 +12,7 @@ Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. -On filesystems with POSIX modes, the backend creates missing directories as `0700` and exclusively creates a missing database as `0600` before SQLite opens it. New WAL sidecars receive the database's owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through the process umask, but do not protect database integrity when another principal can replace the database entry in its parent directory. +On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 3515309f9a..4661b41309 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -38,8 +38,9 @@ function surfaceBindings(event: SessionEvent): [string | null, string | null] { /** * Exclusively create a missing database file with owner-only permissions. * Existing files retain their modes, and errors other than `EEXIST` propagate. - * `DatabaseSync` reopens by path, so this does not protect integrity when - * another principal can replace the database entry in its parent directory. + * `DatabaseSync` reopens by path, so this does not protect confidentiality or + * integrity when another principal can replace the database entry in its parent + * directory. */ async function createDatabaseFile(path: string): Promise { try { @@ -57,8 +58,9 @@ export interface Config { * opens an in-process database (tests). On filesystems with POSIX modes, * missing directories and databases are created owner-only; existing path * modes are preserved. Filesystem setup errors other than an existing database - * fail initialization. The backend does not protect integrity when another - * principal can replace the database entry in its parent directory. + * fail initialization. The backend does not protect confidentiality or + * integrity when another principal can replace the database entry in its + * parent directory. */ path: string /** From 4dda5b953b5cb3b2de24e964c6f9da6a8c9e1911 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 11:54:37 +0800 Subject: [PATCH 157/163] refactor(subagent): retire standalone mock package --- docs/capability-seams.md | 4 +- docs/config-catalog.md | 35 ----- docs/module-graph.md | 6 - docs/rfc/INDEX.md | 2 +- .../2026-06-21-subagent-capability-seam.md | 5 +- ...-19-retire-subagent-mock-package.i18n.yaml | 4 +- ...2026-07-19-retire-subagent-mock-package.md | 34 +++++ ...6-07-19-retire-subagent-mock-package.zh.md | 34 +++++ ...2026-07-19-retire-subagent-mock-package.md | 35 ----- ...6-07-19-retire-subagent-mock-package.zh.md | 35 ----- ...prune-unimplemented-subagent-vocabulary.md | 6 +- packages/subagent/README.md | 2 +- packages/subagent/tool-subagent/package.json | 1 - .../tests/scripted-provider.spec.ts | 98 ++++++++++++++ .../tool-subagent/tests/scripted-provider.ts | 104 ++++++++++++++ .../tool-subagent/tests/tool-subagent.spec.ts | 31 +++-- packages/support/README.md | 3 +- packages/support/subagent-mock/README.md | 29 ---- packages/support/subagent-mock/package.json | 42 ------ packages/support/subagent-mock/src/index.ts | 127 ------------------ .../subagent-mock/tests/subagent-mock.spec.ts | 122 ----------------- packages/support/subagent-mock/tsconfig.json | 30 ----- pnpm-lock.yaml | 28 ---- scripts/gen-doc-graphs.ts | 2 +- scripts/gen-tool-catalog.ts | 18 ++- .../verify-package-readme-model-experience.ts | 1 - tsconfig.build.json | 1 - tsconfig.json | 1 - 28 files changed, 311 insertions(+), 529 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml (62%) create mode 100644 docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md create mode 100644 docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md create mode 100644 packages/subagent/tool-subagent/tests/scripted-provider.spec.ts create mode 100644 packages/subagent/tool-subagent/tests/scripted-provider.ts delete mode 100644 packages/support/subagent-mock/README.md delete mode 100644 packages/support/subagent-mock/package.json delete mode 100644 packages/support/subagent-mock/src/index.ts delete mode 100644 packages/support/subagent-mock/tests/subagent-mock.spec.ts delete mode 100644 packages/support/subagent-mock/tsconfig.json diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 1aafbb2817..03239998b8 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -77,7 +77,6 @@ flowchart LR pkg_subagent_spawn["subagent-spawn"] pkg_subagent_fork["subagent-fork"] pkg_subagent_acp["subagent-acp"] - pkg_subagent_mock["subagent-mock"] pkg_tasks["tasks"] svc_tasks["ctx.tasks
Background task registry"] pkg_tool_tasks["tool-tasks"] @@ -129,7 +128,6 @@ flowchart LR pkg_subagent --> svc_subagents pkg_subagent_acp --> svc_subagents pkg_subagent_fork --> svc_subagents - pkg_subagent_mock --> svc_subagents pkg_subagent_spawn --> svc_subagents pkg_system_prompt --> svc_systemPrompt pkg_tasks --> svc_tasks @@ -224,7 +222,7 @@ flowchart LR | `ctx.codeRuntime` | `seam` | [`code-runtime`](../packages/code-runtime/code-runtime) | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | [`tools`](../packages/core/tools) | - | Runs one model-written program against host-provided async bindings; backends differ by substrate and language (the tool registry consumes it for Code Mode). | | `ctx.fs` | `seam` | [`fs`](../packages/fs/fs) | [`fs-local`](../packages/fs/fs-local) | [`tool-fs`](../packages/fs/tool-fs) | [`fs-policy`](../packages/fs/fs-policy) | tool-fs executes read/write/edit through ctx.fs; fs-policy contributes observed-state checks through the fs/* event gate. | | `ctx.compact` | `seam` | [`compact`](../packages/compact/compact) | [`compact-basic`](../packages/compact/compact-basic) | [`compact-basic`](../packages/compact/compact-basic) | - | The basic backend currently consumes the pre-step event directly; a model-facing compact tool remains deferred. | -| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-mock`](../packages/support/subagent-mock) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | +| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn`](../packages/subagent/subagent-spawn), [`subagent-fork`](../packages/subagent/subagent-fork), [`subagent-acp`](../packages/subagent/subagent-acp) | [`tool-subagent`](../packages/subagent/tool-subagent) | - | Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name. | | `ctx.tasks` | `core` | [`tasks`](../packages/tasks/tasks) | - | [`tool-bash`](../packages/bash/tool-bash), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-tasks`](../packages/tasks/tool-tasks) | - | Producers (tool-bash background commands, tool-subagent background delegations) register running work; tool-tasks is the model-facing control surface that reads, lists, and kills it. | | `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-local`](../packages/web/web-fetch-local) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. | | `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 30c448ba03..69704c2aa2 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -890,41 +890,6 @@ export interface Config { Source: [`packages/subagent/subagent-fork/src/index.ts:25`](../packages/subagent/subagent-fork/src/index.ts) -## `@deepseek-ai/dsh-subagent-mock` - -Requires: `subagents` - -```ts config-catalog -/** Config for the mock provider; all optional with test-friendly defaults. */ -export interface Config { - /** Registry name to register under. */ - name: string - /** The text the scripted child "returns" as its final answer. */ - reply?: string - /** The stop reason the run settles with. */ - stopReason?: SubagentStopReason - /** Which start-time capabilities to advertise (default: all `true`). */ - capabilities?: Partial - /** - * The conversation-history descriptor to declare - * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh - * conversation). Set `true` to exercise seeded/fork wording in consumer - * tests. This flag says nothing about tool, service, scope, or authority - * inheritance. - */ - inheritsParentContext?: boolean - /** - * Structured value surfaced when a request carries an `outputSchema` and the - * `outputSchema` capability is on (default: `{ reply }`). - */ - structured?: unknown -} -``` - -Depends on: [`SubagentCapabilities`](../packages/subagent/subagent/src/index.ts) · [`SubagentStopReason`](../packages/subagent/subagent/src/index.ts) - -Source: [`packages/support/subagent-mock/src/index.ts:87`](../packages/support/subagent-mock/src/index.ts) - ## `@deepseek-ai/dsh-subagent-spawn` Requires: `subagents` diff --git a/docs/module-graph.md b/docs/module-graph.md index f49092f99d..afd4004673 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -100,7 +100,6 @@ flowchart TD pkg_invariants["invariants"] pkg_llm_replay["llm-replay"] pkg_loader_smoke["loader-smoke"] - pkg_subagent_mock["subagent-mock"] end subgraph group_ui["packages/ui"] pkg_acp["acp"] @@ -368,10 +367,6 @@ flowchart TD pkg_hooks_claude --> pkg_session_persistence pkg_hooks_claude --> pkg_subagent pkg_hooks_claude --> pkg_tools - pkg_subagent_mock --> pkg_agent - pkg_subagent_mock --> pkg_llm - pkg_subagent_mock --> pkg_session - pkg_subagent_mock --> pkg_subagent pkg_jsonrpc --> pkg_agent pkg_jsonrpc --> pkg_llm pkg_jsonrpc --> pkg_llm_deepseek @@ -513,7 +508,6 @@ flowchart TD | [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`subagent-mock`](../packages/support/subagent-mock) | `support` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`stdio`](../packages/ui/stdio) | `ui` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`user-interaction`](../packages/ui/user-interaction) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`home`](../packages/util/home), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tool-bash`](../packages/bash/tool-bash), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index ec88f76c2d..25f7637361 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -22,7 +22,6 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; |---|---| | [Prune dead public and result surface](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Make JSON-RPC completion and transport directional](proposed/simplification/2026-07-19-make-jsonrpc-directional.md) | 2026-07-19 | -| [Retire the standalone subagent mock package](proposed/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | | [Use one surface manager per session](proposed/simplification/2026-07-19-use-one-session-surface-manager.md) | 2026-07-19 | ### Architecture @@ -117,6 +116,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Drop unconsumed skill provider events](implemented/simplification/2026-07-12-drop-unconsumed-skill-provider-events.md) | 2026-07-12 | | [Prune unused web seam fields](implemented/simplification/2026-07-12-prune-unused-web-seam-fields.md) | 2026-07-12 | | [Simplify session-log representation](implemented/simplification/2026-07-12-simplify-session-log-representation.md) | 2026-07-12 | +| [Retire the standalone subagent mock package](implemented/simplification/2026-07-19-retire-subagent-mock-package.md) | 2026-07-19 | ### Architecture diff --git a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md index 11824bcf63..3f44940848 100644 --- a/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -2,7 +2,7 @@ Status: implemented -> The full seam is shipped: the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its RFC](2026-06-22-acp-subagent-backend.md)). +> The full seam is shipped: the `dsh-subagent` interface and `dsh-tool-subagent` consumer; the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); the nested-agent snapshot infrastructure ([per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md)); and the out-of-process `dsh-subagent-acp` backend ([its RFC](2026-06-22-acp-subagent-backend.md)). ## Problem @@ -32,7 +32,6 @@ A new package group `packages/subagent/`: | `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` | | `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log | | `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process | -| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path | | `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` | ### The primitive: async `start → SubagentRun` @@ -62,7 +61,7 @@ Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), p ## Testing -The seam is tested through the real Cordis Loader/export path, which catches the export-shape failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. +Registry and tool tests replace only the nondeterministic child boundary with a package-local scripted provider while exercising the real `SubagentService`, lifecycle, task integration, and model-facing tool. Provider and consumer export shapes retain their Loader regression coverage for the failure described in [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md). Registry tests cover reload safety, duplicate names, and start-time capability rejection; nested-agent scenarios replay keylessly through [per-session snapshot replay](../testing/2026-06-22-subagent-snapshot-replay.md); in-process backends also have real-loop unit tests and a with-key e2e. ## Consequences diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml similarity index 62% rename from docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml rename to docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml index 37c3c8b508..b86d9405e0 100644 --- a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml +++ b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-19-retire-subagent-mock-package.md: 2731d35448cbc4dc9db4c92579c35202a53eefdd -2026-07-19-retire-subagent-mock-package.zh.md: 919c56502227157465277cc07c23fb7096763684 +2026-07-19-retire-subagent-mock-package.md: 47174d22eaf27c5a5509280793dd50d70c542d00 +2026-07-19-retire-subagent-mock-package.zh.md: bfad78e1912f9d5153196df3fea2494c88436a91 diff --git a/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md new file mode 100644 index 0000000000..47174d22ea --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.md @@ -0,0 +1,34 @@ +# RFC: Retire the standalone subagent mock package + +Status: implemented + +English | [中文](2026-07-19-retire-subagent-mock-package.zh.md) + +## Problem + +`@deepseek-ai/dsh-subagent-mock` was a configurable test double packaged as a workspace plugin. Its only external consumers were the `tool-subagent` unit suite and the tool-catalog generator; no runtime package, example, snapshot configuration, or real provider loaded it. + +That narrow fixture carried a manifest, exports, peer and development dependencies, project references, package README obligations, Loader composition tests, module-graph membership, and documentation exceptions. The tool-catalog generator mounted it only to make production consumers register their schemas and never executed a child. + +## Decision + +The standalone package is deleted. Its scripted child behavior now lives in `packages/subagent/tool-subagent/tests/scripted-provider.ts`, where tests mount the real `SubagentService`, provider registry, tool implementation, and task runtime while replacing only the nondeterministic child boundary. + +The local fixture retains deterministic replies, structured results, stop reasons, cancellation before and after publication, conversation-inheritance descriptors, and effect-scoped disposal. Package-specific Schemastery and Loader-export tests disappear because the fixture is no longer a deployable plugin. + +The tool-catalog generator registers a minimal local `SubagentProvider` descriptor before mounting `ToolSubagent` or the workflow engine. The descriptor cannot start a child; it exists only to satisfy production load-time dependencies while harvesting schemas from the real consumers. + +Workspace project references, package dependencies, lockfile entries, graph metadata, support-package prose, config-catalog entries, and README gate exceptions no longer name the retired package. + +## Alternatives considered + +**Keep a reusable mock package for future tests.** Reuse never materialized outside one test file and one generator. A future second behavioral consumer can extract a shared fixture after its contract is known; pre-packaging it made test infrastructure look like a supported backend. + +**Generate subagent schemas without mounting production consumers.** Hand-constructing or importing schemas would weaken the catalog check that the real registry and tool composition expose the documented shape. A minimal provider descriptor preserves that check without carrying executable fake-backend behavior. + +## Consequences + +- The workspace has one fewer deployable package and no test-only node in the capability or module graphs. +- `tool-subagent` tests retain foreground, background-task, lifecycle, cancellation, reply, stop-reason, and structured-result coverage through production services. +- Tool-catalog output remains generated from production registrations and is byte-for-byte unchanged. +- Runtime and example packages gain no dependency on test fixtures. diff --git a/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md new file mode 100644 index 0000000000..bfad78e191 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-19-retire-subagent-mock-package.zh.md @@ -0,0 +1,34 @@ +# RFC: 撤销独立的 subagent mock 包 + +Status: implemented + +[English](2026-07-19-retire-subagent-mock-package.md) | 中文 + +## 问题 + +`@deepseek-ai/dsh-subagent-mock` 曾是一个以工作区插件形式发布的可配置测试替身。它仅有两个外部消费方:`tool-subagent` 单元测试和工具目录生成器;运行时包、示例、快照配置和真实提供方都不会加载它。 + +这个用途狭窄的 fixture(测试前置数据)需要维护 manifest(元数据清单)、导出、对等依赖(peer dependency)与开发依赖、项目引用、包(package)README 契约、Loader 组合测试、模块图成员关系以及文档例外。工具目录生成器挂载它,只是为了让生产消费方注册 schema,并不会执行子 agent。 + +## 决策 + +删除独立包。脚本化子 agent 行为现位于 `packages/subagent/tool-subagent/tests/scripted-provider.ts`;测试挂载真实的 `SubagentService`、提供方注册表、工具实现和任务运行时,只替换具有不确定性的子 agent 边界。 + +本地 fixture 保留确定性回复、结构化结果、停止原因、发布前后的取消、对话继承描述和作用域化的 dispose(资源释放)覆盖。由于 fixture 不再是可部署插件,删除包专用的 Schemastery 与 Loader 导出测试。 + +工具目录生成器在挂载 `ToolSubagent` 或工作流引擎之前,注册一个最小本地 `SubagentProvider` 描述。该描述无法启动子 agent;它只用于满足生产消费方的加载时依赖,同时从真实消费方提取 schema。 + +工作区项目引用、包依赖、锁文件条目、图元数据、支持包说明、配置目录条目和 README 门禁例外不再提及已撤销的包。 + +## 备选方案 + +**为未来测试保留可复用 mock 包。** 除一个测试文件和一个生成器外,复用需求始终没有出现。未来产生第二个行为消费方时,可以在共享契约明确后再提取 fixture;提前将其打包会使测试基础设施看起来像受支持的后端。 + +**不挂载生产消费方,直接生成 subagent schema。** 手工构造或直接导入 schema,会削弱目录门禁对真实注册表与工具组合是否公开文档结构的校验。最小提供方描述能保留该校验,而无需携带可执行的虚假后端行为。 + +## 影响 + +- 工作区减少一个可部署包,能力图与模块图也不再包含测试专用节点。 +- `tool-subagent` 测试继续通过生产服务覆盖前台、后台任务、生命周期、取消、回复、停止原因和结构化结果。 +- 工具目录输出仍根据生产注册生成,并保持字节级一致。 +- 运行时包与示例包都不会依赖测试 fixture。 diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md deleted file mode 100644 index 2731d35448..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.md +++ /dev/null @@ -1,35 +0,0 @@ -# RFC: Retire the standalone subagent mock package - -Status: proposed - -English | [中文](2026-07-19-retire-subagent-mock-package.zh.md) - -## Problem - -`@deepseek-ai/dsh-subagent-mock` is a configurable test double packaged as a workspace plugin. Its only external consumers are the `tool-subagent` unit suite and the tool-catalog generator. No runtime package, example, snapshot configuration, or real provider loads it. - -That narrow fixture carries a manifest, exports, peer and development dependencies, project references, package README obligations, Loader composition tests, module-graph membership, and documentation-gate exceptions. The tool-catalog generator mounts it only to make the real subagent tool register its schema; it never executes a child. - -## Proposal - -Delete `packages/support/subagent-mock`. Move the scripted provider behavior actually used by `tool-subagent` into a package-local test fixture while continuing to exercise the real `SubagentService`, provider registry, and tool implementation. - -Have the tool-catalog generator register the minimal provider descriptor required before mounting `ToolSubagent`. Remove the package references, manifest dependency, graph node, README allowlists, and mock-specific Loader tests. - -## Alternatives considered - -**Keep a reusable mock package for future tests.** Reuse has not materialized outside one test file and one generator. A future second consumer can extract a fixture once its shared contract is known; packaging all configurable reply, cancellation, result, and Loader behavior today makes test infrastructure look like a supported backend. - -**Generate the subagent schema without mounting the real tool.** Hand-constructing or importing the schema would weaken the catalog's check that the production registry and tool composition expose the documented shape. The generator should keep mounting the real service and tool with only the child boundary replaced. - -## Acceptance criteria - -- `packages/support/subagent-mock` and every workspace, graph, dependency, and documentation entry for it are removed. -- `tool-subagent` tests retain every scripted reply, structured-result, cancellation, foreground/background, and task-integration case they currently exercise through the real service and tool. -- Tool-catalog generation mounts the production subagent registry and tool with a minimal local provider and produces a byte-identical catalog. -- No runtime or example package gains a dependency on test-only fixtures. -- Focused subagent tests, catalog and graph generation, module-graph verification, build, hygiene, and the full pre-push suite pass. - -## Risks - -Relocating the fixture could accidentally replace too much production composition with a stub. The local fixture must implement only the nondeterministic child boundary; capability checks, lifecycle, task handling, and tool output remain under production code. Mock Loader and HMR coverage can disappear because no deployed composition consumes the package afterward. diff --git a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md b/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md deleted file mode 100644 index 919c565022..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-19-retire-subagent-mock-package.zh.md +++ /dev/null @@ -1,35 +0,0 @@ -# RFC: 撤销独立的 subagent mock 包 - -Status: proposed - -[English](2026-07-19-retire-subagent-mock-package.md) | 中文 - -## 问题 - -`@deepseek-ai/dsh-subagent-mock` 是一个以工作区插件形式发布的可配置测试替身。它仅有两个外部消费方:`tool-subagent` 单元测试和工具目录生成器。运行时包、示例、快照配置和真实提供方都不会加载它。 - -这个用途狭窄的 fixture(测试前置数据)需要维护 manifest(元数据清单)、导出、对等依赖(peer dependency)与开发依赖、项目引用、包(package)README 契约、Loader 组合测试、模块图成员关系以及文档门禁例外。工具目录生成器挂载它,只是为了让真实 subagent 工具注册 schema;生成器从不执行子 agent。 - -## 提案 - -删除 `packages/support/subagent-mock`。把 `tool-subagent` 实际使用的脚本化提供方行为移入该包的本地测试 fixture,同时继续测试真实的 `SubagentService`、提供方注册表和工具实现。 - -工具目录生成器在挂载 `ToolSubagent` 前,只注册所需的最小提供方描述。删除该包的项目引用、manifest 依赖、图节点、README 允许列表和 mock 专用 Loader 测试。 - -## 备选方案 - -**为未来测试保留可复用 mock 包。** 除一个测试文件和一个生成器外,复用需求并未出现。未来产生第二个消费方时,可以在共享契约明确后再提取 fixture;当前把所有可配置回复、取消、结果与 Loader 行为打包,会使测试基础设施看起来像受支持的后端。 - -**不挂载真实工具,直接生成 subagent schema。** 手工构造或直接导入 schema,会削弱目录生成器对生产注册表与工具组合是否公开文档结构的校验。生成器应继续挂载真实服务与工具,只替换不确定的子 agent 边界。 - -## 验收标准 - -- 删除 `packages/support/subagent-mock`,并移除其全部工作区、图、依赖和文档条目。 -- `tool-subagent` 测试保留当前通过真实服务与工具覆盖的全部脚本化回复、结构化结果、取消、前台与后台运行以及任务集成用例。 -- 工具目录生成器使用最小本地提供方挂载生产 subagent 注册表与工具,并生成字节级一致的目录。 -- 运行时包与示例包都不会新增对测试专用 fixture 的依赖。 -- 聚焦 subagent 测试、目录与图生成、模块图校验、构建、hygiene 和完整 pre-push 门禁全部通过。 - -## 风险 - -迁移 fixture 时,可能会误将过多生产组合替换成测试替身。本地 fixture 只能实现不确定的 subagent 边界;能力检查、生命周期、任务处理与工具输出仍由生产代码负责。由于之后不再有部署组合消费该包,可以删除 mock 的 Loader 与 HMR 覆盖。 diff --git a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md index 4c52d0b1a4..266a1d911d 100644 --- a/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md +++ b/docs/rfc/rejected/simplification/2026-07-04-prune-unimplemented-subagent-vocabulary.md @@ -1,19 +1,19 @@ # RFC: Prune the unimplemented subagent seam vocabulary -Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below stands as the record of what is currently unimplemented. +Status: rejected — the deferred capability vocabulary (`outputSchema`/`structured`, `toolFilter`, `sendMessage`/`resume`) is intentionally reserved surface: the seam advertises the full intended contract ahead of its implementations by design, so providers and consumers grow into a stable shape rather than re-negotiating it per capability. The consumer-evidence analysis below records the decision-time state. ## Problem The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) shipped a two-tier capability design: start-time capability flags checked by the service, and optional runtime methods on `SubagentRun`. Three start-time features and both optional runtime methods have zero implementations and zero callers: -- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): every real provider declares `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) builds `{ prompt, parent, signal?, agentOptions? }` and structurally cannot set either; `structured` is produced only by the test mock (`packages/support/subagent-mock`) for its own spec. The service's capability check carries two assert rows whose only exercisers are the rejection tests. +- **`outputSchema`/`structured` and `toolFilter`** (`SubagentCapabilities`, `SubagentStartRequest`, `SubagentResult` in `packages/subagent/subagent/src/types.ts`): at the decision point, every real provider declared `outputSchema: false, toolFilter: false` (`packages/subagent/subagent-spawn/src/index.ts`, `packages/subagent/subagent-fork/src/index.ts`, `packages/subagent/subagent-acp/src/index.ts`); the sole production `ctx.subagents.start` caller (`packages/subagent/tool-subagent/src/index.ts`) built `{ prompt, parent, signal?, agentOptions? }` and structurally could not set either; `structured` appeared only in the scripted test fixture. The service's capability check carried two assert rows whose only exercisers were the rejection tests. - **`SubagentRun.sendMessage` / `SubagentRun.resume`** (same file): implemented by NO provider — not even the mock; the spawn spec asserts their *absence*. The only reason `dsh-subagent` depends on `dsh-tools` at all is `outputSchema`'s `SchemaSpec` type. Three subsequent subagent workstreams (per-session snapshot replay, the fork seed boundary, the ACP backend) landed around this surface without growing a single consumer. ## Proposal -Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the mock's structured branch and its `capabilities`/`structured` config knobs, and the tests that exist to pin the removed surface (the two rejection rows, the spawn absence test, the mock structured specs). Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, and the README rows in `packages/subagent/subagent`, `packages/subagent/subagent-spawn`, `packages/subagent/subagent-fork`, and `packages/support/subagent-mock`. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). +Remove `outputSchema`/`structured`, `toolFilter`, `sendMessage`, and `resume` from the seam; shrink `SubagentCapabilities` to `{ depthLimit }`; drop the two capability-assert rows, the all-false flags on the three providers, the scripted fixture's structured branch and capability knobs, and the tests that exist to pin the removed surface. Drop the `dsh-tools` peer/dev dependency from `packages/subagent/subagent/package.json`. Update the [subagent.md](../../../core-data-structures/subagent.md) pastes and the type-equiv manifest, plus the affected provider READMEs. The implementing PR amends the seam RFC's capability catalog per [implemented/AGENTS.md](../../implemented/AGENTS.md). **Keep** `depthLimit`/`maxDepth` and capability checks. The in-process backend enforces the limit, although the shipping tool does not yet set it. Recursion is a known seam risk, so the appropriate follow-up is to supply a tool default rather than delete working enforcement. diff --git a/packages/subagent/README.md b/packages/subagent/README.md index 62de4ffb08..9935233e85 100644 --- a/packages/subagent/README.md +++ b/packages/subagent/README.md @@ -12,6 +12,6 @@ The subagent seam: an agent delegating work to a child agent. Like the [bash](.. | `subagent-acp/` | Out-of-process backend: a child agent in a spawned subprocess, driven over ACP | (registers on `ctx.subagents`) | | `tool-subagent/` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | -The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs) and ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. +The interface lives at `subagent/subagent/`. The in-process `subagent-spawn` / `subagent-fork` backends share the `subagent-inprocess` driver (a library with no provider of its own — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend builds on the `subagent-subprocess` library (the credential env scrub, the dispose ladder, isolated config dirs). Tests replace only the child boundary with package-local fixtures. The proposal and design rationale: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index c52f5a74dc..e2d4378743 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/dsh-subagent-mock": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts new file mode 100644 index 0000000000..7365348410 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { type Agent } from '@deepseek-ai/dsh-agent' +import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { SessionId } from '@deepseek-ai/dsh-session' +import * as scripted from './scripted-provider.ts' + +/** A minimal parent; the scripted provider only reads its id. */ +function fakeParent(id = 'parent-1'): Agent { + return { id: SessionId(id) } as unknown as Agent +} + +function baseRequest(over: Partial = {}): SubagentStartRequest { + return { + prompt: [{ type: 'text', text: 'task' }], + parent: fakeParent(), + signal: new AbortController().signal, + ...over, + } +} + +async function mount(config: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(SubagentService) + await scripted.mountScriptedProvider(ctx, { name: 'mock', ...config }) + return ctx +} + +describe('scripted subagent provider fixture', () => { + it('registers through the real service and returns the scripted reply', async () => { + const ctx = await mount({ reply: 'hello from fixture' }) + expect(ctx.subagents.list()).toEqual(['mock']) + + const run = await ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'hello from fixture' }], + structured: undefined, + stopReason: 'completed', + }) + await run.dispose() + }) + + it('registers under a configurable name', async () => { + const ctx = await mount({ name: 'spawn' }) + expect(ctx.subagents.list()).toEqual(['spawn']) + }) + + it('returns configured and default structured results', async () => { + const configured = await mount({ reply: 'r', structured: { answer: 42 } }) + const schema = { type: 'object' as const, properties: { answer: { type: 'number' as const } } } + const configuredRun = await configured.subagents.start('mock', baseRequest({ outputSchema: schema })) + await expect(configuredRun.result).resolves.toMatchObject({ structured: { answer: 42 } }) + + const fallback = await mount({ reply: 'fallback reply' }) + const fallbackRun = await fallback.subagents.start('mock', baseRequest({ outputSchema: schema })) + await expect(fallbackRun.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) + }) + + it('omits structured output when no schema is requested', async () => { + const ctx = await mount({ capabilities: { outputSchema: false } }) + const run = await ctx.subagents.start('mock', baseRequest()) + expect(await run.result).not.toHaveProperty('structured') + }) + + it('honors configured and cancellation stop reasons', async () => { + const refused = await mount({ stopReason: 'refusal' }) + const refusedRun = await refused.subagents.start('mock', baseRequest()) + await expect(refusedRun.result).resolves.toMatchObject({ stopReason: 'refusal' }) + + const cancelled = await mount() + const controller = new AbortController() + const cancelledRun = await cancelled.subagents.start('mock', baseRequest({ signal: controller.signal })) + controller.abort() + await expect(cancelledRun.result).resolves.toMatchObject({ stopReason: 'aborted' }) + }) + + it('rejects cancellation before or during asynchronous publication', async () => { + const ctx = await mount() + const alreadyAborted = new AbortController() + alreadyAborted.abort() + await expect(ctx.subagents.start('mock', baseRequest({ signal: alreadyAborted.signal }))) + .rejects.toThrow('scripted subagent start aborted before publication') + + const handoff = new AbortController() + const pending = ctx.subagents.start('mock', baseRequest({ signal: handoff.signal })) + handoff.abort() + await expect(pending).rejects.toThrow('scripted subagent start aborted before publication') + }) + + it('unregisters with its owning fixture fiber', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await scripted.mountScriptedProvider(ctx, { name: 'mock' }) + expect(ctx.subagents.list()).toEqual(['mock']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) +}) diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts new file mode 100644 index 0000000000..01c0769cf8 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -0,0 +1,104 @@ +/** Package-local scripted child boundary for deterministic tool-subagent tests. */ + +import type { Context } from 'cordis' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, +} from '@deepseek-ai/dsh-subagent' + +const DEFAULT_CAPABILITIES: SubagentCapabilities = { + outputSchema: true, + depthLimit: true, + toolFilter: true, + persona: true, +} + +/** Options for one scripted provider fixture. */ +export interface Config { + /** Registry name to register under. */ + name: string + /** Final text returned by the scripted child. */ + reply?: string + /** Terminal result reason. */ + stopReason?: SubagentStopReason + /** Start-time features advertised by the provider. */ + capabilities?: Partial + /** Whether tool descriptions say the child inherits completed turns. */ + inheritsParentContext?: boolean + /** Structured value returned when the request asks for one. */ + structured?: unknown +} + +/** Scripted provider whose result aborts if its signal or disposer wins first. */ +class ScriptedSubagentProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities + readonly inheritsParentContext: boolean + + constructor( + readonly name: string, + private readonly config: Config, + ) { + this.capabilities = { ...DEFAULT_CAPABILITIES, ...config.capabilities } + this.inheritsParentContext = config.inheritsParentContext ?? false + } + + async start(request: SubagentStartRequest): Promise { + if (request.signal.aborted) throw new Error('scripted subagent start aborted before publication') + const reply = this.config.reply ?? 'scripted subagent reply' + const output: ContentBlock[] = [{ type: 'text', text: reply }] + const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema + const stopReason = this.config.stopReason ?? 'completed' + const state = { cancelled: false } + const onAbort = (): void => { state.cancelled = true } + request.signal.addEventListener('abort', onAbort, { once: true }) + await Promise.resolve() + if (state.cancelled) { + request.signal.removeEventListener('abort', onAbort) + throw new Error('scripted subagent start aborted before publication') + } + + const resultFor = (): SubagentResult => ({ + output, + ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, + stopReason: state.cancelled ? 'aborted' : stopReason, + }) + const result = new Promise((resolve) => { + setTimeout(() => { resolve(resultFor()) }, 0) + }).finally(() => { + request.signal.removeEventListener('abort', onAbort) + }) + + return { + id: SessionId(`scripted-subagent:${this.name}:${request.parent.id}`), + localAgent: undefined, + result, + dispose(): Promise { + state.cancelled = true + request.signal.removeEventListener('abort', onAbort) + return Promise.resolve() + }, + } + } +} + +/** + * Mount one scripted provider through an effect-scoped local plugin. + * @param ctx - context carrying the real subagent registry. + * @param config - scripted provider identity and outcome. + * @returns the fixture plugin's disposable fiber. + */ +export function mountScriptedProvider(ctx: Context, config: Config) { + return ctx.plugin({ + name: 'scripted-subagent-provider', + inject: ['subagents'], + apply(pluginCtx: Context): void { + pluginCtx.subagents.registerProvider(new ScriptedSubagentProvider(config.name, config)) + }, + }) +} diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 3bd42ae299..d88367ced5 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -9,18 +9,17 @@ import AgentRegistry from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import TaskService from '@deepseek-ai/dsh-tasks' import * as ToolTasks from '@deepseek-ai/dsh-tool-tasks' -import * as mock from '@deepseek-ai/dsh-subagent-mock' +import * as mock from './scripted-provider.ts' import * as tool from '../src/index.ts' import { runOutcome, settleRun } from '../src/index.ts' import { SessionId } from '@deepseek-ai/dsh-session' /** * Drives the REAL plugin body: mounts `dsh-tool-subagent` on a real - * `ToolRegistry` + `SubagentService`, with the real `dsh-subagent-mock` as the - * backend, and invokes the registered `subagent` tool through - * `ctx.tools.execute`. The mock is the genuine collaborator (we mock only the - * "child agent", the expensive/non-deterministic boundary) — everything - * downstream of the tool is the shipping code path. + * `ToolRegistry` + `SubagentService`, with a package-local scripted child + * boundary, and invokes the registered `subagent` tool through + * `ctx.tools.execute`. Everything downstream of the child boundary is the + * shipping code path. */ /** A minimal parent Agent — the tool reads `agent.id` for `parent`. */ @@ -33,7 +32,7 @@ async function setup(toolConfig: tool.Config, mockConfig: Partial = await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'mock', ...mockConfig }) + await mock.mountScriptedProvider(ctx, { name: 'mock', ...mockConfig }) await ctx.plugin(tool, toolConfig) return ctx } @@ -131,8 +130,8 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'spawn', reply: 'from spawn' }) - await ctx.plugin(mock, { name: 'acp', reply: 'from acp' }) + await mock.mountScriptedProvider(ctx, { name: 'spawn', reply: 'from spawn' }) + await mock.mountScriptedProvider(ctx, { name: 'acp', reply: 'from acp' }) await ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' }) await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' }) @@ -249,7 +248,7 @@ describe('dsh-tool-subagent', () => { tool.apply(ctx, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(false) // Backend arrives (as a delayed sibling fiber would): the tool appears. - await ctx.plugin(mock, { name: 'mock', reply: 'late but fine' }) + await mock.mountScriptedProvider(ctx, { name: 'mock', reply: 'late but fine' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(text(result)).toBe('late but fine') @@ -260,7 +259,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - const backend = await ctx.plugin(mock, { name: 'mock' }) // fresh conversation (descriptor: false) + const backend = await mock.mountScriptedProvider(ctx, { name: 'mock' }) // fresh conversation (descriptor: false) await ctx.plugin(tool, { provider: 'mock' }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') @@ -270,7 +269,7 @@ describe('dsh-tool-subagent', () => { // Backend reloads with a DIFFERENT conversation-history descriptor: the wording is re-derived // from the fresh provider, not served stale from the first mount. - await ctx.plugin(mock, { name: 'mock', inheritsParentContext: true }) + await mock.mountScriptedProvider(ctx, { name: 'mock', inheritsParentContext: true }) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('inherits this conversation') }) @@ -281,7 +280,7 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SubagentService) // Arm 1: a mounted tool dies with its plugin fiber; the provider survives. - await ctx.plugin(mock, { name: 'mock' }) + await mock.mountScriptedProvider(ctx, { name: 'mock' }) const mounted = await ctx.plugin(tool, { provider: 'mock' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) await mounted.dispose() @@ -293,7 +292,7 @@ describe('dsh-tool-subagent', () => { // live plugin owns (the zombie mount). const waiting = await ctx.plugin(tool, { provider: 'later', toolName: 'subagent_later' }) await waiting.dispose() - await ctx.plugin(mock, { name: 'later' }) + await mock.mountScriptedProvider(ctx, { name: 'later' }) expect(ctx.tools.schemas().some(s => s.name === 'subagent_later')).toBe(false) }) @@ -302,11 +301,11 @@ describe('dsh-tool-subagent', () => { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'mock' }) + await mock.mountScriptedProvider(ctx, { name: 'mock' }) await ctx.plugin(tool, { provider: 'mock' }) // An unrelated provider registering (added-event with another name) and // unregistering (removed-event with another name) must not touch the tool. - const other = await ctx.plugin(mock, { name: 'other', inheritsParentContext: true }) + const other = await mock.mountScriptedProvider(ctx, { name: 'other', inheritsParentContext: true }) expect(ctx.tools.schemas().filter(s => s.name === 'subagent')).toHaveLength(1) expect(ctx.tools.schemas().find(s => s.name === 'subagent')!.description).toContain('does not see this conversation') await other.dispose() diff --git a/packages/support/README.md b/packages/support/README.md index 0d991f07cf..433d6b3cdb 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -9,6 +9,5 @@ Packages that exist to serve development, testing, and the examples rather than | `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-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and 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. +`invariants` is development support but has no environment guard: it runs wherever registered, and the default `dsh-agent-spine-demo` bundle mounts it unconditionally. `agent-loop-testkit` centralizes the mandatory service spine for hand-built AgentLoop tests without owning their loop or scenario. `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `acp-snapshot` carries the ACP subprocess/client boundary plus the snapshot harness, normalizers, and suite machinery, while `loader-smoke` owns the parallel stdio/Loader process boundary used by keyless example e2e suites. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md deleted file mode 100644 index a879433b67..0000000000 --- a/packages/support/subagent-mock/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# @deepseek-ai/dsh-subagent-mock - -A scripted `SubagentProvider` for testing the [subagent seam](../../subagent/subagent/README.md) without a model or a real child agent — the subagent analog of [`dsh-llm-replay`](../llm-replay/README.md). - -It lets a test drive `ctx.subagents` and the model-facing `dsh-tool-subagent` through the real Cordis loader/export path, exercising provider registration, async start, start-time capability validation, required-signal cancellation, `result`, `dispose`, and structured output deterministically and keylessly. - -## Usage - -Load it as a plugin (functional shape: `name`/`inject`/`Config`/`apply`, no default). Config (all optional): - -| Key | Default | Meaning | -|---|---|---| -| `name` | `mock` | Registry name to register the provider under. | -| `reply` | `mock subagent reply` | The scripted child's final answer text. | -| `stopReason` | `completed` | The stop reason `result` settles with. | -| `capabilities` | all `true` | Which start-time capabilities (`outputSchema`, `depthLimit`, `toolFilter`, and `persona`) the provider advertises. | -| `inheritsParentContext` | `false` | Conversation-history descriptor: `false` means fresh, while `true` exercises seeded/fork wording. It says nothing about tool, service, scope, or authority inheritance. | -| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | - -Aborting the required request signal or disposing before `result` settles flips the stop reason to `aborted`, so both holder-facing cancellation paths are observable. - -## Model Experience - -Indirectly, through `dsh-tool-subagent`, which renders this test provider's configured reply or stop-reason error into the parent test history. - -## Known Limitations and Deferred Work - -- **Scripted provider only** — it does not run a model, create a child agent, or exercise real prompt/tool-loop behavior. -- **One synthetic outcome per run** — it models no multi-turn, streaming, steering, resume, or subprocess transport behavior. diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json deleted file mode 100644 index c9d0982f55..0000000000 --- a/packages/support/subagent-mock/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-subagent-mock", - "description": "Scripted subagent provider for testing the subagent seam (keyless, deterministic)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-subagent": "workspace:^", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts deleted file mode 100644 index 4ea30d166a..0000000000 --- a/packages/support/subagent-mock/src/index.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Scripted, model-free subagent provider for deterministic coverage of registration, - * capability checks, lifecycle, the model-facing tool, and structured results through the real - * loader path. It is a named-export functional plugin; no default export. - * @module @deepseek-ai/dsh-subagent-mock - */ - -import type { Context } from 'cordis' -import z from 'schemastery' -import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import type { - SubagentCapabilities, - SubagentProvider, - SubagentResult, - SubagentRun, - SubagentStartRequest, - SubagentStopReason, -} from '@deepseek-ai/dsh-subagent' - -const STOP_REASONS = ['completed', 'aborted', 'error', 'max-tokens', 'refusal'] as const - -const DEFAULT_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true, persona: true } - -/** Scripted provider whose configured result aborts if disposed or signalled first. */ -class MockSubagentProvider implements SubagentProvider { - readonly capabilities: SubagentCapabilities - readonly inheritsParentContext: boolean - - constructor( - readonly name: string, - private readonly config: Config, - ) { - this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities } - this.inheritsParentContext = config.inheritsParentContext ?? false - } - - async start(request: SubagentStartRequest): Promise { - if (request.signal.aborted) throw new Error('mock subagent start aborted before publication') - const reply = this.config.reply ?? 'mock subagent reply' - const output: ContentBlock[] = [{ type: 'text', text: reply }] - const wantsStructured = request.outputSchema !== undefined && this.capabilities.outputSchema - const baseStop: SubagentStopReason = this.config.stopReason ?? 'completed' - const flags = { cancelled: false } - const onAbort = (): void => { flags.cancelled = true } - request.signal.addEventListener('abort', onAbort, { once: true }) - // Make publication genuinely asynchronous so a same-turn abort is still - // a provider-owned startup failure rather than a returned live run. - await Promise.resolve() - if (flags.cancelled) { - request.signal.removeEventListener('abort', onAbort) - throw new Error('mock subagent start aborted before publication') - } - - // A deterministic child id derived from the parent — no clock/random (both - // banned in deterministic paths here, and unnecessary for a scripted run). - const id = SessionId(`mock-subagent:${this.name}:${request.parent.id}`) - - const resultFor = (): SubagentResult => ({ - output, - ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, - stopReason: flags.cancelled ? 'aborted' : baseStop, - }) - - const result = new Promise((resolve) => { - setTimeout(() => { resolve(resultFor()) }, 0) - }).finally(() => { - request.signal.removeEventListener('abort', onAbort) - }) - return { - id, - localAgent: undefined, - result, - dispose(): Promise { - flags.cancelled = true - request.signal.removeEventListener('abort', onAbort) - return Promise.resolve() - }, - } - } -} - -export const name = 'subagent-mock' -export const inject = ['subagents'] - -/** Config for the mock provider; all optional with test-friendly defaults. */ -export interface Config { - /** Registry name to register under. */ - name: string - /** The text the scripted child "returns" as its final answer. */ - reply?: string - /** The stop reason the run settles with. */ - stopReason?: SubagentStopReason - /** Which start-time capabilities to advertise (default: all `true`). */ - capabilities?: Partial - /** - * The conversation-history descriptor to declare - * ({@link SubagentProvider.inheritsParentContext}); default `false` (fresh - * conversation). Set `true` to exercise seeded/fork wording in consumer - * tests. This flag says nothing about tool, service, scope, or authority - * inheritance. - */ - inheritsParentContext?: boolean - /** - * Structured value surfaced when a request carries an `outputSchema` and the - * `outputSchema` capability is on (default: `{ reply }`). - */ - structured?: unknown -} - -export const Config: z = z.object({ - name: z.string().default('mock'), - reply: z.string(), - stopReason: z.union(STOP_REASONS), - capabilities: z.object({ - outputSchema: z.boolean(), - depthLimit: z.boolean(), - toolFilter: z.boolean(), - persona: z.boolean(), - }), - inheritsParentContext: z.boolean(), - structured: z.any(), -}) - -export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new MockSubagentProvider(config.name, config)) -} diff --git a/packages/support/subagent-mock/tests/subagent-mock.spec.ts b/packages/support/subagent-mock/tests/subagent-mock.spec.ts deleted file mode 100644 index aec0fd7db1..0000000000 --- a/packages/support/subagent-mock/tests/subagent-mock.spec.ts +++ /dev/null @@ -1,122 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { type Agent } from '@deepseek-ai/dsh-agent' - -import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' -import * as mock from '../src/index.ts' -import { SessionId } from '@deepseek-ai/dsh-session' - -/** A minimal parent — the mock provider only reads `parent.id`. */ -function fakeParent(id = 'parent-1'): Agent { - return { id: SessionId(id) } as unknown as Agent -} - -function baseRequest(over: Partial = {}): SubagentStartRequest { - return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), signal: new AbortController().signal, ...over } -} - -async function mount(config: Partial = {}): Promise { - const ctx = new Context() - await ctx.plugin(SubagentService) - await ctx.plugin(mock, { name: 'mock', ...config }) - return ctx -} - -describe('dsh-subagent-mock', () => { - it('registers a provider on ctx.subagents and returns the scripted reply', async () => { - const ctx = await mount({ reply: 'hello from mock' }) - expect(ctx.subagents.list()).toEqual(['mock']) - - const run = await ctx.subagents.start('mock', baseRequest()) - await expect(run.result).resolves.toEqual({ - output: [{ type: 'text', text: 'hello from mock' }], - structured: undefined, - stopReason: 'completed', - }) - await run.dispose() - }) - - it('registers under a configurable name', async () => { - const ctx = await mount({ name: 'spawn' }) - expect(ctx.subagents.list()).toEqual(['spawn']) - }) - - it('surfaces a structured result when the request carries an outputSchema', async () => { - const ctx = await mount({ reply: 'r', structured: { answer: 42 } }) - const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) - await expect(run.result).resolves.toMatchObject({ structured: { answer: 42 } }) - }) - - it('defaults structured output to { reply } when outputSchema is requested but no structured value is configured', async () => { - const ctx = await mount({ reply: 'fallback reply' }) - const run = await ctx.subagents.start('mock', baseRequest({ outputSchema: { type: 'object', properties: { answer: { type: 'number' } } } })) - await expect(run.result).resolves.toMatchObject({ structured: { reply: 'fallback reply' } }) - }) - - it('omits structured output when outputSchema capability is off', async () => { - const ctx = await mount({ capabilities: { outputSchema: false } }) - // The service rejects an outputSchema request against a no-cap provider, so - // the structured path is only reachable when the cap is on; with it off and - // no schema requested, the result has no structured field. - const run = await ctx.subagents.start('mock', baseRequest()) - const result = await run.result - expect(result).not.toHaveProperty('structured') - }) - - it('honors a configured stop reason', async () => { - const ctx = await mount({ stopReason: 'refusal' }) - const run = await ctx.subagents.start('mock', baseRequest()) - await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' }) - }) - - it('flips the stop reason to aborted when the signal fires before the result settles', async () => { - const ctx = await mount() - const controller = new AbortController() - const run = await ctx.subagents.start('mock', baseRequest({ signal: controller.signal })) - controller.abort() - await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) - }) - - it('rejects an already-aborted request before starting publication', async () => { - const ctx = await mount() - const controller = new AbortController() - controller.abort() - - await expect(ctx.subagents.start('mock', baseRequest({ signal: controller.signal }))) - .rejects.toThrow('mock subagent start aborted before publication') - }) - - it('rejects when cancellation wins the asynchronous publication handoff', async () => { - const ctx = await mount() - const controller = new AbortController() - const pending = ctx.subagents.start('mock', baseRequest({ signal: controller.signal })) - - controller.abort() - - await expect(pending).rejects.toThrow('mock subagent start aborted before publication') - }) - - it('unregisters the provider when the owning fiber is disposed (HMR safety)', async () => { - const ctx = new Context() - await ctx.plugin(SubagentService) - const fiber = await ctx.plugin(mock, { name: 'mock' }) - expect(ctx.subagents.list()).toEqual(['mock']) - await fiber.dispose() - expect(ctx.subagents.list()).toEqual([]) - }) - - it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { - // A default export would make Loader unwrap only that value and drop `inject`. - expect('default' in mock).toBe(false) - expect(mock.name).toBe('subagent-mock') - expect(mock.inject).toEqual(['subagents']) - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(mock) as Record - expect(unwrapped).toBe(mock) - expect(unwrapped.name).toBe('subagent-mock') - expect(unwrapped.inject).toEqual(['subagents']) - expect(typeof unwrapped.apply).toBe('function') - }) -}) diff --git a/packages/support/subagent-mock/tsconfig.json b/packages/support/subagent-mock/tsconfig.json deleted file mode 100644 index ccc9fa45ed..0000000000 --- a/packages/support/subagent-mock/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../subagent/subagent" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 47d998fedc..f67e60bd8f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1640,9 +1640,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../subagent - '@deepseek-ai/dsh-subagent-mock': - specifier: workspace:^ - version: link:../../support/subagent-mock '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt @@ -1751,31 +1748,6 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/support/subagent-mock: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 - version: 1.0.0-rc.5(cordis@4.0.0-rc.7)(node-addon-require-builtin@0.1.0) - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-subagent': - specifier: workspace:^ - version: link:../../subagent/subagent - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/tasks/tasks: devDependencies: '@deepseek-ai/dsh-agent': diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index f6aaa6c2d9..5a4178bd56 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -245,7 +245,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'subagent', title: 'Subagent provider registry', mode: 'seam', - implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp', 'subagent-mock'], + implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'], consumers: ['tool-subagent'], note: 'Providers implement transports; tool-subagent exposes one configured provider as a model-facing tool name.', }, diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index f178f70e9f..ca69768227 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -19,7 +19,7 @@ import WebService from '@deepseek-ai/dsh-web' import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa' import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local' import SubagentService from '@deepseek-ai/dsh-subagent' -import * as SubagentMock from '@deepseek-ai/dsh-subagent-mock' +import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillLocal from '@deepseek-ai/dsh-skill-local' import TaskService from '@deepseek-ai/dsh-tasks' @@ -39,6 +39,17 @@ import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' +/** Register the descriptor needed to mount schema-producing consumers. */ +function registerCatalogSubagentProvider(ctx: Context, name: string): void { + const provider: SubagentProvider = { + name, + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false, persona: false }, + inheritsParentContext: false, + start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')), + } + ctx.subagents.registerProvider(provider) +} + /** * Tool package plus its hand-maintained boot recipe. The caller mounts the * prompt and registry; each recipe supplies only package-specific seams and @@ -191,8 +202,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ shippedNames: ['subagent', 'subagent_fork'], async mount(ctx) { await ctx.plugin(SubagentService) - // Register a scripted provider under the name the tool delegates to. - await ctx.plugin(SubagentMock, { name: 'mock' }) + registerCatalogSubagentProvider(ctx, 'mock') await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: @@ -234,7 +244,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ // subagent provider to satisfy it. The schema does not depend on which // provider backs the engine. await ctx.plugin(SubagentService) - await ctx.plugin(SubagentMock, { name: 'mock' }) + registerCatalogSubagentProvider(ctx, 'mock') await ctx.plugin(VmWorkflowEngine, { provider: 'mock' }) await ctx.plugin(ToolWorkflow) }, diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 75fb2b75ad..35cd2dae1b 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -65,7 +65,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, 'packages/support/loader-smoke': { kind: 'none', reason: 'The test harness observes child-process streams without changing live requests.' }, 'packages/support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' }, - 'packages/support/subagent-mock': { kind: 'indirect', reason: 'Only dsh-tool-subagent renders its configured test outcome.' }, 'packages/tasks/tasks': { kind: 'indirect', reason: 'Producer and control-surface plugins own all model rendering over the task registry.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/ui/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, diff --git a/tsconfig.build.json b/tsconfig.build.json index dd13e018c3..6f18011715 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -77,7 +77,6 @@ { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, { "path": "./packages/subagent/subagent" }, - { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, { "path": "./packages/subagent/subagent-subprocess" }, diff --git a/tsconfig.json b/tsconfig.json index 22451dc10f..d0b5692cf0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -88,7 +88,6 @@ { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, { "path": "./packages/subagent/subagent" }, - { "path": "./packages/support/subagent-mock" }, { "path": "./packages/subagent/tool-subagent" }, { "path": "./packages/subagent/subagent-inprocess" }, { "path": "./packages/subagent/subagent-subprocess" }, From 548bbe7ae898383ababa3a849905b0642dc966e7 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:05:59 +0800 Subject: [PATCH 158/163] docs(tui): record dedicated terminal front-door decision Record the July 17 package split as an implemented bilingual feature RFC. The decision explains why TUI and stdio have separate terminal contracts, why concrete front-door selection belongs to composition, and how the TUI preserves existing session, tool-presentation, and lifecycle ownership. Link the deterministic dark, light, and animated demos from the RFC so the assets have an in-repository owner. Cross-link the feature and testing decisions from the package README, then regenerate the RFC index and bilingual consistency record. --- docs/rfc/INDEX.md | 1 + ...cated-full-screen-tui-front-door.i18n.yaml | 6 ++ ...17-dedicated-full-screen-tui-front-door.md | 58 +++++++++++++++++++ ...dedicated-full-screen-tui-front-door.zh.md | 58 +++++++++++++++++++ packages/ui/tui/README.md | 2 + 5 files changed, 125 insertions(+) create mode 100644 docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml create mode 100644 docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md create mode 100644 docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md diff --git a/docs/rfc/INDEX.md b/docs/rfc/INDEX.md index 2a262fca6e..9daf5de83b 100644 --- a/docs/rfc/INDEX.md +++ b/docs/rfc/INDEX.md @@ -89,6 +89,7 @@ Generated by `pnpm run gen-rfc-index` from the RFC tree — never edit by hand; | [Session query relationship tracing](implemented/feature/2026-07-13-session-query-tracing.md) | 2026-07-13 | | [Optional time-context plugin](implemented/feature/2026-07-14-time-context-plugin.md) | 2026-07-14 | | [Durable per-step time context](implemented/feature/2026-07-16-durable-per-step-time-context.md) | 2026-07-16 | +| [Dedicated full-screen TUI front door](implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) | 2026-07-17 | ### Simplification diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml new file mode 100644 index 0000000000..e7770a2753 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-07-17-dedicated-full-screen-tui-front-door.md: dd4d7350a61b0f19fcc87658e4c0f9d8bbf2e998 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 45e0926f87e1b73c378c195a46a6d40d13a69bfa diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md new file mode 100644 index 0000000000..dd4d7350a6 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -0,0 +1,58 @@ +# RFC: Dedicated full-screen TUI front door + +Status: implemented + +English | [中文](2026-07-17-dedicated-full-screen-tui-front-door.zh.md) + +## Problem + +The line-oriented `@deepseek-ai/dsh-stdio` front door works in pipes and ordinary terminals, but a full-screen coding interface must own raw input, differential screen drawing, cursor state, overlays, and terminal restoration. Combining those contracts in one UI plugin couples the pipe-safe path to a TTY-only lifecycle and makes it unclear which terminal behavior a composition selects. + +The interactive channel must remain a Cordis plugin over the same agent, session, tool, and user-interaction services as every other front door. It needs to resume durable history, follow compaction replacements, display tool-owned presentation, and restore the terminal on startup failure and disposal. A standalone chat application or a second agent composition would duplicate behavior outside the plugin graph. + +## Decision + +DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. + +The app layer selects a concrete terminal front door before mounting it. `@deepseek-ai/dsh-stdio-demo` can resolve `auto` from the two process streams, while the `coding-agent` and `tui-agent` leaves explicitly select readline and TUI respectively. The TUI leaf reuses the coding agent's backend and tool composition through an asserted include patch, so the three runnable agent leaves remain symmetric without duplicating deployment choices. + +The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. + +### Session projection and interaction + +The TUI rebuilds the transcript from the active `session.surface` and reprojects it whenever an event carries a `surfaceOp`, so resumed and compacted history matches the model-visible conversation. It renders Markdown text and reasoning, token totals, the latest `todo/write` plan, and tool cards produced through each tool definition's `presentCall` and `presentResult` methods. Pending chunks and tool calls update the same components that completed events settle. + +Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. The plugin registers the shared `userInteraction` provider and presents questions as queued keyboard overlays; agent behavior and answer logging remain owned by their existing services. + +### Terminal ownership + +Before model output, session data, tool presentation, questions, configuration, or diagnostics reach pi-tui or the terminal title, `displayText()` renders C0 and C1 controls other than line feeds as visible hexadecimal escapes. Only the TUI and pi-tui create ANSI control sequences. + +The built-in palette uses standard 16-color ANSI foregrounds and SGR attributes, keeps body text and backgrounds at terminal defaults, and uses reverse video for selection. Host terminals therefore remap the interface for light and dark themes without a TUI-specific theme setting; `color: false` removes styling. + +### Shipped interface + +The deterministic scripted demo shows prompt submission, streamed model output, and the ANSI palette under dark and light terminal themes without a network dependency. + +![Scripted TUI interaction](../../../assets/tui/jokes-dynamic.gif) + +| Dark terminal | Light terminal | +| --- | --- | +| ![TUI on a dark terminal](../../../assets/tui/jokes-dark.png) | ![TUI on a light terminal](../../../assets/tui/jokes-light.png) | + +## Verification + +The implemented [TUI terminal-state snapshot RFC](../testing/2026-07-18-tui-terminal-state-snapshots.md) owns the four-layer verification contract: direct behavior tests, transient semantic terminal snapshots, recorded JSONL journeys through production tools, and Loader/PTY smoke tests. The package README owns configuration, commands, model-visible effects, and current limitations. + +## Alternatives considered + +- **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. +- **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. +- **Keep TUI wiring and tests under the readline `coding-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the coding agent's backend composition. + +## Consequences + +- Interactive terminal work gains a stateful Markdown, card, plan, and question interface without changing the line-oriented protocol used by pipes and automation. +- The TUI carries a pi-tui dependency and a strict TTY requirement; non-TTY deployments select `@deepseek-ai/dsh-stdio` at composition time. +- Session projection makes resume and compaction consistent with the durable conversation, but one configured session owns the transcript and editor. +- Tool packages extend terminal cards through their existing presentation methods without adding tool-specific branches to the TUI. diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md new file mode 100644 index 0000000000..45e0926f87 --- /dev/null +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -0,0 +1,58 @@ +# RFC: 独立的全屏 TUI 入口 + +Status: implemented + +[English](2026-07-17-dedicated-full-screen-tui-front-door.md) | 中文 + +## 问题 + +逐行输出的 `@deepseek-ai/dsh-stdio` 入口适用于管道和普通终端,但全屏编码界面必须负责原始输入、差分绘制、光标状态、浮层和终端恢复。把这两类契约合并到一个 UI 插件中,会迫使管道安全路径依赖仅适用于 TTY 的生命周期,也使组合无法明确表达所选终端行为。 + +交互通道必须继续作为 Cordis 插件,使用与其他入口相同的 agent(智能体)、会话、工具和用户交互服务。它需要恢复持久历史、跟随压缩替换、显示工具自有的呈现内容,并在启动失败和资源释放时恢复终端。独立聊天应用或第二套 agent 组合会在插件图之外重复实现这些行为。 + +## 决策 + +DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 + +应用组合层在挂载前选择具体的终端入口。`@deepseek-ai/dsh-stdio-demo` 可以根据两个进程流通过 `auto` 作出选择,`coding-agent` 和 `tui-agent` 叶节点则分别明确选择 readline 与 TUI。TUI 叶节点通过带断言的 include patch 复用 coding agent 的后端和工具组合,使三个可运行的 agent 叶节点保持对称,同时避免重复部署选项。 + +所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 + +### 会话投影与交互 + +TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在事件携带 `surfaceOp` 时重新投影,因此恢复或压缩后的历史与模型可见会话保持一致。TUI 渲染 Markdown 文本与推理、token 用量、最新 `todo/write` 计划,以及各工具定义通过 `presentCall` 和 `presentResult` 方法生成的工具卡片。进行中的分片与工具调用会更新同一组组件,随后由完成事件收束状态。 + +agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。插件注册共享的 `userInteraction` 提供方,以排队的键盘浮层呈现问题;agent 行为和答案日志仍由既有服务负责。 + +### 终端所有权 + +在模型输出、会话数据、工具呈现、问题、配置或诊断信息进入 pi-tui 或终端标题前,`displayText()` 会把换行之外的 C0 和 C1 控制字符显示为十六进制转义文本。只有 TUI 和 pi-tui 可以生成 ANSI 控制序列。 + +内置配色仅使用标准 16 色 ANSI 前景色和 SGR 属性,正文文字和背景沿用终端默认值,选中项使用反显。因此,宿主终端可以直接按浅色或深色主题重映射界面,无需 TUI 专用主题设置;`color: false` 会移除样式。 + +### 已交付界面 + +确定性的脚本化演示无需网络即可展示提示词提交、流式模型输出,以及 ANSI 配色在深色和浅色终端主题下的效果。 + +![脚本化 TUI 交互](../../../assets/tui/jokes-dynamic.gif) + +| 深色终端 | 浅色终端 | +| --- | --- | +| ![深色终端中的 TUI](../../../assets/tui/jokes-dark.png) | ![浅色终端中的 TUI](../../../assets/tui/jokes-light.png) | + +## 验证 + +已实现的 [TUI 终端状态快照 RFC](../testing/2026-07-18-tui-terminal-state-snapshots.md) 规定四层验证契约:直接行为测试、瞬态语义终端快照、通过生产工具执行的已录制 JSONL 流程,以及 Loader/PTY 冒烟测试。包(package)README 负责记录配置、命令、模型可见效果和当前限制。 + +## 曾考虑的替代方案 + +- **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 +- **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 +- **把 TUI 接线与测试保留在 readline `coding-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 coding agent 的后端组合。 + +## 后果 + +- 交互式终端获得带状态的 Markdown、卡片、计划和提问界面,同时不会改变管道与自动化使用的逐行协议。 +- TUI 会引入 pi-tui 依赖并严格要求 TTY;非 TTY 部署在组合时选择 `@deepseek-ai/dsh-stdio`。 +- 会话投影使恢复和压缩与持久会话保持一致,但只有一个已配置会话拥有 transcript 和编辑器。 +- 工具包通过既有呈现方法扩展终端卡片,无需在 TUI 中增加工具专用分支。 diff --git a/packages/ui/tui/README.md b/packages/ui/tui/README.md index ff1f536754..ce7e9ac2fc 100644 --- a/packages/ui/tui/README.md +++ b/packages/ui/tui/README.md @@ -2,6 +2,8 @@ The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should compose [`@deepseek-ai/dsh-stdio`](../stdio/README.md) instead. +The implemented [TUI feature RFC](../../../docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision and shipped interface; the [terminal-state snapshot RFC](../../../docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy. + This package owns interactive terminal presentation and input only. It injects `agents`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries. The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions as keyboard-driven overlays. Surface replacement events rebuild the transcript so compacted history does not reappear. From ddd3370477fbf420364c9369da4c88c10c0c9786 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:12:16 +0800 Subject: [PATCH 159/163] test(session-persistence): pin rollback journal permissions --- .../session-persistence-sqlite/README.md | 2 +- .../tests/sqlite.spec.ts | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index e9066d3748..11fef96dad 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -12,7 +12,7 @@ Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, The repository's Node range supports unflagged `node:sqlite`. The database enables foreign keys and uses the configured journal mode (`wal` by default; use a rollback mode where WAL shared-memory files are unsuitable). `PRAGMA user_version` stores the table-layout version; databases with any other version are rejected because this unreleased format has no migrations. -On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. +On filesystems with POSIX modes, the backend requests mode `0700` for missing directories and exclusively creates a missing database with mode `0600` before SQLite opens it; the process umask may further restrict both. New WAL, shared-memory, and persistent rollback-journal sidecars receive the database's resulting owner-only mode. Existing directories, database files, and sidecars keep their modes; filesystem setup errors other than an existing database fail initialization. These defaults prevent incidental exposure through a permissive process umask, but do not protect database confidentiality or integrity when another principal can replace the database entry in its parent directory. ## Contract semantics over rows diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 4a6b141e04..f26edfa54d 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -406,6 +406,22 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b.dispose() }) + it('creates a persistent rollback journal with owner-only mode', async () => { + if (process.platform === 'win32') return + const path = await freshDbPath() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path, journalMode: 'persist' }) + const m = meta('persist-permissions') + + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + + expect((await stat(path)).mode & 0o777).toBe(0o600) + expect((await stat(`${path}-journal`)).mode & 0o777).toBe(0o600) + await fiber.dispose() + }) + it('preserves the mode of an existing database file', async () => { if (process.platform === 'win32') return const path = await freshDbPath() From 5dcf6095cb191d1f13fbc8ebe3d5df570d4d3b2e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:25:40 +0800 Subject: [PATCH 160/163] docs: include JSDoc in type-equiv blocks --- docs/AGENTS.md | 2 +- docs/core-data-structures/approval.md | 24 +++ docs/core-data-structures/bash.md | 113 +++++------ docs/core-data-structures/code-runtime.md | 36 ++++ docs/core-data-structures/compaction.md | 1 + docs/core-data-structures/core.md | 161 ++++++++------- docs/core-data-structures/filesystem.md | 99 +++++++++ docs/core-data-structures/llm-streaming.md | 29 +++ docs/core-data-structures/persistence.md | 34 ++-- docs/core-data-structures/sandbox.md | 40 +++- docs/core-data-structures/scope.md | 10 + docs/core-data-structures/session-query.md | 64 +++++- docs/core-data-structures/session.md | 191 ++++++++++++------ docs/core-data-structures/skills.md | 45 ++++- docs/core-data-structures/spill.md | 27 +++ docs/core-data-structures/subagent.md | 139 +++++++++++++ docs/core-data-structures/system-prompt.md | 20 ++ docs/core-data-structures/tasks.md | 16 ++ docs/core-data-structures/token-meter.md | 2 + docs/core-data-structures/tools.md | 105 +++++++++- docs/core-data-structures/user-interaction.md | 7 + docs/core-data-structures/web.md | 51 ++++- docs/core-data-structures/workflow.md | 50 +++++ docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- ...2026-06-20-core-data-structures-catalog.md | 10 +- scripts/type-equiv.manifest.json | 2 +- scripts/verify-type-equiv.ts | 60 ++++-- 29 files changed, 1068 insertions(+), 282 deletions(-) diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 0c305e87c8..dc06018c16 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -27,7 +27,7 @@ Placement: bugs → postmortems; rationale → RFCs; procedures → cookbooks; t - **Document current state, not change history.** Avoid "previously/now/no longer", PRs, commits, and stack positions in durable prose; name the live mechanism. Put change stories in commits, PRs, RFCs, or postmortems. - **Write an RFC in the same PR for decisions a maintainer may reasonably revisit.** Mechanical or self-evident changes need none ([when to write one](rfc/README.md)). - **One physical line per paragraph** (`verify-md-wrap`): use editor soft-wrap. Code blocks, tables, and list structure keep their formatting; code comments stay under the linter's column limit. -- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type definition is fenced ` ```ts type-equiv ` and registered in the manifest so it cannot drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). +- **Fenced `ts` blocks must compile** (`doc-typecheck`); a pasted type declaration and its original JSDoc are fenced ` ```ts type-equiv ` and registered in the manifest so neither can drift ([mechanics](development.md#documenting-types-verbatim-ts-type-equiv)). - **The [core-data-structures catalog](core-data-structures/core.md) updates in the same change** that reshapes a documented type. `verify-type-equiv` catches drifted pastes, not never-documented new types ([what counts as core](core-data-structures/core.md#what-counts-as-core)). - **Bilingual pairs update together**: editing either side obligates the counterpart and a re-record in the same change ([i18n contract](i18n/README.md)). - **Comments and JSDoc state complete contracts, not reasoning transcripts.** Preserve behavior, conditions, timing, modality, exceptions, consequences, and non-obvious orientation; delete implementation narration, test walkthroughs, review analysis, and code restatement. Keep the local contract and link to its owning rationale. Use [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for required coverage, decision rules, and examples. diff --git a/docs/core-data-structures/approval.md b/docs/core-data-structures/approval.md index e8820ccfeb..8634415a62 100644 --- a/docs/core-data-structures/approval.md +++ b/docs/core-data-structures/approval.md @@ -9,12 +9,20 @@ Source: [`packages/ui/user-approval/src/index.ts`](../../packages/ui/user-approv Every request receives a fresh `ApprovalRequestId`. The brand pairs the `approval/asked` and `approval/decided` audit events without making approval ids interchangeable with tool-call or agent/session ids. ```ts type-equiv +/** + * Pairs one `approval/asked` audit event with its `approval/decided`. + * Service-issued (one fresh id per {@link ApprovalService.request} call). + */ type ApprovalRequestId = Branded<'ApprovalRequestId'> ``` `ApprovalOutcome` is closed and fail-closed. `allowed-once` grants only the asked-about action; callers deny on `rejected`, `cancelled`, and `unavailable`. A missing, non-owning, throwing, or non-conforming answerer becomes `unavailable` rather than opening the gate. ```ts type-equiv +/** + * Closed approval outcomes: a one-shot grant, explicit rejection, withdrawn + * request, or unavailable answerer. Callers fail closed on `unavailable`. + */ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' ``` @@ -23,6 +31,18 @@ type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' `ApprovalPolicy` determines what happens before interactive answerers run. `ask` delegates to the composed answerer chain, whose no-answer default is `unavailable`; `never` deterministically returns `rejected` without dispatching any answerer. The effective value is the last `approval/policy` event in the session log, falling back to the service config. `setApprovalPolicy(session, policy)` is the single write path, so replay reconstructs the override. ```ts type-equiv +/** + * A session's approval policy — what happens to an {@link ApprovalService} + * ask BEFORE any interactive answerer sees it: + * + * - `'ask'` (the default) — delegate to the composed answerers; with none + * composed the chain falls through to the fail-closed `'unavailable'` + * (exactly today's behavior). + * - `'never'` — never prompt anyone: every ask resolves `'rejected'` + * deterministically. The strict headless stance (CI, unattended runs) and + * the only policy value stated in the system prompt — unlike `'ask'`, its + * outcome is knowable without asking, so stating it cannot overclaim. + */ type ApprovalPolicy = 'ask' | 'never' ``` @@ -33,6 +53,10 @@ The prompt section states the deterministic `never` behavior and records either `ApprovalRequest` identifies the agent and tool action closely enough to route and audit the question. It deliberately omits tool arguments: an answerer attaches the prompt to the already-streamed tool call through `callId` instead of rendering a second copy that could drift. ```ts type-equiv +/** + * Readonly same-process permission question. `callId` links to an already + * presented tool call, so arguments are not duplicated here. + */ interface ApprovalRequest { /** * The agent on whose behalf the question is asked. Routes the question (a diff --git a/docs/core-data-structures/bash.md b/docs/core-data-structures/bash.md index c61c24add6..060d249eb7 100644 --- a/docs/core-data-structures/bash.md +++ b/docs/core-data-structures/bash.md @@ -9,10 +9,12 @@ Source: [`packages/bash/bash/src/types.ts`](../../packages/bash/bash/src/types.t `DSH_*` variables are Harness-owned child-process facts. The model-facing bash tool collects them through `ctx.bashEnv` and passes them through `BashExecRequest.dshEnv`; executors remove inherited `DSH_*` names before merging the current snapshot. ```ts type-equiv +/** One environment key inside the managed {@link DSH_ENV_PREFIX} namespace. */ type DshEnvironmentKey = `${typeof DSH_ENV_PREFIX}${string}` ``` ```ts type-equiv +/** Trusted DeepSeek Harness variables for one bash execution. */ type DshEnvironment = Readonly> ``` @@ -21,6 +23,12 @@ type DshEnvironment = Readonly> The seam separates the **model-/plugin-facing request** (optional `workdir`/`timeoutMs`/`stdoutMaxBytes`, filled from config or request policy) from the **fully-resolved spec** the executor acts on (those fields required). The tool layer calls `ctx.bash.resolve(request)` between them — this is the repo's "explicit > implicit at package seams" rule made concrete: the reader of a `BashExecSpec` never wonders where the working directory or output budget came from. ```ts type-equiv +/** + * A caller's execution REQUEST: `workdir` and `timeoutMs` are optional and + * filled by {@link BashExecutor.resolve} from the implementation's config. + * This is the model-/plugin-facing shape; pass it to `resolve()` to obtain a + * fully-resolved {@link BashExecSpec}. + */ interface BashExecRequest { command: string /** Working directory override (default: implementation-configured). */ @@ -59,24 +67,17 @@ interface BashExecRequest { * reject non-`DSH_*` names supplied through this managed channel. */ dshEnv?: DshEnvironment | undefined - /** - * Explicit per-call sandbox-policy input, overriding the executor's - * configured default mode for THIS call. Never a silent default: a - * consumer sets it only from an explicit policy source — an - * `'allowed-once'` grant a human just issued through `ctx.approval` (the - * escalation flow in the sandbox RFC § Escalation, which outranks), or the - * session's standing override folded from its own `bash/sandbox-mode` - * events (the sandbox RFC § Per-session mode switching — the user's recorded per-session - * choice). A sandboxing executor confines THIS call under the given mode; - * a non-sandboxing executor carries the field and confines nothing (the - * tool layer stamps neither escalation nor overrides without a sandboxing - * executor — see {@link BashExecutor.sandboxMode}). - */ + /** Explicit per-call sandbox mode override. */ sandboxMode?: SandboxMode | undefined } ``` ```ts type-equiv +/** + * A resolved execution spec. {@link BashExecutor.resolve} fills and caps the + * required fields; {@link BashExecutor.start} ignores `timeoutMs` because + * background processes have no executor timeout. + */ interface BashExecSpec { command: string workdir: string @@ -88,31 +89,18 @@ interface BashExecSpec { stdoutMaxBytes: number /** Abort signal — implementations kill the command when it fires. */ signal?: AbortSignal | undefined - /** - * Bytes to write to the command's stdin (then close it), carried through - * verbatim from {@link BashExecRequest.stdin}. It has no config default, so - * a missing value means "no stdin" and remains an ordinary optional. - */ + /** Bytes to write to stdin before closing it; absent means no stdin. */ stdin?: string | undefined /** - * Extra environment entries, carried through verbatim from - * {@link BashExecRequest.env} and merged by the implementation AFTER its - * credential scrub (an explicit entry wins even when its name matches the - * scrub pattern). OPTIONAL on the spec for the same reason as `stdin` — no - * config default, absent means "no extra env". + * Ordinary environment entries carried through from + * {@link BashExecRequest.env}. `DSH_*` remains reserved for {@link dshEnv}. + * OPTIONAL on the spec for the same reason as `stdin`: absent means no + * ordinary extra environment. */ env?: Record | undefined /** Managed `DSH_*` snapshot; implementations reject ordinary names. */ dshEnv?: DshEnvironment | undefined - /** - * The sandbox mode this call executes under, required-but-nullable so every - * resolved spec states its policy. A sandboxing executor's `resolve()` stamps - * the effective mode (the request's explicit override, else its configured - * default) so `run()`/`start()` read the spec, never the config; - * a non-sandboxing executor carries the request value through verbatim and - * ignores it (`undefined` under such an executor means what its README says: - * unconfined execution). - */ + /** Resolved sandbox mode; ignored by executors that do not confine. */ sandboxMode: SandboxMode | undefined } ``` @@ -126,24 +114,31 @@ interface BashExecSpec { The outcome of one completed (or killed) foreground run. Orthogonal outcomes are reported **independently** — a process can both time out AND exit 0 because it trapped the signal — so `timedOut`, `aborted`, `signal`, and `exitCode` are each their own field; a caller never reads a cut-short run as a clean success. ```ts type-equiv +/** The outcome of one completed (or killed) foreground run. */ interface BashRunResult { /** Exit code; null when the process died from a signal. */ exitCode: number | null /** Terminating signal (e.g. 'SIGTERM'); null on normal exit. */ signal: NodeJS.Signals | null - /** True when the executor's own timeout killed the command. */ + /** + * True when the executor's own timeout was the FIRST cause to cut the command + * short. Mutually exclusive with {@link aborted}: one fused deadline drives + * both the timeout and the caller's cancellation, so a timeout and an abort + * racing before process close report the single first-abort cause, not both + * (see the [timeout-library RFC](../../../../docs/rfc/implemented/architecture/2026-07-06-timeout-deadline-library.md)). + */ timedOut: boolean - /** True when the caller's AbortSignal killed the command. */ + /** + * True when the caller's `AbortSignal` was the FIRST cause to kill the command + * (and it was not the executor's own timeout). Mutually exclusive with + * {@link timedOut} — see there for the first-cause classification. + */ aborted: boolean /** The effective timeout applied to this run (after defaulting/capping). */ timeoutMs: number stdout: CollectedOutput stderr: CollectedOutput - /** - * Sandbox facts, present iff a sandboxing executor ran the command — an - * unsandboxed executor (e.g. `dsh-bash-local`) never sets it. See - * {@link BashSandboxInfo} for the `denied` classification semantics. - */ + /** Sandbox execution facts, absent for an unsandboxed executor. */ sandbox?: BashSandboxInfo } ``` @@ -151,6 +146,7 @@ interface BashRunResult { Each stream is a `CollectedOutput` — the (possibly truncated) text plus recovery info. When truncated, `text` is the **tail** and the complete stream spills to a private file: ```ts type-equiv +/** One captured stream: the (possibly truncated) text plus recovery info. */ interface CollectedOutput { /** Collected text — the TAIL of the stream when truncated. */ text: string @@ -168,36 +164,19 @@ A sandbox-consuming executor exposes its configured fallback through `BashExecut A sandboxed run reports its mode, conservative denial classification, and enforcement completeness. `runnerFailed` marks a sandbox runner failure before the command ran; foreground execution throws `SANDBOX_UNAVAILABLE`, while a settled background process has only its facts channel. ```ts type-equiv +/** + * Sandbox facts for one run, present iff a sandboxing executor handled it. + * Facts are reported independently of process exit status so callers can + * distinguish command failures from policy denials and runner failures. + */ interface BashSandboxInfo { /** The mode the command actually ran under. */ mode: SandboxMode - /** - * True when the executor classifies this run's failure as the sandbox - * denying a file operation. The classification is CONSERVATIVE (a failed - * exit whose stderr carries a filesystem-permission signature) and reads - * the COLLECTED stderr — the bounded in-memory tail per - * {@link CollectedOutput} semantics, so a signature that survives only in a - * spill file is missed toward `denied: false`. A plain command failure - * keeps `denied: false` even under a sandboxed mode. - */ + /** Whether the sandbox denied a file operation. */ denied: boolean - /** - * How completely the runner enforced `mode`'s file effects — see - * {@link SandboxEnforcement}. Absent exactly when `mode` is - * `danger-full-access`: nothing is confined, so there is no enforcement to - * report. - */ + /** How completely the selected runner enforced the requested mode. */ enforcement?: SandboxEnforcement - /** - * True when the executor classifies this failure as the SANDBOX RUNNER - * itself failing (missing binary, refused profile, fail-closed refusal - * before exec) — the command NEVER RAN; this is a sandbox failure, not a - * task failure, and it outranks `denied` (a runner's own error text can - * contain denial words). Only ever stamped on settled BACKGROUND tasks: a - * foreground run surfaces the same condition as the thrown - * `SANDBOX_UNAVAILABLE` error instead (the foreground path has an error - * channel; a settled task's facts are its only channel). - */ + /** Whether the sandbox runner failed before the command could run. */ runnerFailed?: boolean } ``` @@ -209,6 +188,11 @@ One more piece completes the vocabulary: the `SANDBOX_UNAVAILABLE` error code (o `start()` returns a handle with no id or owner. `dsh-tool-bash` adapts it into `ctx.tasks.start()` hooks; the generic runtime then owns task identity and lifecycle. `done` resolves when the process closes and never rejects, reads remain valid after settlement, and sandbox facts are stamped before `done` resolves. ```ts type-equiv +/** + * A background process handle returned by {@link BashExecutor.start}. It is the + * only access path; buffered output remains readable after exit. Executor + * disposal kills running processes and awaits {@link done}. + */ interface BashProcess { /** Process lifecycle state (settled exactly once). */ status: BashProcessStatus @@ -237,6 +221,7 @@ interface BashProcess { `readOutput()` returns the incremental delta and spill recovery facts: ```ts type-equiv +/** One incremental {@link BashProcess.readOutput} read. */ interface BashProcessRead { /** Output produced since the previous read (stderr in a marked section). */ delta: string diff --git a/docs/core-data-structures/code-runtime.md b/docs/core-data-structures/code-runtime.md index 9237a3cce9..cea69180af 100644 --- a/docs/core-data-structures/code-runtime.md +++ b/docs/core-data-structures/code-runtime.md @@ -9,6 +9,12 @@ Source: [`packages/code-runtime/code-runtime/src/types.ts`](../../packages/code- A `CodeRunRequest` carries **everything the runtime acts on** — per the "explicit > implicit at package seams" rule, defaulting (time budgets, output caps) is the implementation's validated config, never a hidden `??` inside `run()`: ```ts type-equiv +/** + * One run: the program source plus everything the runtime acts on. Per the + * explicit-over-implicit convention, defaulting (time budgets, output caps) + * is the implementation's validated config — a request carries no optional + * tuning knobs for a hidden `??` to fill in. + */ interface CodeRunRequest { /** * The program source, in the runtime's {@link ../index.ts | language}. It @@ -31,6 +37,11 @@ interface CodeRunRequest { The result reports an error as a **field**, never a rejection of `run()` — reporting a failed program is the caller's job, not an exception path (mirroring `BashExecutor.run`'s resolve-on-failure contract): ```ts type-equiv +/** + * The outcome of one run. An error is a FIELD on a resolved result, never a + * rejection of `run()` — reporting a failed program is the caller's job, not + * an exception path. + */ interface CodeRunResult { /** * The program's completion value (its top-level `return`), when it ran to @@ -51,6 +62,13 @@ interface CodeRunResult { Each `CodeBindingNamespace` becomes one global object of async callables inside the program (the Code Mode consumer passes one: `tools`). Arguments and resolutions must be structured-cloneable — a runtime may bridge calls across a serialization boundary — and a runtime treats binding names as hostile input (`__proto__` is an ordinary own property, never a prototype collision): ```ts type-equiv +/** + * A named group of {@link CodeBindingFunction}s the runtime exposes to the + * program as one global object (e.g. `tools`). Function names are arbitrary + * strings — a runtime must treat names like `__proto__` or `constructor` as + * ordinary own properties (null-prototype construction), never as prototype + * collisions. + */ interface CodeBindingNamespace { /** The global identifier the program sees (must be a valid JS identifier). */ global: string @@ -60,6 +78,14 @@ interface CodeBindingNamespace { ``` ```ts type-equiv +/** + * One host-side function exposed to the program as an async callable. The + * runtime bridges calls to it (possibly across a serialization boundary), so + * `args` and the resolution value MUST be structured-cloneable; a runtime + * rejects a non-cloneable value with a descriptive error rather than + * corrupting the run. A rejection of this function surfaces inside the + * program as a rejection of the corresponding call. + */ type CodeBindingFunction = (args: unknown) => Promise ``` @@ -70,6 +96,16 @@ Logs are plain strings in emission order. The runtime captures the program's con Failure kinds are **orthogonal outcomes reported independently** (per [defensive-patterns](../defensive-patterns.md)): a budget expiry is not an exception, an abort is not a timeout, and a substrate death (e.g. OOM) is neither: ```ts type-equiv +/** + * Why a run failed. The kinds are orthogonal outcomes reported independently + * (per docs/defensive-patterns.md): a budget expiry is not an exception, an + * abort is not a timeout, and a substrate death is neither. + * + * - `'exception'` — the program threw or failed to parse/transform. + * - `'timeout'` — an implementation-owned budget expired; the message says which. + * - `'abort'` — {@link CodeRunRequest.signal} fired. + * - `'worker-exit'` — the execution substrate died without settling (e.g. OOM). + */ interface CodeRunFailure { /** The failure class (see the interface doc for each kind's meaning). */ kind: 'exception' | 'timeout' | 'abort' | 'worker-exit' diff --git a/docs/core-data-structures/compaction.md b/docs/core-data-structures/compaction.md index 11f895b2a8..ccc212a895 100644 --- a/docs/core-data-structures/compaction.md +++ b/docs/core-data-structures/compaction.md @@ -23,6 +23,7 @@ These variants are merged inside a `declare module '@deepseek-ai/dsh-session'` b What a successful compaction returns to its caller: the bookkeeping-event seqs, raw summary, shadowed range and seqs, and estimated token count. ```ts type-equiv +/** Result of a successful compaction operation. */ interface CompactionResult { /** The seq of the appended `compact/start` event. */ startSeq: number diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dbf635922d..dfb59b3ac6 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -36,7 +36,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` | | [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality | -> Type definitions on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). Inline JSDoc is omitted for readability; follow the source link for the full contracts. +> Type declarations and their JSDoc on this page are pasted **verbatim** from source and drift-checked by `pnpm run verify-type-equiv` (see [development.md](../development.md#documenting-types-verbatim-ts-type-equiv)). FIXME(catalog-verbs): the drift gate covers only the nouns (the pasted type shapes); every method surface on these pages is hand-written prose. core-data-structures should probably also generate the *verbs* — the public methods of the cataloged classes — so a signature change cannot silently outdate the catalog. @@ -83,6 +83,7 @@ The `Branded` primitive lives in its own type-only package, [dsh-brand](../.. Source: [`packages/util/brand/src/index.ts`](../../packages/util/brand/src/index.ts) ```ts type-equiv +/** A string carrying a compile-time-only brand `B`. */ type Branded = string & { readonly [BRAND]: B } ``` @@ -95,6 +96,10 @@ A conversation is `Message`s; a message is an array of typed **content blocks**. Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) ```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock @@ -108,6 +113,7 @@ The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBl A `Message` is a role plus blocks. Loop-derived assistant messages carry their durable provider/model identity and optional adapter-private replay metadata: ```ts type-equiv +/** Provider ownership and adapter-private replay data for an assistant message. */ interface AssistantProvenance { /** Provider route that produced the message. */ provider: string @@ -123,6 +129,10 @@ interface AssistantProvenance { ``` ```ts type-equiv +/** + * A single message in a conversation history. Loop-derived assistant messages + * always carry provenance; callers may omit it on hand-built foreign history. + */ interface Message { role: 'system' | 'user' | 'assistant' content: ContentBlock[] @@ -134,6 +144,10 @@ interface Message { Where a message came from is itself a merge-extensible sum type: ```ts type-equiv +/** + * Where a message (or injected content) came from. + * Merge-extensible sum type — plugins add their own `kind`s. + */ interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } @@ -155,6 +169,7 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) Provider and model discovery uses small provider-neutral descriptors. A model catalog is advisory: routing still keys on a registered provider, and an adapter may accept unlisted model ids. ```ts type-equiv +/** Display metadata for one registered provider route. */ interface LlmProviderInfo { /** Provider route key used by {@link GenerateOptions.provider}. */ id: string @@ -164,6 +179,7 @@ interface LlmProviderInfo { ``` ```ts type-equiv +/** One adapter-discovered model; catalog membership is advisory, not request validation. */ interface LlmModelInfo { /** Provider route that owns this model entry. */ provider: string @@ -177,6 +193,7 @@ interface LlmModelInfo { ``` ```ts type-equiv +/** A single model request, fully assembled. */ interface GenerateOptions { /** Registered provider route selecting the adapter instance. */ provider: string @@ -212,6 +229,10 @@ interface GenerateOptions { Why a model response stopped is a merge-extensible reason: ```ts type-equiv +/** + * Why a model response stopped. + * Merge-extensible so adapters can surface provider-specific reasons. + */ interface FinishReasonMap { 'stop': { kind: 'stop' } 'tool-calls': { kind: 'tool-calls' } @@ -226,6 +247,13 @@ interface FinishReasonMap { `GenerateOptions.tools` carries `ToolSchema` — the JSON-schema description of a tool, as sent to the model. It is declared in dsh-llm (not dsh-tools) precisely because it is part of the request the loop assembles every step: ```ts type-equiv +/** + * JSON-schema description of a tool, as sent to the model. + * + * Declared here (not in dsh-tools) because it is part of {@link GenerateOptions}; + * dsh-tools' ToolDefinition and dsh-system-prompt's PromptAssembly both import + * it from this package. + */ interface ToolSchema { name: string description: string @@ -247,6 +275,11 @@ On the wire, a loop-built request reads in this order: the `system` slot (the re FIXME(call-config-shape): revisit the exact definition of this type — which fields are genuinely epoch-level for cache purposes (`model` certainly; the sampling scalars sit here out of caution), and where provider-specific extras (reasoning options, extra body params) belong when an adapter needs them. ```ts type-equiv +/** + * Provider + model + sampling scalars of one conversation's requests. Every field maps + * 1:1 onto the same-named `GenerateOptions` field; the loop builds requests + * from the logged header rather than accepting these per call. + */ interface LlmCallConfig { provider: string model: string @@ -263,6 +296,19 @@ A `Session` is an **append-only log** of typed `SessionEvent`s — the single so Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ type SessionEvent = { [K in SessionEventType]: { type: K @@ -275,7 +321,9 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ @@ -295,35 +343,29 @@ Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types `InjectOptions` extends ordinary message attribution with context-only framing and durable model-hidden JSON metadata: ```ts type-equiv +/** Options specific to durable synthetic context injection. */ interface InjectOptions extends SendOptions { + /** Keep the canonical context tag, or send caller-owned framing verbatim. */ envelope?: ContextEnvelope + /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } ``` ```ts type-equiv +/** Public agent handle; its concrete implementation is internal to `@deepseek-ai/dsh-agent-loop`. */ interface Agent { + /** The single identity shared with {@link session}. */ readonly id: SessionId readonly options: AgentOptions readonly session: Session readonly status: AgentStatus - - /** - * The agent's scope context (`@deepseek-ai/dsh-scope`, key = this agent): - * registrations through it — tools, prompt sections/variables, listeners, - * restrictions — are visible to this agent only and unwind when it is - * disposed; `agent.ctx.on('agent/…')` listeners fire only for this agent. - */ + /** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */ readonly ctx: Context /** - * Queue a user message. Starts a turn when idle; otherwise waits for the next - * turn. Content and the resolved source are accepted as one detached, - * deeply-frozen lossless-JSON record before notification or enqueue, so - * caller or `agent/queued` listener in-place mutation cannot change later - * log/model input. Throws synchronously when either value is not losslessly - * JSON-serializable; `agent/prompt-submit` may still return an explicit - * replacement. + * Queue detached, frozen lossless-JSON input; starts a turn when idle. + * Invalid input throws synchronously before notification or enqueue. */ send(content: ContentBlock[], options?: SendOptions): void @@ -335,77 +377,25 @@ interface Agent { steer(content: ContentBlock[], options?: SendOptions): void /** - * Inject in-session context (file-change notices, skill content, cron - * notifications, …): appends a `context/message` session event the next model - * request sees at its chronological position, rendered as synthetic context - * rather than a user prompt. The default uses the canonical context tag; - * `options.envelope: 'raw'` preserves caller-owned framing. Does not run the - * model. - * - * In an open turn, inject appends at the current log position except while - * the current tool-call batch executes: accepted context waits FIFO until the - * batch settles, then appends after every recorded result and before turn - * close even when execution is interrupted. - * - * Turn-enclosure (the turn-enclosure RFC): an inject while a turn is open joins that turn; - * an inject while idle wraps its `context/message` in a one-shot `injection` - * turn (`turn/start` → `context/message` → `turn/end`) and checkpoints it for - * durability, so every event stays inside a turn and a persistence backend - * never loses a between-turn notice. The idle checkpoint is fire-and-forget - * (inject is synchronous): a failing flush is reported via `agent/error` - * (step `0`) and the logger, never thrown into the caller. - * - * Live-adapter review has validated the canonical tagged-envelope rendering - * against current DeepSeek behavior; provider-specific mismatches belong in - * that adapter, not in the canonical session vocabulary. + * Append detached model-facing context without running the model. An open-turn + * injection joins at the current log position unless the current tool batch is + * executing; then it waits FIFO until that batch settles and drains before turn + * close even when interrupted. Idle injection uses a one-shot turn and durability + * checkpoint. Disposal awaits idle checkpoints; flush failures report through `agent/error`. */ inject(content: ContentBlock[], options?: InjectOptions): void /** - * Cancel ALL pending work for the agent. `cancel()`: - * - * - clears the queued FIFO (un-started prompts never run) and the steering - * FIFO (steering for the cancelled turn is dropped, not re-enqueued); - * - aborts the in-flight step if one is running (the turn ends `aborted`); - * - drops a turn that is about to start (a `cancel()` landing in the - * pre-step window — after a `send()` queued but before the loop flips to - * `running`, or after `running` is emitted but before the first step) so - * that queued prompt does not run and cannot be batched into the cancelled - * turn. - * - * After `cancel()`, `whenIdle()` resolves on the post-cancel quiescent state. - * `cancel()` on an idle agent with nothing queued or running is a safe no-op - * — it does NOT arm anything that would drop a later legitimate prompt. + * Clear queued and steering work, including work waiting to start, and abort + * the active step. The supplied reason is preserved across pre-step and active + * cancellation windows, and `whenIdle()` resolves after cancellation reaches + * quiescence. Idle cancellation is a no-op and does not arm a later cancel. */ cancel(reason?: string): void - /** - * Resolve once the agent has reached quiescence after settling out of - * `running`, or immediately if it is already idle with no queued work. A - * non-owner's quiescence-observation hook: a consumer that does NOT own the - * agent's lifecycle awaits this to proceed only after queued/running work has - * fully stopped, rather than returning while the driver is still streaming or - * about to start a queued turn — without itself tearing the agent down. (A - * lifecycle OWNER does not need it: `AgentHandle.dispose()` already awaits the - * loop-exit promise directly as part of stopping and unregistering. So this is - * for a non-owning observer — e.g. a test awaiting a turn to settle, or a - * monitor — that wants the settle signal but must not dispose the agent.) - * - * "Quiescence", not merely "status changed": a disposed agent emits - * `agent/status('disposed')` from inside its disposer, BEFORE the driver loop - * has unwound — so `whenIdle()` resolving on `disposed` must wait for the loop - * to actually exit (the implementation chains the loop-exit promise), not just - * observe the status flip. A mid-step disposal that never reaches `idle` still - * unblocks the await this way. - */ + /** Resolve at idle quiescence; disposal waits for driver exit rather than only the status transition. */ whenIdle(): Promise - // Subagent delegation is realized on top of this interface by the - // `@deepseek-ai/dsh-subagent` seam, not by a method here: a backend creates - // the child through `ctx.agents.create` (fork seeds the child Session with a - // balanced prefix of the parent's log via `CreateAgentOptions.seed`; spawn - // starts fresh) and drives it as an ordinary Agent handle, so steer() and - // event subscription work uniformly. See docs/core-data-structures/subagent.md. } ``` @@ -420,10 +410,13 @@ Each `agent/*` interception waterfall returns a small, seam-specific typed union Source: [`packages/core/agent/src/types.ts`](../../packages/core/agent/src/types.ts) ```ts type-equiv +/** Model-facing context injected by a listener; `source` prevents plugin text from being labeled as user input. */ interface HookContext { content: ContentBlock[] source: MessageSource + /** Keep the canonical context tag, or use caller-owned framing verbatim. */ envelope?: ContextEnvelope + /** Opaque JSON state retained in the session event but hidden from the model. */ meta?: JsonValue } ``` @@ -431,6 +424,11 @@ interface HookContext { `agent/prompt-submit` returns a `PromptDecision` (allow a drained queued message — optionally rewriting its `content` or attaching `additionalContexts` — or block it; a batch whose every prompt is blocked opens a zero-step turn that ends `rejected`): ```ts type-equiv +/** + * Prompt interception result. `allow.content` replaces the prompt and each + * `additionalContexts` entry becomes a separate context message. `block` records a + * durable `prompt/blocked`; an all-blocked batch ends a zero-step rejected turn. + */ type PromptDecision = | { kind: 'allow'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; reason: string } @@ -439,6 +437,7 @@ type PromptDecision = `agent/turn-continuation` returns a `ContinuationDecision` (the loop's default is `continue` when the step had tool calls or steering was injected, else `stop`; a `continue` `reason` is recorded as next-step steering in the same turn and therefore carries no context envelope or metadata — the typed `/goal` pattern): ```ts type-equiv +/** Turn continuation override; a continue reason is recorded as next-step steering in the same turn. */ type ContinuationDecision = | { action: 'stop' } | { action: 'continue'; reason?: { content: ContentBlock[]; source: MessageSource } } @@ -447,12 +446,18 @@ type ContinuationDecision = `agent/turn-stop` returns the stop-only `ContinuationStop` subset or `undefined`. The loop calls this serial checkpoint after folding the ordinary decision, its reason, and pending steering; a stop is terminal and discards pending steering. ```ts type-equiv +/** + * The terminal subset of {@link ContinuationDecision}. A listener on + * `agent/turn-stop` returns this to make the already-composed continuation + * outcome terminal; `undefined` abstains. + */ type ContinuationStop = Extract ``` `agent/session-start` carries a `SessionStartSource` (why the session lifecycle began; a bridge keys its SessionStart matcher on it): ```ts type-equiv +/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */ type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact' ``` diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index 6438b1ba03..f29258857e 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -11,8 +11,17 @@ Provider source: [`packages/fs/fs/src/types.ts`](../../packages/fs/fs/src/types. Every operation resolves a user-supplied path to an opaque backend target first. Consumers may display `displayPath`, but must not parse `targetKey` (a branded opaque id) or assume it is a local absolute path. ```ts type-equiv +/** + * A path resolved by a backend into a stable identity. `resolve()` produces + * this; every other operation takes it. + */ interface FsTarget { + /** Opaque key for stale guards and target lookup. */ targetKey: FsTargetKey + /** + * Path for model/UI-facing output. May be a local absolute path, + * workspace-relative path, or remote URI depending on the backend. + */ displayPath: string } ``` @@ -20,19 +29,40 @@ interface FsTarget { The backend owns file-version tokens — the freshness token a write/edit guards against. The policy plugin stores them for stale checks; consumers do not interpret them. Both ids are branded opaque strings. ```ts type-equiv +/** + * Opaque key for stale guards and target lookup. The local backend uses a + * realpath-like string; a remote backend might use a workspace URI or file id. + * Consumers MUST NOT parse it or assume it is a local absolute path. + */ type FsTargetKey = Branded<'FsTargetKey'> ``` ```ts type-equiv +/** + * Opaque file-version token — the freshness token a write/edit guards against. + * The local backend derives it from high-resolution stat identity and freshness + * fields; a remote backend might use a revision id. The policy layer records it + * for stale checks; consumers may display related metadata but MUST NOT + * interpret this token. + */ type FsVersion = Branded<'FsVersion'> ``` `stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. ```ts type-equiv +/** + * Metadata about a target — what {@link FileSystem.stat} returns. Lets the + * policy layer reject directories/special files before reading and choose + * `readText` vs `streamText` from `size` without probing by failure. `version` + * is the freshness token. `undefined` from `stat` means the target is absent. + */ interface FsInfo { + /** Opaque freshness token of the target right now. */ version: FsVersion + /** Whether the target is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' + /** Byte size of a regular file, when the backend can report it. */ size?: number } ``` @@ -40,9 +70,18 @@ interface FsInfo { `lstat` is the path-level no-follow metadata primitive. It takes a path instead of an `FsTarget` because `resolve` intentionally follows symlinks to produce stable identity; consumers that need trust-boundary checks can call `lstat` first and reject `symlink` before resolving. ```ts type-equiv +/** + * Metadata about a path without following the final path component when it is a + * symbolic link. Unlike {@link FsInfo}, this path-level probe can report + * `symlink` so consumers with trust-boundary rules can reject repository-owned + * links before resolving a target. + */ interface FsPathInfo { + /** Opaque freshness token of the path entry right now. */ version: FsVersion + /** Whether the path entry is a regular file, directory, symlink, or other. */ type: 'file' | 'directory' | 'symlink' | 'other' + /** Byte size of the path entry, when the backend can report it. */ size?: number } ``` @@ -50,11 +89,20 @@ interface FsPathInfo { `listDir` returns direct child entries in stable name order. Each entry carries the child basename, type, resolved target, and cheap metadata when the backend can report it. It must not read file contents, so `size` is only for regular files and `version` is metadata-derived. Broken or disappeared children may be returned as `other` without metadata; permission or backend I/O failures while listing or resolving child metadata fail the whole listing with `FS_PERMISSION_DENIED` or `FS_IO_ERROR`. ```ts type-equiv +/** + * One direct child returned by {@link FileSystem.listDir}. Listing returns + * metadata and resolved targets only; it must not read file contents. + */ interface FsDirEntry { + /** Basename of the child inside the listed directory. */ name: string + /** Whether the child is a regular file, a directory, or something else. */ type: 'file' | 'directory' | 'other' + /** Resolved child target for follow-up operations. */ target: FsTarget + /** Opaque freshness token when the backend can report metadata cheaply. */ version?: FsVersion + /** Byte size of a regular file, when the backend can report it. */ size?: number } ``` @@ -64,16 +112,33 @@ interface FsDirEntry { Both `writeText` and `editText` take their version guard OPTIONALLY: omit it for an unconditional (bare-provider) mutation, supply it to guard. `writeText`'s guard is an `FsWriteIntent` — `createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only when the target exists at the observed version, else `FS_STALE_VERSION`. Omitting `expected` unconditionally creates-or-overwrites. The union itself carries only the two guarded intents; "no guard" is expressed by omission, so write and edit share one symmetric `expected?` shape. ```ts type-equiv +/** + * Guarded write intent. `createIfAbsent` rejects an existing target with + * `FS_NOT_OBSERVED`; `replaceIfVersion` rejects absence or mismatch with + * `FS_STALE_VERSION`. Omitting the intent from `writeText` means unconditional + * create-or-overwrite, not a third union arm. + */ type FsWriteIntent = | { kind: 'createIfAbsent' } | { kind: 'replaceIfVersion'; version: FsVersion } ``` ```ts type-equiv +/** Outcome of a full-file write. */ interface FsWriteOutcome { + /** Whether the write created a new file or replaced an existing one. */ operation: 'create' | 'update' + /** Opaque version of the file after the write. */ version: FsVersion + /** + * The file's content BEFORE the write, or `null` when the file did not exist + * (a create) or was undiffable (binary/non-UTF-8). LF-normalized storage text + * (the diff basis), never a diff — a consumer computes the result-time + * contextual diff from `before`/`after` when `before` is present, else falls + * back to a whole-file diff. + */ before: string | null + /** The file's content AFTER the write, LF-normalized to share `before`'s diff basis. */ after: string } ``` @@ -81,17 +146,29 @@ interface FsWriteOutcome { `editText` is a provider-level mutation, not a `read` plus `write` composed elsewhere. When guarded it verifies the expected version BEFORE literal matching (so a stale edit reports `FS_STALE_VERSION`, not a match failure against newer content); unguarded it edits the current content. Either way it applies the replacement and writes atomically — keeping matching, line-ending handling, the stale check, and atomic replacement inside one mutation critical section — and a missing target reports `FS_STALE_VERSION` on both paths. ```ts type-equiv +/** A literal-replacement edit request. */ interface FsEditRequest { + /** Literal non-empty text to replace. Must match exactly (after line-ending normalization). */ oldString: string + /** Literal replacement text. An empty string deletes the matched text. */ newString: string + /** Replace every match instead of requiring exactly one. */ replaceAll: boolean } ``` ```ts type-equiv +/** Outcome of a literal edit. */ interface FsEditOutcome { + /** Opaque version of the file after the edit. */ version: FsVersion + /** + * The file's content BEFORE the edit. Raw storage text (LF-normalized by the + * backend), never a diff — a consumer computes the result-time contextual diff + * (the applied hunk with context) from `before`/`after`. + */ before: string + /** The file's content AFTER the edit. */ after: string } ``` @@ -107,8 +184,20 @@ interface FsEditOutcome { The policy plugin needs just enough execution context to derive the observed-state owner by narrowing the opaque `object` actor the `fs/*` events carry. `ToolExecution` satisfies this shape, so `dsh-tool-fs` passes its execution object through as the actor without making `dsh-fs-policy` import the tool, agent, or session packages. ```ts type-equiv +/** + * Minimal structural view of a tool execution the policy plugin needs to derive + * an observed-state owner. `@deepseek-ai/dsh-tools`' `ToolExecution` satisfies + * this shape, so the tool passes its `exec` straight through as the opaque + * `object` actor on the `fs/*` events; this plugin narrows that actor to this + * shape without importing `dsh-tools`, `dsh-agent`, or `dsh-session`. + * + * The owner is `agent.session` when present. It is treated as an opaque object + * identity (a `WeakMap` key); this package never reads any of its fields. + */ interface FsPolicyExec { + /** The agent on whose behalf the call runs, when there is one. */ agent?: { + /** The session that owns observed-file state, used as an opaque key. */ session?: object } } @@ -119,10 +208,15 @@ interface FsPolicyExec { A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv +/** Outcome of a bounded text read — what {@link formatReadOutput} renders. */ interface FileReadOutcome { + /** 1-based first line requested. */ offset: number + /** Returned lines, already numbered. */ lines: FileTextLine[] + /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ totalLines: number + /** Whether selected output hit the byte cap before EOF or the requested limit. */ truncatedByBytes?: true } ``` @@ -136,6 +230,11 @@ Observed state is a `WeakMap>` held inside th Filesystem failures use stable `FsErrorCode` strings carried by `FsError` (`HarnessError`). The tool registry preserves `{ name, code }` on error results, so retry, permission, and UI layers can branch without parsing text. ```ts type-equiv +/** + * Stable, machine-routable codes for filesystem failures. Carried on + * {@link FsError}; the tool registry surfaces `{ name, code }` on `isError` + * results so retry/permission/UI layers can branch without parsing messages. + */ type FsErrorCode = | 'FS_NOT_FOUND' | 'FS_NOT_DIRECTORY' diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index f9489a6d7f..0ef9ae02f7 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -9,6 +9,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) A streaming response interleaves several typed blocks (text, reasoning, multiple tool calls). `index` ties each delta to its block; `block-end` carries the fully-assembled `ContentBlock` so consumers don't have to re-assemble deltas themselves. It is a **closed** discriminated union — a `switch` over `type` ends with `assertNever`, so adding a variant breaks compilation at every consumer that must handle it. ```ts type-equiv +/** + * Raw streaming protocol emitted by adapters. + * Block indexes correlate interleaved deltas, and `block-end` carries the + * assembled block. Adapters emit usage before the terminal finish and nothing + * afterward; tool arguments remain raw JSON strings. Failures either throw or + * end with `error`/`aborted`, and consumers must handle both paths. + */ type StreamChunk = | { type: 'block-start'; index: number; blockType: ContentBlockType } | { type: 'text-delta'; index: number; text: string } @@ -41,9 +48,19 @@ This contract was pinned down by two deliberately independent implementations: ` The static public application identity every adapter sends to providers ([`packages/llm/llm/src/attribution.ts`](../../packages/llm/llm/src/attribution.ts)). `attributionHeaders(identity?)` maps it to the standard `User-Agent` header only; OpenRouter-specific app attribution headers are intentionally not supported by this contract. The default `APP_IDENTITY` sources its version from the package manifest; every field is a public product fact - no secrets, paths, session ids, or per-user identifiers, and nothing per-request may influence the values. Rationale: [Mandatory `User-Agent` attribution](../rfc/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md). ```ts type-equiv +/** + * Static public application identity sent to LLM providers. + * + * Every field is a public product fact, safe on every request: no secrets, + * local paths, session ids, prompt text, or per-user identifiers belong here, + * and nothing per-request may influence the values. + */ interface AppIdentity { + /** `User-Agent` product token (lowercase, hyphenated). */ product: string + /** Product version; sourced from package metadata, never hand-copied. */ version: string + /** Public home URL of the app, used as the `User-Agent` comment. */ url: string } ``` @@ -53,6 +70,14 @@ interface AppIdentity { Per-call token accounting. Counts are **disjoint**: `inputTokens` is uncached input only; cached input is reported separately, and billed input is the sum of the three. Adapters whose providers fold cache hits into a single prompt total (DeepSeek's `prompt_tokens`) subtract them back out. ```ts type-equiv +/** + * Token accounting for one model call (cache fields are optional). + * + * Counts are DISJOINT: `inputTokens` is uncached input only; cached input is + * reported separately as `cacheReadTokens`/`cacheWriteTokens` (billed input = + * sum of the three). Adapters whose providers fold cache hits into a total + * prompt count (DeepSeek's `prompt_tokens`) subtract them out. + */ interface TokenUsage { inputTokens: number outputTokens: number @@ -73,6 +98,10 @@ interface TokenUsage { `ContentBlockType` (the key set the `index`-correlated blocks carry) derives from `ContentBlockMap`: ```ts type-equiv +/** + * Merge-extensible content blocks keyed by `type`. New core blocks must land + * with adapter, UI, and compaction support. + */ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index c1e02ae743..72ebcc5852 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -17,6 +17,11 @@ A backend that reloads a log crashed mid-turn finds an open `turn/start` with no `SessionPersistence.locate(meta)` synchronously resolves a backend-owned independent artifact without reading, creating, or flushing it. JSONL returns its absolute target path; SQLite returns `undefined` because sessions share one database. A returned path can therefore name a file that does not yet exist or lacks the current unflushed turn; it is a location hint, not authorization or a freshness guarantee. ```ts type-equiv +/** + * A backend-resolved, per-session local artifact location. The path is an + * absolute target path and can name an artifact that has not materialized yet. + * Consumers must treat it as a location hint, never as an authorization token. + */ interface SessionLocation { /** Backend-specific artifact kind, for example `jsonl`. */ readonly kind: string @@ -32,6 +37,9 @@ Per-session metadata travels **separately** from the event log: format version, Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/types.ts) ```ts type-equiv +/** + * Immutable validated storage metadata, kept outside the conversation event log. + */ interface SessionHeader { /** * On-disk format version, stamped from {@link SESSION_FORMAT_VERSION} when the @@ -48,13 +56,8 @@ interface SessionHeader { /** The session this one was forked from (seed lineage), if any. */ readonly parentSession?: SessionId /** - * How many leading events were INHERITED via a seed rather than produced by - * this session — the seed boundary. Set when a fork seeds a child with a - * prefix of the parent's log (= the seeded prefix length); absent/0 means the - * session produced all its own events. Persisted so a reload reconstructs the - * boundary instead of re-deriving it from the full stored log, and so a replay - * harness can skip the inherited prefix when deriving the child's OWN script - * (the seeded events are the parent's, not this child's model calls). + * How many leading events were inherited through a seed. Persisting this + * boundary lets resume and replay distinguish parent history from child work. */ readonly seedLength?: number } @@ -65,20 +68,17 @@ interface SessionHeader { Creating a `Session` through the store takes a `seed` (replay/fork an existing event log) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller supplies the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. ```ts type-equiv +/** + * Options for creating a {@link Session} via the store. `seed` replays/forks + * an existing event log; `meta` carries the caller-supplied storage fields the + * store folds into a {@link SessionHeader}. + */ interface CreateSessionOptions { /** Events to seed the new session with (replay/fork). */ readonly seed?: readonly SessionEvent[] /** - * Creation metadata. The store fills in `version`/`id` and defaults - * `createdAt` to now; the caller supplies the storage-level fields (validated - * absolute `cwd`, `parentSession` lineage, the seed boundary `seedLength`, and - * — when reconstructing a persisted session — the original `createdAt` to - * preserve it). - * - * `seedLength` is EXPLICIT, not inferred from `seed.length`: a reconstruction - * (resume/load) seeds the WHOLE stored log, so its `seed.length` is the full - * length, not the original boundary — the caller must pass the persisted - * boundary back. A fresh fork passes its actual seeded-prefix length. + * Storage metadata read once before publication. `seedLength` is explicit + * because a resumed seed contains the full stored log, not only its inherited prefix. */ readonly meta?: { readonly cwd?: string diff --git a/docs/core-data-structures/sandbox.md b/docs/core-data-structures/sandbox.md index 6b7b212373..5b20febc3e 100644 --- a/docs/core-data-structures/sandbox.md +++ b/docs/core-data-structures/sandbox.md @@ -9,18 +9,30 @@ Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox `SandboxMode` governs filesystem effects only. `read-only` denies writes except the required `/dev/null` sink; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv +/** + * File-effect policy for confined processes. `read-only` permits only required + * sinks such as `/dev/null`; `workspace-write` also permits the workspace and a + * backend-defined temp area; `danger-full-access` bypasses confinement. Network + * and process visibility are outside this vocabulary. + */ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' ``` Only the first two modes can be sent to a provider. A `danger-full-access` consumer spawns its original argv and does not call `ctx.sandbox`. ```ts type-equiv +/** A confining (non-`danger-full-access`) mode — the modes a {@link SandboxPolicy} can carry. */ type ConfinedSandboxMode = Exclude ``` Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. ```ts type-equiv +/** + * Enforcement completeness for this host. `partial` means an active backend or + * older kernel ABI cannot govern every promised file effect; callers requiring + * an absolute boundary must not treat it as `full`. + */ type SandboxEnforcement = 'full' | 'partial' ``` @@ -29,6 +41,15 @@ type SandboxEnforcement = 'full' | 'partial' The policy is fully resolved and carried per call. This permits concurrent consumers and one-shot escalated retries to ask the same provider for different boundaries without mutating provider state. ```ts type-equiv +/** + * What one confined execution is allowed to touch — carried PER CALL, not + * fixed on the provider: two consumers may confine under different policies + * at the same instant (bash under `read-only` while a confined child agent + * needs its state directory writable), and an approved escalated retry is a + * new call with a wider policy. Defaulting/resolution is the consumer's + * explicit step (its config owns the fallback chain); the provider treats + * the policy as fully specified. + */ interface SandboxPolicy { /** The file-effect mode this execution runs under. */ mode: ConfinedSandboxMode @@ -42,6 +63,11 @@ interface SandboxPolicy { `ConfinedArgv` is what the consumer spawns. Besides the replacement argv, it carries the backend's enforcement fact and two orthogonal stderr dialects. `denialSignatures` identify the confined command being blocked while the sandbox works correctly. `runnerFailureSignatures` identify the sandbox runner refusing or failing before it executes the command; consumers check these first and surface a sandbox infrastructure failure, never an ordinary task failure. ```ts type-equiv +/** + * A {@link SandboxProvider.confine} result: the argv to spawn in place of + * the caller's own, plus the enforcement completeness the selected backend + * achieves for it. + */ interface ConfinedArgv { /** The wrapped argv (runner, profile, separator, then the caller's argv). */ argv: string[] @@ -57,17 +83,9 @@ interface ConfinedArgv { */ denialSignatures: readonly string[] /** - * How the RUNNER ITSELF failing identifies itself: case-insensitive stderr - * substrings produced when the sandbox binary is missing, refuses its - * profile, or fails closed before exec'ing the command (`bwrap: `, - * `landlock-run: `, `sandbox-exec: ` — each covers both the runner's own - * error prefix and the shell's runner-not-found message). ORTHOGONAL to - * {@link denialSignatures}: a denial is the confined COMMAND being blocked - * (the sandbox working as designed); a runner failure means the command - * NEVER RAN and must surface as a sandbox failure, not a task failure — - * consumers check these signatures FIRST (a runner's own error text may - * contain denial words, e.g. an unopenable grant root reporting - * `Permission denied`). + * Case-insensitive signatures for runner failure before command execution. + * Consumers check these before denial signatures: runner failure means the + * command never ran, while denial means confinement worked and blocked it. */ runnerFailureSignatures: readonly string[] } diff --git a/docs/core-data-structures/scope.md b/docs/core-data-structures/scope.md index 66d5f40de9..d79f464b3e 100644 --- a/docs/core-data-structures/scope.md +++ b/docs/core-data-structures/scope.md @@ -9,12 +9,18 @@ Source: [`packages/core/scope/src/index.ts`](../../packages/core/scope/src/index `ScopeKey` is an opaque object identity. The shipped loop uses the live `Agent` object as its own key, but the primitive never inspects the object. ```ts type-equiv +/** An opaque, identity-compared scope key. */ type ScopeKey = object ``` `Scoped` is the compile-time brand on the opaque routing receiver returned by `scopeTarget(base, key)`. Scope-filtered event declarations require this carrier as their `this` type, while the real event subject remains an explicit argument. ```ts type-equiv +/** + * A routing-only event receiver built by {@link scopeTarget}. The type + * parameter records the subject type for dispatch checking; the carrier does + * not expose the subject's properties. Event payloads carry the real subject. + */ type Scoped = object & { readonly [ScopedBrand]: T } ``` @@ -23,9 +29,13 @@ type Scoped = object & { readonly [ScopedBrand]: T } `Scope` pairs the tagged registration context with two teardown surfaces. `rawDispose` preserves the exact Cordis disposer identity needed by an ordered composite effect; `dispose()` is the public shared quiescence boundary for direct and racing callers. ```ts type-equiv +/** A minted registration scope and its quiescent disposal boundaries. */ interface Scope { + /** Context through which scope-owned registrations are made. */ ctx: Context + /** Exact Cordis disposer, used when nesting this scope in an ordered composite effect. */ rawDispose: () => Promise | void + /** Dispose every scope-owned registration; racing calls await the same completion. */ dispose(): Promise } ``` diff --git a/docs/core-data-structures/session-query.md b/docs/core-data-structures/session-query.md index 86d2259f7f..4652358162 100644 --- a/docs/core-data-structures/session-query.md +++ b/docs/core-data-structures/session-query.md @@ -9,23 +9,34 @@ Source: [`packages/session-query/session-query/src/types.ts`](../../packages/ses `SessionRecord` is returned by the cross-corpus list. It exposes source availability independently from the cloned live-preferred header. `SessionEventRecord` is a lightweight raw-log projection; classification uses the same `foldSurface()` transitions as model-history derivation. ```ts type-equiv -export type SessionEventSurface = 'current' | 'shadowed' | 'log-only' +/** Whether an event is current model context, replaced context, or raw-log-only. */ +type SessionEventSurface = 'current' | 'shadowed' | 'log-only' ``` ```ts type-equiv -export interface SessionRecord { +/** Lightweight identity and source availability for one logical session. */ +interface SessionRecord { + /** Cloned session header selected from the live-preferred corpus. */ header: SessionHeader + /** Whether the id currently exists in `ctx.sessions`. */ live: boolean + /** Whether the active persistence backend currently materializes the id. */ persisted: boolean } ``` ```ts type-equiv -export interface SessionEventRecord { +/** Lightweight metadata for one event within a logical session. */ +interface SessionEventRecord { + /** Session that owns the event. */ sessionId: SessionId + /** Monotonic event seq within the session. */ seq: number + /** Discriminant of the session event. */ type: SessionEventType + /** Event timestamp in Unix epoch milliseconds. */ time: number + /** Event placement in the folded session surface. */ surface: SessionEventSurface } ``` @@ -35,24 +46,35 @@ export interface SessionEventRecord { `SessionLineageTrace` carries known parents in immediate-to-outward order and a forest of recursively nested direct descendants. The completeness discriminant makes a known root and a missing parent mutually exclusive. ```ts type-equiv -export interface SessionLineageNode { +/** Recursive descendant node in a session-lineage trace. */ +interface SessionLineageNode { + /** Detached logical-corpus record for this descendant. */ session: SessionRecord + /** Direct children, each carrying its own recursive descendants. */ descendants: SessionLineageNode[] } ``` ```ts type-equiv -export type SessionLineageTrace = { +/** Known ancestry and descendants for one logical session. */ +type SessionLineageTrace = { + /** Detached record for the session that was traced. */ target: SessionRecord + /** Known parents from the immediate parent outward. */ ancestors: SessionRecord[] + /** Complete known descendant trees rooted at the target's direct children. */ descendants: SessionLineageNode[] } & ( | { + /** The complete parent chain is present in the logical corpus. */ complete: true + /** Detached record at the top of the complete lineage. */ root: SessionRecord } | { + /** The parent chain leaves the visible logical corpus. */ complete: false + /** First parent id that is not present in the logical corpus. */ unresolvedParentId: SessionId } ) @@ -63,20 +85,31 @@ export type SessionLineageTrace = { The request addresses one raw seq and optional neighboring counts. The result carries a `SessionHeader` rather than availability flags so a known live target can remain independent of persistence health. ```ts type-equiv -export interface SessionEventReadRequest { +/** Request for one event plus raw neighboring log context. */ +interface SessionEventReadRequest { + /** Session that owns the target event. */ sessionId: SessionId + /** Target event seq. */ seq: number + /** Number of preceding raw events to include. */ before?: number + /** Number of following raw events to include. */ after?: number } ``` ```ts type-equiv -export interface SessionEventWindow { +/** Full target event and a bounded raw-log window. */ +interface SessionEventWindow { + /** Cloned header for the live-preferred source read. */ session: SessionHeader + /** Full cloned target event. */ target: SessionEvent + /** Full cloned events from `startSeq` through `endSeq`. */ events: SessionEvent[] + /** First seq included in `events`. */ startSeq: number + /** Last seq included in `events`. */ endSeq: number } ``` @@ -86,19 +119,29 @@ export interface SessionEventWindow { Event traces distinguish positional surface replacement from logged provenance. Every seq list contains direct links except `replacementChain`, which follows immediate replacers from the target to the final positional replacement. ```ts type-equiv -export interface SessionEventTraceRequest { +/** Request for direct surface and provenance relationships around one event. */ +interface SessionEventTraceRequest { + /** Session that owns the target event. */ sessionId: SessionId + /** Target event seq. */ seq: number } ``` ```ts type-equiv -export interface SessionEventTrace { +/** Direct surface and provenance relationships for one event. */ +interface SessionEventTrace { + /** Lightweight target record. */ target: SessionEventRecord + /** Immediate positional replacement event, when the target was shadowed. */ replacedBy?: number + /** Positional replacers from the immediate replacement to the final replacement. */ replacementChain: number[] + /** Surface nodes directly removed when the target itself performed a replacement. */ replacedEventSeqs: number[] + /** Direct logged provenance sources in their recorded order. */ sourceEventSeqs: number[] + /** Later events that directly name the target as a provenance source, in log order. */ derivedEventSeqs: number[] } ``` @@ -108,7 +151,8 @@ export interface SessionEventTrace { The closed code union distinguishes request validation, missing targets, malformed surface logs, optional-backend failure, and contradictory source metadata. ```ts type-equiv -export type SessionQueryErrorCode = +/** Stable machine-routable failure taxonomy for exact session reads and traces. */ +type SessionQueryErrorCode = | 'SESSION_QUERY_EVENT_NOT_FOUND' | 'SESSION_QUERY_INVALID_CONFIG' | 'SESSION_QUERY_INVALID_LINEAGE' diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index b0aa7d4fc5..3d3fca045a 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -9,6 +9,7 @@ Source: [`packages/core/session/src/types.ts`](../../packages/core/session/src/t `ContextEnvelope` selects the standard tagged projection or preserves a producer-owned complete frame. The latter changes framing only; the event remains a user-role `context/message` in chronological history. ```ts type-equiv +/** Canonical context-tag framing, or caller-owned framing rendered verbatim. */ type ContextEnvelope = 'context' | 'raw' ``` @@ -17,30 +18,43 @@ type ContextEnvelope = 'context' | 'raw' The append-only event types. Merge-extensible: a plugin declares extra event types via declaration merging — e.g. the [compaction seam](compaction.md) adds `compact/start` / `compact/summary` / `compact/end`, and `@deepseek-ai/dsh-hook-protocol` adds log-only `hook/invoked` / `hook/result` provenance for a hook bridge. Like `compact/*`, these are NOT `SurfaceEventType`s (no `surfaceOp`). The generated [persistence log event catalog](../persistence-catalog.md) enumerates every member — core and merged — with its payload, surface badge, and declaration site. ```ts type-equiv +/** + * The merge-extensible, append-only source of truth for an agent interaction. + * Message history is derived from this log. Every event is lossless JSON and + * sequence numbers stay contiguous, including raw chunks, so persistence can + * store the canonical log verbatim. + */ interface SessionEventMap { + /** + * Opens turn `turn`. `trigger` records what started it — a drained message + * batch or an idle-time injection. The turn is the durability/replay + * boundary: every event sits between a `turn/start` and its matching + * `turn/end` (the turn-enclosure invariant). + */ 'turn/start': { turn: number; trigger: TurnTrigger } + /** + * Closes turn `turn` with the {@link TurnEndReason} that ended it. The loop + * fires the awaited `session/flush` checkpoint at every turn end, so the turn + * boundary is also the durable-commit boundary. + */ 'turn/end': { turn: number; reason: TurnEndReason } + /** Opens step `step` of turn `turn` — one model call plus the tool executions it requested. */ 'step/start': { turn: number; step: number } + /** Closes step `step` of turn `turn`. */ 'step/end': { turn: number; step: number } /** A user-visible prompt (queued message drained at turn start). */ 'user/message': { content: ContentBlock[]; source: MessageSource } /** - * A queued prompt an `agent/prompt-submit` listener VETOED — the durable - * record of a blocked prompt and why. Appended in place of the `user/message` - * the prompt would have become, so the block survives replay even in a MIXED - * batch where another queued prompt is allowed (there the turn does not end - * `rejected`, so the boundary reason alone would not preserve it). `content` - * is the original prompt the listener rejected; `reason` is the veto text - * ({@link PromptDecision} `block.reason`). NOT a {@link SurfaceEventType}: a - * blocked prompt produces no LLM message and never reaches `deriveMessages()`. + * Durable record of a prompt veto and its reason. It is log-only: the blocked + * prompt never enters the model-visible surface, including in a mixed batch. */ 'prompt/blocked': { content: ContentBlock[]; source: MessageSource; reason: string } /** * In-session context injection (file-change notices, subdir AGENTS.md, * skill content, cron notifications, …). Rendered into the derived history * as synthetic context — NOT a user prompt. `envelope: 'raw'` lets a caller - * supply its own complete framing; `meta` is persisted JSON hidden from the - * model. + * own the complete model-facing frame; `meta` is durable JSON state omitted + * from the model projection. */ 'context/message': { content: ContentBlock[] @@ -57,34 +71,29 @@ interface SessionEventMap { * usage record). `usage` is absent when the adapter reported none. */ 'assistant/message': { turn: number; step: number; content: ContentBlock[]; provenance: AssistantProvenance; usage?: TokenUsage } + /** + * The model requested one tool invocation: `name` with the raw `arguments` + * JSON string exactly as the model produced it (unparsed). `callId` pairs the + * call with its `tool/result`. + */ 'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string } + /** + * A completed tool call's model-facing result, plus an optional tool-private + * `meta` presentation payload. `meta` is opaque to the core (`unknown` — the + * producing tool owns its shape and reads it back in `presentResult`) but MUST + * be JSON-serializable: `Session.append` runtime-validates all event data with + * `isJsonValue`, so a non-serializable `meta` is rejected at the source, and the + * durable log reproduces the identical card on replay. Absent unless the tool + * attaches one (e.g. `dsh-tool-fs` carries its result-time contextual diff here). + */ 'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean; error?: { name: string; code: string }; meta?: unknown } /** Steering content injected between steps of a running turn. */ 'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource } - /** - * The agent's whole todo list, carried as a full snapshot and replaced - * wholesale on each write — the current list is the most recent `todo/write` - * (last-write-wins on replay, no fold). Appended by an owning agent via - * `session.append('todo/write', { todos })`. - * - * NOT a {@link SurfaceEventType}: it produces no LLM message and never reaches - * `deriveMessages()`, so it carries no `surfaceOp` and stays off the surface — - * it is durable, replayable UI state, distinct from the conversation history. - * It is a `SessionEventMap` member riding the existing `session/event` emit, - * not a first-class Cordis `interface Events` notification, so it has no - * cordis-catalog row. - */ + /** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */ 'todo/write': { todos: TodoItem[] } /** - * Full snapshot of the {@link EpochHeader} the NEXT request is built under, - * with the {@link RequestHeaderReason} it was recorded whole. Appended by - * the loop inside the step, before dispatch, on a loop instance's first - * request-building step (`'initial'`/`'resume'`) or when a later request's - * header changes (`'change'`); always records what the request actually - * used, post-`agent/request`. Reconstruction reads the latest snapshot. NOT a - * {@link SurfaceEventType}: it produces no LLM message — it is the request - * envelope, logged so every request is a pure function of the session log - * (the reconstructability RFC). + * Full header for the next request, appended inside its step before dispatch. + * It is log-only; the latest snapshot reconstructs the request header. */ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } } @@ -95,8 +104,21 @@ interface SessionEventMap { The unit of the `todo/write` event's whole-list snapshot. Deliberately minimal — a `content` line and a three-state `status` (no id, priority, or `activeForm`): the list is replaced wholesale on every write, so entries need no stable identity, and the status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally requires). See the [todo_write RFC](../rfc/implemented/feature/2026-06-29-todo-write-tool.md). ```ts type-equiv -export interface TodoItem { +/** + * One entry in an agent's todo list — the unit of the `todo/write` + * {@link SessionEventMap} event's whole-list snapshot. + * + * Deliberately minimal: a human-readable `content` line and a three-state + * `status`. No id, priority, or `activeForm` — the list is replaced wholesale + * on every write (last-write-wins), so entries need no stable identity, and the + * status triple is exactly the ACP `PlanEntryStatus`, so a UI bridge can map a + * todo list onto an ACP `plan` 1:1 (synthesizing the priority ACP additionally + * requires). + */ +interface TodoItem { + /** What this task is — a short imperative line shown in the UI. */ content: string + /** Lifecycle state. `in_progress` marks the single task being worked now. */ status: 'pending' | 'in_progress' | 'completed' } ``` @@ -106,8 +128,13 @@ export interface TodoItem { The request envelope — the `EpochHeader` (call config + rendered system prompt + assembled tool schemas + the session prefix) — is logged session state, so every conversation request is a pure function of the log (the reconstructability RFC). A full `request/header` snapshot with reason `'initial'` or `'resume'` records each loop-instance boundary; a later changed request records another full snapshot with reason `'change'`. `foldRequestHeader(events)` reconstructs the header by selecting the latest snapshot. The event is not a `SurfaceEventType`: it produces no LLM message. ```ts type-equiv -export interface EpochHeader { - /** The conversation's call configuration (provider + model + sampling scalars). */ +/** + * Logged request state outside derived history: call config, system prompt, + * tools, and prefix. The latest full `request/header` snapshot reconstructs it; + * canonical empty optional fields are absent. + */ +interface EpochHeader { + /** The conversation's call configuration (provider, model, and sampling scalars). */ config: LlmCallConfig /** Rendered system prompt text; absent for a system-less request. */ system?: string @@ -131,6 +158,19 @@ Canonical form: an empty system prompt, an empty tool list, and an empty session A proper discriminated union over `type` (not independent `type`/`data` unions), so `switch (event.type)` narrows `event.data` without casts. `seq` is the monotonic position in the log (`seq = log.length`); `time` is epoch ms. ```ts type-equiv +/** + * One immutable entry in the session log. + * + * A proper discriminated union over `type` (not independent `type`/`data` + * unions), so `switch (event.type)` narrows `event.data` without casts. + * + * The {@link sourceEventSeqs} and {@link surfaceOp} fields are conditional: + * they only exist on {@link SurfaceEventType} variants (`user/message`, + * `assistant/message`, `tool/result`, `context/message`, `steering/message`). + * Non-surface events (boundary markers, chunks, usage, errors) never carry + * surface metadata — the compiler enforces this at `Session.append()` + * call sites. + */ type SessionEvent = { [K in SessionEventType]: { type: K @@ -143,7 +183,9 @@ type SessionEvent = { /** * Seq numbers of events that are provenance sources of this event * (e.g. the `assistant/chunk` seqs that built an `assistant/message`, - * or the surface nodes shadowed by a compaction replace node). + * or the surface nodes shadowed by a compaction replace node). An + * `assistant/message` may carry a present empty array for a known empty + * provider stream; omission means unrecorded provenance. */ sourceEventSeqs?: number[] /** How this event entered the surface; absent for non-surface events. */ @@ -163,7 +205,12 @@ The five message-producing types (`SurfaceEventType` — `user/message`, `assist ### `SurfaceEventType` — the message-producing subset of event types ```ts type-equiv -export type SurfaceEventType = +/** + * The subset of {@link SessionEventType} values whose events produce LLM + * messages and are eligible to appear on the ordered surface. Only these + * event types may carry {@link SurfaceOp} and {@link SessionEvent.sourceEventSeqs}. + */ +type SurfaceEventType = | 'user/message' | 'assistant/message' | 'tool/result' @@ -174,7 +221,19 @@ export type SurfaceEventType = ### `SurfaceOp` — how an event entered the surface ```ts type-equiv -export type SurfaceOp = +/** + * How a session event entered the ordered surface. Only valid on + * {@link SurfaceEventType} events. + * + * - `'append'`: added to the tail — normal path for user/assistant/tool/context + * messages. + * - `{ op: 'replace', start, end }`: replaces surface nodes from `start` + * (inclusive) through `end` (inclusive) with this node. Both must exist as + * surface nodes in the current surface. `start === end` replaces a single + * node. The node's {@link SessionEvent.sourceEventSeqs} must include every + * shadowed surface node. Used by compaction and possible other manipulations. + */ +type SurfaceOp = | 'append' | { op: 'replace'; start: number; end: number } ``` @@ -184,8 +243,18 @@ export type SurfaceOp = ### `SurfaceIntent` — the parameter to `session.append()` ```ts type-equiv -export interface SurfaceIntent { +/** + * Surface placement and provenance for {@link Session.append}. Required on + * message-producing events and forbidden on log-only events. + */ +interface SurfaceIntent { surfaceOp: SurfaceOp + /** + * Complete known provenance source set. `assistant/message` may use a + * present empty array for a known empty provider stream; omission means its + * provenance was not recorded. Other surface events require a non-empty set + * when this field is present. + */ sourceEventSeqs?: number[] } ``` @@ -199,8 +268,11 @@ The same provenance distinction applies here: only `assistant/message` may carry `Session.surface` returns the session's stable `SessionSurface` view. The same incremental manager validates append candidates before commit and advances this projection from committed events; callers can observe membership and replacement generation but cannot invoke validation. ```ts type-equiv -export interface SessionSurface { +/** Readonly live projection of the message-producing session events. */ +interface SessionSurface { + /** Current surface event sequences in model-visible order. */ readonly nodes: readonly number[] + /** Monotonic count of committed positional replacements. */ readonly replaceGeneration: number } ``` @@ -210,17 +282,25 @@ export interface SessionSurface { `foldSurface(events)` returns detached current event sequences together with the actual sequences shadowed by each declared replacement range. The live manager uses the same transitions without retaining replacement history. Its `replaceGeneration` increments for each committed replacement so incremental consumers can distinguish pure tail growth from a rewrite. ```ts type-equiv -export interface SurfaceFoldReplacement { +/** One replacement operation observed while folding a session surface. */ +interface SurfaceFoldReplacement { + /** Seq of the event that replaced the prior surface range. */ seq: number + /** Declared inclusive start seq of the replaced surface range. */ start: number + /** Declared inclusive end seq of the replaced surface range. */ end: number + /** Actual surface entries removed by the operation, in surface order. */ shadowedSeqs: number[] } ``` ```ts type-equiv -export interface SurfaceFoldResult { +/** Complete result of replaying the surface operations in a session log. */ +interface SurfaceFoldResult { + /** Current surface event sequences in model-visible order. */ nodes: number[] + /** Replacement operations in event order. */ replacements: SurfaceFoldReplacement[] } ``` @@ -248,6 +328,10 @@ An explicit `boundary` lets callers fork from a previous completed turn even if ## What started a turn: `TurnTriggerMap` ```ts type-equiv +/** + * What started a turn. + * Merge-extensible sum type (same pattern as MessageSourceMap). + */ interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } /** @@ -265,6 +349,9 @@ interface TurnTriggerMap { ## Why a turn ended: `TurnEndReasonMap` ```ts type-equiv +/** + * Why a turn ended. Merge-extensible sum type. + */ interface TurnEndReasonMap { completed: { kind: 'completed' } aborted: { kind: 'aborted'; reason?: string } @@ -276,26 +363,16 @@ interface TurnEndReasonMap { */ error: { kind: 'error'; step: number; message: string; code?: string } disposed: { kind: 'disposed' } + /** At least one step reached its output-token ceiling, even if a plugin continued the turn. */ 'max-tokens': { kind: 'max-tokens' } /** - * The turn's entire prompt batch was BLOCKED before any step ran — every - * drained queued message was vetoed by an `agent/prompt-submit` listener (a - * hook). The turn still opened (so the boundary stays balanced and the block - * is a durable in-turn fact), but ran zero steps. `reason` carries the block - * message from the vetoing decision. Distinct from `aborted` (a user-driven - * cancel) and `error` (a failure): the prompt was rejected by policy, not - * interrupted or broken. A UI renders it as "prompt blocked by hook". + * Policy blocked every prompt before the first step. The zero-step turn still + * records a balanced durable boundary and the veto reason. */ rejected: { kind: 'rejected'; reason: string } /** - * The turn never ended on its own: the process crashed mid-turn and a - * persistence backend later closed the orphaned (open) turn on reload so the - * log stays balanced. SYNTHESIZED by the backend's crash-recovery repair — no - * loop ever emits this. Its events are real (they were durably appended before - * the crash) and are PRESERVED, not discarded: a single turn can be huge in a - * long-horizon task (many steps, large tool output), so truncating it would - * lose real work. The marker records that the turn was cut short, not that the - * model completed it. See the session-persistence RFC. + * A persistence backend closed a crash-orphaned turn on reload. The loop never + * emits this marker, and the events recorded before the crash remain intact. */ interrupted: { kind: 'interrupted' } } diff --git a/docs/core-data-structures/skills.md b/docs/core-data-structures/skills.md index 93189ba2cb..daccb38550 100644 --- a/docs/core-data-structures/skills.md +++ b/docs/core-data-structures/skills.md @@ -11,9 +11,25 @@ Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/ind Duplicate names resolve by rank, provider order, then local order; summaries sort by name. A rejected `list()` is logged and skipped without caching the degraded catalog, while malformed candidates fail fast. ```ts type-equiv +/** Provider interface for one source of skills, such as local directories or a remote registry. */ interface SkillProvider { + /** Unique provider name in the `ctx.skills` registry. */ readonly name: string + /** + * List available skill candidates for the current lookup context. Provider + * plugins register synchronously during `apply()`; remote initialization, + * authentication, and discovery are awaited inside this method. Implementations + * should settle promptly when `options.signal` aborts. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns provider candidates with precedence ranks and opaque locators. + */ readonly list: (options: SkillLookupOptions) => Promise + /** + * Load a complete skill body for a previously listed candidate. + * @param candidate - the winning candidate originally returned by this provider. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill body, or `undefined` if it is no longer loadable. + */ readonly get: (candidate: SkillCandidate, options: SkillLookupOptions) => Promise } ``` @@ -37,6 +53,7 @@ The project root is the nearest ancestor containing `.git`; without one, the cur Skill names are kebab-case (`^[a-z0-9]+(?:-[a-z0-9]+)*$`). The local provider accepts directory bundles (`/SKILL.md`) and flat Markdown files (`.md`). Nested recursive `**/SKILL.md` discovery is intentionally outside v1. ```ts type-equiv +/** Origin bucket for a skill contribution. The value is prompt-visible metadata, not precedence by itself. */ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | 'user-agents' | 'custom' | (string & {}) ``` @@ -45,13 +62,21 @@ type SkillSource = 'project-dsh' | 'project-agents' | 'runtime' | 'user-dsh' | ' `SkillSummary` is the registry's model-invocable summary shape. Consumers choose which fields to render; the session catalog uses only `name` and `description`, never the body or absolute file path. `disableModelInvocation` hides a skill from model listings while allowing trusted code to load it by name. ```ts type-equiv +/** Model-visible skill metadata returned by `ctx.skills.list()` and rendered into request guidance. */ interface SkillSummary { + /** Kebab-case identifier used with the `skill` tool. */ readonly name: string + /** Short routing description shown to the model. */ readonly description: string + /** Optional extra routing guidance shown to the model. */ readonly whenToUse?: string + /** Whether the skill is hidden from model listings while remaining loadable by trusted callers. */ readonly disableModelInvocation?: boolean + /** Discovery source that produced this winning skill. */ readonly source: SkillSource + /** Provider that owns this skill body. */ readonly provider: string + /** Provider-specific base for relative resources. */ readonly resourceBase?: SkillResourceBase } ``` @@ -59,10 +84,15 @@ interface SkillSummary { `SkillCandidate` is the provider-to-registry shape. `locator` is opaque provider state; the registry only stores it and gives it back to the winning provider's `get()`. ```ts type-equiv +/** Provider catalog entry used by the registry to merge and later load skills. */ interface SkillCandidate extends SkillSummary { + /** Lower ranks win duplicate skill names before provider registration order is considered. */ readonly rank: number + /** Opaque provider-owned handle passed back to `provider.get()`. */ readonly locator: unknown + /** Absolute file path when the provider has one. */ readonly path?: string + /** Parsed optional metadata object from provider-specific skill frontmatter. */ readonly metadata?: Readonly> } ``` @@ -70,6 +100,7 @@ interface SkillCandidate extends SkillSummary { `SkillDefinition` is the complete parsed result returned by `ctx.skills.get()` and used by the `skill` tool. `resourceBase` tells the tool how to render relative-resource guidance for local, URL, or provider-managed skills. ```ts type-equiv +/** Optional provider-specific base used by loaded skill bodies to resolve relative resources. */ type SkillResourceBase = | { readonly kind: 'directory'; readonly path: string } | { readonly kind: 'url'; readonly url: string } @@ -77,9 +108,13 @@ type SkillResourceBase = ``` ```ts type-equiv +/** Complete parsed skill definition, including the body loaded by `ctx.skills.get()`. */ interface SkillDefinition extends SkillSummary { + /** Markdown instruction body after any provider-specific metadata removal. */ readonly content: string + /** Absolute file path when the skill came from disk. */ readonly path?: string + /** Parsed optional metadata object from frontmatter. */ readonly metadata?: Readonly> } ``` @@ -87,9 +122,8 @@ interface SkillDefinition extends SkillSummary { Runtime skills use the same complete shape and participate in the same first-wins collection order. The returned disposer removes the contribution and invalidates discovery caches. ```ts type-equiv -type SkillRegistration = Omit & { - readonly provider?: string -} +/** Runtime skill contribution accepted by `ctx.skills.register()`. */ +type SkillRegistration = Omit & { readonly provider?: string } ``` ## Lookup and configuration @@ -97,8 +131,11 @@ type SkillRegistration = Omit & { Skill lookup is cwd-sensitive because providers may expose workspace-local skills, and its optional signal cancels provider work for the caller. Providers receive the same readonly options object used for cache identity and loading. Cancellation is checked before and after catalog selection, including cache hits, and races both discovery and full-definition loading. If no git root is found, the local provider treats the supplied cwd itself as the project root. ```ts type-equiv +/** Caller context used for cwd-sensitive and abortable provider work. */ interface SkillLookupOptions { + /** Workspace selector for the current lookup. */ readonly cwd?: string | undefined + /** Abort discovery or loading work for the current caller. */ readonly signal?: AbortSignal | undefined } ``` @@ -106,7 +143,9 @@ interface SkillLookupOptions { The registry owns only its discovery-cache bound. The local provider owns filesystem roots (`dshHome`, `agentsHome`, and `customSkillDirs`). The consumer owns its catalog description bound. ```ts type-equiv +/** Skill registry configuration. */ interface Config { + /** Maximum number of completed cwd/provider catalogs kept in memory. */ readonly collectCacheMaxEntries?: number } ``` diff --git a/docs/core-data-structures/spill.md b/docs/core-data-structures/spill.md index 4e8ced8258..d046874b0e 100644 --- a/docs/core-data-structures/spill.md +++ b/docs/core-data-structures/spill.md @@ -9,15 +9,28 @@ Source: [`packages/spill/spill/src/types.ts`](../../packages/spill/spill/src/typ `saveText` is the whole seam: persist `content` verbatim, return an opaque locator, a backend-supplied retrieval hint, and the exact byte count. The request carries the save-time storage namespace (`owner`), WHERE it came from (`source`, descriptive provenance for naming and inspection — not access control), and a `suggestedName` the backend may use as a naming hint (it is not a path). ```ts type-equiv +/** One request to persist text to a spill artifact. */ interface SaveTextSpill { owner: SpillOwner source: SpillSource + /** + * A caller-suggested base name (e.g. `web_fetch.txt`). The backend sanitizes + * it to a single safe path segment before use — it is a hint, never a path. + */ suggestedName: string + /** The full text to persist (UTF-8). */ content: string } ``` ```ts type-equiv +/** + * Save-time storage namespace for a spilled artifact. The session id lets a + * backend group storage under the producing session, but the returned + * {@link SpillLocator} is the model-facing handle. Forked sessions inherit + * locators already present in the seeded log; those artifacts are not copied or + * re-owned, and spills produced after the fork use the child session id. + */ interface SpillOwner { sessionId: SessionId } @@ -26,9 +39,17 @@ interface SpillOwner { `SpillOwner.sessionId` is the save-time storage namespace. Forked sessions inherit existing spill locators from the seeded log; those artifacts are not copied or re-owned, and spills produced after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. ```ts type-equiv +/** + * Provenance of one spilled artifact — recorded by the backend for a readable + * filename and inspection. Not interpreted for access control; purely + * descriptive. + */ interface SpillSource { + /** The tool whose result was spilled (e.g. `web_fetch`). */ toolName: string + /** The model-issued call id the result belongs to. */ callId: CallId + /** A short human label for the artifact (e.g. `result`). */ label: string } ``` @@ -36,6 +57,7 @@ interface SpillSource { ## The result ```ts type-equiv +/** A saved spill artifact: its locator, byte length, and backend-specific retrieval guidance. */ interface SpillRef { locator: SpillLocator bytes: number @@ -46,6 +68,11 @@ interface SpillRef { `SpillLocator` is a [branded](core.md#branded-ids) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. ```ts type-equiv +/** + * Opaque model-facing handle for one spilled artifact. A local backend may use a + * filesystem path; a remote or database backend may use a URI or key. Consumers + * render it with {@link SpillRef.retrievalHint}, but do not parse it. + */ type SpillLocator = Branded<'SpillLocator'> ``` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md index 815438f215..d279f26a9d 100644 --- a/docs/core-data-structures/subagent.md +++ b/docs/core-data-structures/subagent.md @@ -11,10 +11,22 @@ Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/suba A provider advertises its **start-time** features on a static descriptor the service checks BEFORE a run exists; a request that needs one the provider lacks is rejected loud (`SubagentError('UNSUPPORTED_CAPABILITY')`), never accepted-then-ignored. **Runtime** features (steering, resume) are instead optional methods on [`SubagentRun`](#a-live-run-subagentrun) — the method's presence IS the capability, and TS narrowing is the discovery mechanism. ```ts type-equiv +/** + * Which START-TIME features a provider supports. Checked by the service before delegating to + * {@link SubagentProvider.start}: a request that needs a capability the chosen provider lacks + * is rejected with a typed error rather than accepted-then-ignored (the "fail loud, no silent + * degradation" rule). These static flags cover features needed before a run exists; runtime + * capabilities such as steering and resume are optional {@link SubagentRun} methods whose presence + * is the capability. + */ interface SubagentCapabilities { + /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ readonly outputSchema: boolean + /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ readonly depthLimit: boolean + /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ readonly toolFilter: boolean + /** Honor {@link SubagentStartRequest.persona} (a per-child persona). */ readonly persona: boolean } ``` @@ -24,14 +36,60 @@ interface SubagentCapabilities { The tool layer builds this request from the model input and its own config; the service validates it against the named provider before `start`. Required `parent` supplies the session cwd, lineage, and delegation depth. Optional output schema, depth, tool filter, and persona require matching capability flags. Unsupported schemas fail at start; in-process backends scope filters and personas to child creation and implement the supported object-rooted schema with a forced capture tool. ```ts type-equiv +/** + * What a caller asks for when starting a subagent. The tool layer builds this + * from the model's `{ description, prompt }` plus its own config; the service + * validates {@link SubagentCapabilities} against the named provider, then + * passes it to {@link SubagentProvider.start}. + */ interface SubagentStartRequest { + /** The task/prompt for the child agent (a user message in the child session). */ readonly prompt: ContentBlock[] + /** + * The spawning ("parent") agent — the one whose tool call started this + * subagent. REQUIRED: in-process backends read `parent.session.header` for + * the working directory, the `parentSession` lineage to stamp on the child, + * and the parent's delegation depth. Out-of-process backends (ACP) ignore it. + */ readonly parent: Agent + /** + * Cancellation signal from the spawning context (the tool's `exec.signal`). + * This is the canonical cancellation channel both before and after startup: + * a provider rejects `start()` after cleaning partial resources when it + * fires before publication, and cancels a published child when it fires + * afterward. + */ readonly signal: AbortSignal + /** Per-child agent options (model and plugin-defined extension fields). */ readonly agentOptions?: AgentOptions + /** + * Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects + * unsupported schemas or providers without the capability. Data must be plain host-realm JSON; + * a successful child returns the matching value as {@link SubagentResult.structured}. + */ readonly outputSchema?: StructuredOutputSchema + /** + * Optional absolute delegation-depth cap for the child being started: its + * computed depth must be less than or equal to this non-negative safe + * integer. Requires {@link SubagentCapabilities.depthLimit}; rejected at + * start otherwise. + */ readonly maxDepth?: number + /** + * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; + * rejected at start otherwise. In-process backends apply it as a scoped + * `tools.restrict()` in the child's creation window: the named tools vanish + * from the child's prompt AND refuse to execute (one visibility), with loud + * unknown-name validation. + */ readonly toolFilter?: ToolRestriction + /** + * Optional per-child persona. Requires {@link SubagentCapabilities.persona}; + * rejected at start otherwise. In-process backends register it as a scoped + * `deployment:persona` section on the child, SHADOWING the deployment's + * persona for this child alone — same template semantics as the deployment + * persona (strict `{{…}}` interpolation against the registered variables). + */ readonly persona?: string } ``` @@ -43,9 +101,21 @@ interface SubagentStartRequest { The outcome of a run, resolved by `SubagentRun.result`. `structured` is present only after a requested `outputSchema` was successfully satisfied; requesting a schema does not guarantee it, and a provider may return `stopReason: 'error'` when the child fails or finishes without a valid capture. A non-`completed` `stopReason` means `output` may be partial — the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv +/** + * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. + */ interface SubagentResult { + /** The child's final assistant output (the last assistant message's content). */ readonly output: ContentBlock[] + /** + * The structured result after a requested `outputSchema` was successfully + * satisfied. Requesting a schema does not guarantee presence: a provider can + * end with `stopReason: 'error'` when the child fails or finishes without a + * valid capture. Shape is validated against the request schema by the + * provider; `unknown` here because the seam is schema-agnostic. + */ readonly structured?: unknown + /** Why the run ended. A non-`completed` reason means `output` may be partial. */ readonly stopReason: SubagentStopReason } ``` @@ -53,11 +123,22 @@ interface SubagentResult { `SubagentStopReason` is a [merge-extensible derived union](core.md#the-map--derived-union-pattern) — a backend may add variants, so consumers branch on the known cases and treat an unknown terminal reason as a failure: ```ts type-equiv +/** + * Why a subagent run ended. Merge-extensible (a backend may add variants); + * consumers branch on the known cases and fall through `default`. The known + * cases mirror the harness turn-end vocabulary so the tool layer can map a + * non-`completed` result to an `isError` tool result. + */ interface SubagentStopReasonMap { + /** The child finished its turn normally. */ completed: 'completed' + /** The run was cancelled by its request signal or by disposal. */ aborted: 'aborted' + /** The child failed (model error, transport error). */ error: 'error' + /** The child hit its token ceiling before finishing. */ 'max-tokens': 'max-tokens' + /** The child declined the task. */ refusal: 'refusal' } ``` @@ -67,12 +148,47 @@ interface SubagentStopReasonMap { `SubagentRun` is the consumer-owned handle for a ready child. Consumers await `result` and always dispose the run to reach quiescence. Child failures resolve with a non-completed stop reason; only unrepresentable infrastructure faults reject. Optional `sendMessage` and `resume` methods advertise their runtime capabilities by presence. ```ts type-equiv +/** + * Child handle returned only after readiness. Consumers await {@link result} and must always + * {@link dispose} to cancel remaining work and reach quiescence. Optional methods are runtime + * capability discovery; narrow their presence before calling. + */ interface SubagentRun { + /** + * Parent-scoped run id. For a local run, this MUST equal the published child + * session id, whose `parentSession` records `request.parent.session.id`; a + * remote provider mints an id unique in the parent namespace. + */ readonly id: SessionId + /** + * The exact published in-process child, or `undefined` for a remote run. + * When present, its id is {@link id}; the provider retains no ownership + * implication beyond the run's ordinary {@link dispose} contract. + */ readonly localAgent: Agent | undefined + /** + * Resolves with the child's terminal {@link SubagentResult} when the run + * settles. Does NOT reject on a child-level failure — a model/transport + * failure resolves with `stopReason: 'error'` so the consumer maps it to an + * `isError` tool result. Rejects only on an infrastructure fault the seam + * cannot represent as a stop reason. + */ readonly result: Promise + /** + * Cancel remaining work, reach child quiescence, and release the run's + * resources (in-process: dispose the owned agent and remove its session; + * ACP: kill and reap the subprocess). Idempotent. + */ dispose(): Promise + /** + * OPTIONAL (steering capability): send additional content to the running + * child between steps. Present only on providers that support live steering. + */ sendMessage?(content: ContentBlock[]): void + /** + * OPTIONAL (resume capability): send a follow-up task to a settled child, + * continuing its session, and return a fresh run for the continuation. + */ resume?(content: ContentBlock[]): Promise } ``` @@ -84,10 +200,33 @@ A local run MUST publish an ordinary child agent/session before `start()` fulfil Each provider is a named child-agent transport, and multiple providers may coexist. The service validates requested start-time capabilities before `start()`. `inheritsParentContext` describes only conversation seeding (`fork`: true; `spawn` and `acp`: false), allowing consumers to generate accurate model-facing wording without implying inherited tools, services, or authority. ```ts type-equiv +/** + * A subagent backend: one transport for running a child agent (in-process + * spawn/fork, ACP to another process, …). Implementations register under a + * unique name via {@link SubagentService.registerProvider}; multiple providers + * coexist in one context (unlike the single-implementation bash seam). The + * Providers are trusted same-process implementations; callers treat their + * descriptors and returned values as borrowed immutable data. + */ interface SubagentProvider { + /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ readonly name: string + /** The start-time features this provider supports (see {@link SubagentCapabilities}). */ readonly capabilities: SubagentCapabilities + /** + * Whether the child sees the parent's completed-turn prefix. This is descriptive, not a + * service-validated start capability: the model-facing tool derives truthful wording from it. + * It says nothing about tool registration, injected services, or authority inheritance. + */ readonly inheritsParentContext: boolean + /** + * Establish a child and return its handle only after publication. The + * service has already validated that every requested start-time capability + * is supported, so an implementation may assume e.g. `request.maxDepth` is + * honorable when present. If setup fails or `request.signal` aborts before + * fulfillment, the provider owns and cleans all partial resources before this + * promise rejects. Ownership transfers to the caller only on fulfillment. + */ start(request: SubagentStartRequest): Promise } ``` diff --git a/docs/core-data-structures/system-prompt.md b/docs/core-data-structures/system-prompt.md index 4b6f1e6625..04c86aabe6 100644 --- a/docs/core-data-structures/system-prompt.md +++ b/docs/core-data-structures/system-prompt.md @@ -9,7 +9,12 @@ Source: [`packages/core/system-prompt/src/index.ts`](../../packages/core/system- `AssembleContext` identifies the scope layer one assembly resolves. It is merge-extensible: `dsh-agent` adds the optional live `agent` field, and `assembleContextFor(agent)` sets that field and `scope` together. ```ts type-equiv +/** Merge-extensible context for one prompt assembly. */ interface AssembleContext { + /** + * Scope whose providers and waterfall listeners participate. When absent, + * only global providers and subject-less listeners participate. + */ scope?: ScopeKey } ``` @@ -19,8 +24,11 @@ interface AssembleContext { `ToolProviderResult.schemas` is the model-visible set for the current assembly. `knownNames` is the provider's pre-restriction name universe used to distinguish a configured-name typo from a known tool that is deliberately hidden in this scope. ```ts type-equiv +/** Tool schemas visible in one assembly and their pre-restriction name set. */ interface ToolProviderResult { + /** The schemas this provider contributes to THIS assembly. */ readonly schemas: readonly ToolSchema[] + /** The pre-restriction name universe for config validation (defaults to `schemas`' names). */ readonly knownNames?: readonly string[] } ``` @@ -30,9 +38,21 @@ interface ToolProviderResult { `PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. ```ts type-equiv +/** One contributed section of the system prompt (registry input). */ interface PromptSection { + /** Unique name — a duplicate registration throws (see {@link SystemPrompt.section}). */ readonly name: string + /** + * Sections are concatenated in ascending order. Convention: `-100` is the + * harness identity, `0` the deployment persona, tool guidance uses 100–199; + * other negative orders also render before the persona. + */ readonly order: number + /** + * Static text or a provider evaluated at each assembly with that assembly's + * {@link AssembleContext}. The text may reference `{{variable}}`s — they are + * interpolated later, by {@link renderPrompt}. + */ readonly text: string | ((context: AssembleContext) => string) } ``` diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index 0482912af7..ebe2062a84 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -7,6 +7,10 @@ Types shared by long-running producers, `ctx.tasks`, and task control surfaces. `TaskId` is a [branded id](core.md#branded-ids) generated as `-N`. Access control relies on owner authorization, not id secrecy. `TaskKind` derives from a merge-extensible map; the registry treats kinds as opaque id namespaces. ```ts type-equiv +/** + * Producer-defined task kinds. Plugins extend this map by declaration merging; + * the registry treats every value as an opaque id namespace. + */ interface TaskKindMap { bash: 'bash' subagent: 'subagent' @@ -20,6 +24,11 @@ interface TaskKindMap { `TaskStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state. ```ts type-equiv +/** + * Producer declaration passed to {@link TaskService.start}. The runtime + * preflights access and cleanup before invoking {@link run}; the producer owns + * execution resources while the runtime owns identity and lifecycle state. + */ interface TaskStart { /** Producer kind — also the id prefix (`bash`, `subagent`, …). */ kind: TaskKind @@ -44,6 +53,7 @@ interface TaskStart { `TaskHooks.done` is the quiescence boundary. Optional `readOutput` distinguishes consuming stream tasks from final-output-only tasks. ```ts type-equiv +/** Hooks through which the runtime controls and observes producer work. */ interface TaskHooks { /** * Request termination. Must be synchronous, idempotent, and eventually settle @@ -67,6 +77,7 @@ interface TaskHooks { ``` ```ts type-equiv +/** Terminal result supplied by a producer through {@link TaskHooks.done}. */ interface TaskOutcome { /** How the task ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */ status: 'completed' | 'killed' | 'failed' @@ -82,6 +93,10 @@ interface TaskOutcome { Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another surface has delivered or committed to deliver the terminal state. ```ts type-equiv +/** + * A read-only projection of one task, safe to hand to listeners and tools — + * a fresh object per call, never live registry state. + */ interface TaskSnapshot { /** The registry-issued id (`-N`). */ id: TaskId @@ -112,6 +127,7 @@ interface TaskSnapshot { ``` ```ts type-equiv +/** Output and post-read state returned by {@link TaskService.read}. */ interface TaskRead { /** * Stream kinds: the consuming delta since the previous read. Final-output diff --git a/docs/core-data-structures/token-meter.md b/docs/core-data-structures/token-meter.md index e082ea08a9..880ec79d7d 100644 --- a/docs/core-data-structures/token-meter.md +++ b/docs/core-data-structures/token-meter.md @@ -7,6 +7,7 @@ Source: [`packages/llm/token-meter/src/types.ts`](../../packages/llm/token-meter ## `TokenMeasurement` ```ts type-equiv +/** Detached immutable request-pressure and surface snapshot at one consumed log revision. */ interface TokenMeasurement { /** Number of durable events consumed; equal to the next unread event seq. */ readonly logRevision: number @@ -28,6 +29,7 @@ interface TokenMeasurement { ## `TokenSurfaceNode` ```ts type-equiv +/** One token-priced node in the current ordered session surface. */ interface TokenSurfaceNode { /** Durable sequence number of the surface event. */ readonly seq: number diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index edb20586bc..7cc0910d90 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -9,6 +9,7 @@ Source: [`packages/core/tools/src/index.ts`](../../packages/core/tools/src/index A `ToolSchema` (the model-facing fields) plus the `execute` function, host-only scheduler metadata, and optional UI presenters. The registry holds these; the loop dispatches calls through them. The registry's `schemas()` builds the model-facing `ToolSchema[]` by an explicit allowlist — `execute`/`timeoutMs`/`isConcurrencySafe`/`presentCall`/`presentResult` must never leak into a model request. ```ts type-equiv +/** A registered tool: its schema plus the execution function. */ interface ToolDefinition extends ToolSchema { execute(args: unknown, exec: ToolRunContext): Promise /** @@ -26,7 +27,9 @@ interface ToolDefinition extends ToolSchema { * * Opted-in executions must not mutate parent-owned state. Shared state must * tolerate concurrent dispatch; recorder races are permitted only when they - * commute or fail closed. See the parallel-tool-call RFC for the full contract. + * commute or fail closed. See the + * [parallel-tool-call RFC](../../../../docs/rfc/implemented/feature/2026-07-10-parallel-tool-call-execution.md) + * for the full contract. * @param args - parsed arguments; `defineTool` validates before calling. * @returns Whether this call may join a parallel group. */ @@ -61,6 +64,7 @@ Plugin authors write per-property specs with a boolean `required: true`, and a t Source: [`packages/core/tools/src/schema.ts`](../../packages/core/tools/src/schema.ts) ```ts type-equiv +/** One schema-spec property entry. */ interface SchemaProp { type: SchemaType /** Per-property required flag (NOT the JSON Schema top-level required array). */ @@ -69,7 +73,10 @@ interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] - /** Default value. */ + /** + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. + */ default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec @@ -79,12 +86,29 @@ interface SchemaProp { ``` ```ts type-equiv +/** + * The author-facing parameter schema: a shallow map of property name to + * {@link SchemaProp}. Required-ness is a per-property boolean (`required: + * true`), not a separate array. + */ type SchemaSpec = Record ``` `SchemaType` is the primitive union `'string' | 'number' | 'boolean' | 'object' | 'array'`. `InferArgs` maps a `SchemaSpec` to the TS argument type — `required: true` props become required keys, everything else genuinely optional: ```ts type-equiv +/** + * Infer the TS argument type for a complete {@link SchemaSpec}. + * + * Properties marked `required: true` are required keys; all others are + * genuinely optional keys (`?`), so callers may omit them entirely. + * + * Example: + * ```ts + * type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }> + * // → { path: string; limit?: number } + * ``` + */ type InferArgs = Simplify< & { [K in RequiredKeys]: InferPropValue } & { [K in Exclude>]?: InferPropValue } @@ -100,8 +124,14 @@ Registration is a trusted same-process contract. The registry borrows the typed `ToolRestriction` applies only to the live deployment-global tool layer. The registry compiles readonly names into private sets, intersects multiple restrictions, then overlays scope-local tools. A deny-only filter admits later unlisted globals, while an allow-list excludes them. ```ts type-equiv +/** + * Per-scope filter over global tools. Restrictions intersect and do not affect + * scoped registrations or the reserved Code Mode transport. + */ interface ToolRestriction { + /** Global tool names that stay visible; everything else is removed. */ readonly allow?: readonly string[] + /** Global tool names removed from visibility. */ readonly deny?: readonly string[] } ``` @@ -111,14 +141,20 @@ interface ToolRestriction { `ctx.tools.execute()` accepts a caller-owned `ToolExecutionInput`, materializes its parsed JSON arguments once into a pipeline-owned `ToolExecution`, and runs that call through `tools/pre-execute` (the reorderable allow/deny/ask waterfall) → registered monotonic guards → `tools/execute` (around-dispatch wrappers) → `tools/post-execute` (inspect/replace the result) → `tools/result` (the immutable authoritative outcome). The outcome is a `ToolExecutionResult`. ```ts type-equiv +/** Opaque call identity that permits correlation without exposing mutable execution state. */ type ToolExecutionToken = symbol & { readonly [toolExecutionTokenBrand]: true } ``` ```ts type-equiv +/** + * Caller-supplied description of one tool call. {@link ToolRegistry.execute} + * adds the registry-owned token to form a pipeline {@link ToolExecution}; + * callers do not choose that token. + */ interface ToolExecutionInput { readonly callId: CallId readonly name: string - /** Parsed JSON arguments (unknown — tools validate their own input). */ + /** Losslessly JSON-serializable parsed arguments (tools validate their own schema). */ readonly arguments: unknown /** The agent on whose behalf the call runs (set by the agent loop). */ readonly agent?: Agent @@ -135,6 +171,12 @@ interface ToolExecutionInput { A tool body receives the runtime extension. `deferContext()` is the composite-tool channel: it records nested-dispatch context without injecting inside the still-open outer call. ```ts type-equiv +/** + * Runtime context handed to a tool implementation after the registry has + * accepted a {@link ToolExecution}. A composite tool uses + * {@link deferContext} to ferry context produced by nested dispatches back to + * the outer result; the loop appends it only after the outer `tool/result`. + */ interface ToolRunContext extends ToolExecution { /** * Defer one nested-dispatch context until this tool's final result reaches @@ -148,12 +190,23 @@ interface ToolRunContext extends ToolExecution { The agent loop asks the registry for each pending call's execution mode and uses it to form exclusive barriers and rolling-pool parallel runs: ```ts type-equiv +/** + * Scheduling mode for one pending call. `parallel` may overlap with siblings; + * `exclusive` runs alone and forms an ordering barrier. + */ type ToolExecutionMode = | { kind: 'parallel' } | { kind: 'exclusive' } ``` ```ts type-equiv +/** + * One pending tool call inside the registry pipeline. Parsed arguments cross + * one lossless-JSON materialization boundary before policy and are deep-frozen; + * call identity and the registry-assigned {@link token} are readonly. An + * around-dispatch wrapper may set, replace, or remove `signal`. The registry + * freezes the complete object before `tools/result` observers run. + */ interface ToolExecution extends ToolExecutionInput { /** Registry-assigned identity shared with nested calls only as their opaque `parent` token. */ readonly token: ToolExecutionToken @@ -165,10 +218,19 @@ interface ToolExecution extends ToolExecutionInput { A `ToolGuard` is scope-aware final pre-dispatch policy. Its shape deliberately has no allow result: `undefined` preserves the waterfall decision, while a returned reason can only reduce permission, so a later listener cannot undo it. ```ts type-equiv +/** + * A monotonic execution guard evaluated after every `tools/pre-execute` + * listener and before the tool body. Returning a reason denies the call; + * returning `undefined` leaves it unchanged. Because guards have no allow + * result, listener ordering cannot turn a denial back into permission. + * @param execution - the identity-protected call after extensible pre-execute policy completed. + * @returns a final denial reason, or `undefined` to leave the call allowed. + */ type ToolGuard = (execution: Readonly) => string | undefined ``` ```ts type-equiv +/** The outcome of one tool call. */ interface ToolExecutionResult { content: ContentBlock[] isError: boolean @@ -179,14 +241,8 @@ interface ToolExecutionResult { */ error?: ToolErrorInfo /** - * Extra model-facing contexts deferred by a composite tool or attached by - * `tools/post-execute` listeners for the NEXT request. They are not part of - * this call's `content`: the loop accepts them into the active-batch FIFO and - * appends them after every recorded `tool/result` when the batch settles, even - * when execution is interrupted. The array preserves each context's source, - * envelope, metadata, and production order. An accepted outer call keeps - * deferred contexts before decision contexts; a block retains only contexts - * supplied by the blocking decision. + * Model-facing context for the next request, separate from this tool result. The loop + * accepts it into the active-batch FIFO, then appends after recorded results even if interrupted. */ additionalContexts?: HookContext[] /** @@ -206,6 +262,12 @@ The registry materializes and freezes the final accepted result immediately befo Each interception waterfall returns a typed **Decision** (the idiom shared with the `agent/*` seams). `tools/pre-execute` listeners receive `(exec, next)` and return a `PreToolDecision`; `tools/execute` wrappers return a `ToolExecutionResult`; `tools/post-execute` listeners receive `(exec, result, next)` and return a `PostToolDecision`: ```ts type-equiv +/** + * Pre-dispatch decision. `allow` runs the call; `deny` materializes an error; + * `ask` runs only after an approval service returns `allowed-once` and otherwise + * denies. Input rewriting is excluded because arguments are already logged and + * presented. + */ type PreToolDecision = | { kind: 'allow' } | { kind: 'deny'; reason: string } @@ -213,6 +275,10 @@ type PreToolDecision = ``` ```ts type-equiv +/** + * Post-dispatch decision: accept or replace content, attach context for the next + * request, or block by turning corrective feedback into an error result. + */ type PostToolDecision = | { kind: 'accept'; content?: ContentBlock[]; additionalContexts?: HookContext[] } | { kind: 'block'; feedback: ContentBlock[]; additionalContexts?: HookContext[] } @@ -227,25 +293,41 @@ Post-policy may replace content; a block becomes an `isError` result containing The vocabulary a caller uses to demand a machine-readable result from a subagent (`SubagentStartRequest.outputSchema`, [subagent.md](subagent.md#the-start-request)) or a workflow `agent()` call. It is deliberately NOT full JSON Schema: the schema travels verbatim to the model as a forced tool's `parameters`, and the produced value is validated client-side by `validateStructuredValue` — so every accepted keyword must be one the validator actually enforces, and `assertSupportedOutputSchema` rejects anything else loud (`OutputSchemaError`, listing every violation). Both walkers reason over own enumerable properties only (JSON carries nothing else) and reject non-plain objects (`Date`, `Map`) that would serialize lossily. ```ts type-equiv +/** The scalar values `enum`/`const` may carry (finite numbers only). */ type StructuredScalar = string | number | boolean | null ``` ```ts type-equiv +/** The `type` keywords the subset accepts. */ type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null' ``` ```ts type-equiv +/** + * One node of the structured-output schema subset. Recursive via `properties` + * and `items`; see the module doc for the exact keyword semantics. + */ interface StructuredSchemaNode { type: StructuredSchemaType + /** Nested property schemas (`type: 'object'` only). */ properties?: Record + /** Required property names; each must appear in `properties`. */ required?: string[] + /** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */ additionalProperties?: boolean + /** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */ items?: StructuredSchemaNode + /** Allowed values (scalar types only). */ enum?: StructuredScalar[] + /** The single allowed value (scalar types only). */ const?: StructuredScalar + /** Annotation, ignored for validation. */ description?: string + /** Annotation, ignored for validation. */ title?: string + /** Annotation, ignored for validation (must still be JSON data). */ default?: unknown + /** Annotation, ignored for validation (must still be JSON data). */ examples?: unknown } ``` @@ -253,6 +335,7 @@ interface StructuredSchemaNode { A schema is an object-rooted node (`enum`/`const` are scalar-only; `description`/`title`/`default`/`examples` are annotations, allowed and ignored but still required to be JSON data — they ride the wire): ```ts type-equiv +/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */ type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' } ``` diff --git a/docs/core-data-structures/user-interaction.md b/docs/core-data-structures/user-interaction.md index 4edc415039..bab8e8d37b 100644 --- a/docs/core-data-structures/user-interaction.md +++ b/docs/core-data-structures/user-interaction.md @@ -9,6 +9,7 @@ Source: [`packages/ui/user-interaction/src/index.ts`](../../packages/ui/user-int `AskUserQuestionOption` is the selectable-choice shape. `label` is the user-facing option text and also the model-facing selected value; `description` is optional UI help text. ```ts type-equiv +/** One selectable answer offered to the user. */ interface AskUserQuestionOption { /** User-facing label. */ label: string @@ -22,6 +23,7 @@ interface AskUserQuestionOption { `AskUserQuestionItem` is one question in a request. The model supplies a stable `id`, which is echoed back with the answer so batched questions remain routable. ```ts type-equiv +/** One question in an ask_user_question request. */ interface AskUserQuestionItem { /** Stable model-provided question id, echoed in the answer. */ id: string @@ -41,6 +43,7 @@ interface AskUserQuestionItem { `AskUserQuestionRequest` is the cross-package request. `questions` is an array so a UI can present related prompts in one flow while preserving a stable id per answer. ```ts type-equiv +/** Request for a human answer. */ interface AskUserQuestionRequest { /** Questions to display. */ questions: AskUserQuestionItem[] @@ -56,6 +59,7 @@ interface AskUserQuestionRequest { Providers return one answer per answered question id. `selected` contains selected option labels, and `custom` carries a free-form "Other" answer when the user typed one. When `custom` is present, `selected` is empty; custom text is an answer override, not a supplement to selected choices. ```ts type-equiv +/** Answer to one question. */ interface AskUserQuestionAnswerItem { /** The answered question id. */ id: string @@ -67,6 +71,7 @@ interface AskUserQuestionAnswerItem { ``` ```ts type-equiv +/** The human's answer. */ interface AskUserQuestionAnswer { /** Structured answers keyed by question id. */ answers: AskUserQuestionAnswerItem[] @@ -78,6 +83,7 @@ interface AskUserQuestionAnswer { Only one provider may be active in a context. Provider registration is effect-bound so HMR/disposal removes the active UI. ```ts type-equiv +/** UI-side provider for user questions. */ interface UserInteractionProvider { ask(request: AskUserQuestionRequest): Promise } @@ -88,6 +94,7 @@ interface UserInteractionProvider { `UserInteractionError` extends `HarnessError`, so `ctx.tools.execute()` preserves `{ name, code }` for model-facing tool failures such as `EMPTY_QUESTIONS`, `NO_PROVIDER`, `ASK_ABORTED`, or ACP-side cancellation. ```ts type-equiv +/** Stable error taxonomy for user-interaction failures. */ class UserInteractionError extends HarnessError { constructor(message: string, code: string, options?: ErrorOptions) { super(message, code, options) diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 9d79cd96c8..af14f54466 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -13,20 +13,37 @@ Search and fetch share no request schema and no business logic, but they are del The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. ```ts type-equiv +/** + * What one search-capable backend can return. The model-facing argument is just + * a query; `maxResults` is a `dsh-tool-web`-layer bound passed through unchanged + * and enforced on the way back by the seam (see {@link WebSearchResult}). + */ interface WebSearchRequest { readonly query: string /** * Upper bound on returned sources; the seam truncates to it. Omitted = no - * bound. `dsh-tool-web` always sets it. + * bound. `dsh-tool-web` always sets it. A provider whose API supports a + * result-count control (Exa's `numResults`) should apply it at the request + * layer as a cost/latency optimization; the seam enforces the bound + * regardless. */ readonly maxResults?: number } ``` ```ts type-equiv +/** + * Normalized search outcome. `content` is optional provider-generated answer + * text or summary (Exa returns none; Perplexity returns a generated answer). + * `sources[]` is the portable citation surface. `truncated` is set by the seam + * when it cut `sources[]` down to `maxResults`. + */ interface WebSearchResult { + /** Optional provider-generated answer text, search context, or summary. */ readonly content?: string + /** Citeable sources, already truncated to the request's `maxResults`. */ readonly sources: readonly WebSearchSource[] + /** True when the seam dropped sources to honor `maxResults`. */ readonly truncated: boolean } ``` @@ -34,10 +51,17 @@ interface WebSearchResult { `content` is optional provider-generated answer text (Exa and DeepSeek return none; Perplexity returns a generated answer). `sources[]` is the portable citation surface. A source always has a `url`; `title`/`snippet`/`publishedAt` are optional because not every provider returns them — Perplexity citations may be URL-only, and forcing adapters to invent the rest would make the seam lie. `dsh-tool-web` renders `title ?? hostname(url)`. ```ts type-equiv +/** + * One citeable source. A source always has a URL; `title`, `snippet`, and + * `publishedAt` are optional because not every provider returns them — forcing + * adapters to invent them would make the seam lie (Perplexity citations may be + * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. + */ interface WebSearchSource { readonly url: string readonly title?: string readonly snippet?: string + /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ readonly publishedAt?: string } ``` @@ -45,6 +69,12 @@ interface WebSearchSource { ## Fetch request and result ```ts type-equiv +/** + * What one fetch-capable backend is asked to retrieve. The request deliberately + * omits timeout, format, prompt, and extraction controls: cancellation is a + * direct execution argument, while presentation and higher-level LLM concerns + * belong outside safe retrieval. + */ interface WebFetchRequest { readonly url: string } @@ -53,10 +83,20 @@ interface WebFetchRequest { HTTP status is part of the fetched resource state, not automatically a failure: a successful network fetch of a `404`/`500` returns a `WebFetchResult` with the status code and a bounded decoded body. `url` is the final URL after allowed redirects. `WebError` is reserved for failures to safely retrieve or represent the resource. ```ts type-equiv +/** + * Normalized fetch outcome. A successful network fetch of a non-2xx response is + * a result, not an error: the status code is part of the fetched resource + * state. {@link WebError} is reserved for failures to safely retrieve or + * represent the resource. + */ interface WebFetchResult { + /** The final URL after allowed redirects (the request URL is in the request). */ readonly url: string + /** HTTP status code of the fetched response. */ readonly statusCode: number + /** Decoded body, classified by content kind. */ readonly body: WebFetchBody + /** True when the provider capped the decoded body. */ readonly truncated: boolean } ``` @@ -64,6 +104,15 @@ interface WebFetchResult { `WebFetchBody` is a **closed** discriminated union owned by `dsh-web` (not a merge-extensible map): the provider decodes the kind and `dsh-tool-web` renders it, so a new kind is a coordinated change across known packages, not a plugin extension. Consumers `switch` on `kind` ending in `default: assertNever(...)`, so adding a kind breaks compilation at every consumer until handled. Each arm stays its own object literal even where fields coincide today, leaving room for arm-specific fields later (a future `pdf` body's `pageCount`). ```ts type-equiv +/** + * The decoded body of a fetched resource. A CLOSED discriminated union owned by + * `dsh-web`: the provider decodes the kind and `dsh-tool-web` renders it, so a + * new kind is a coordinated change across known packages, not a plugin + * extension. Consumers `switch` on `kind` ending in `default: assertNever(...)` + * so adding a kind breaks compilation at every consumer until handled. Each arm + * stays its own object literal even where fields coincide today, leaving room + * for arm-specific fields later (a `pdf` body's `pageCount`). + */ type WebFetchBody = | { readonly kind: 'html'; readonly content: string } | { readonly kind: 'text'; readonly content: string } diff --git a/docs/core-data-structures/workflow.md b/docs/core-data-structures/workflow.md index 4354105e70..40b17a3112 100644 --- a/docs/core-data-structures/workflow.md +++ b/docs/core-data-structures/workflow.md @@ -11,11 +11,24 @@ Source: [`packages/workflow/workflow/src/types.ts`](../../packages/workflow/work What a caller asks for when starting a run. The tool layer builds this from the model's `{ script, meta, args }` call plus the calling agent; `meta` and `args` are plain JSON DATA (the engine shape-validates `meta` and rejects loud BEFORE anything runs — no script text is ever evaluated to obtain it). `parent` is REQUIRED — every child the script spawns is attributed to it (cwd, lineage, and depth flow through the [subagent seam](subagent.md)). ```ts type-equiv +/** + * What a caller asks for when starting a workflow run. `meta` and `args` are + * plain JSON DATA by the seam contract (the tool builds both from the model's + * schema-validated call; the engine validates `meta`'s shape and rejects loud + * before anything runs) — an engine never evaluates script text to obtain + * them. `parent` is REQUIRED — every `agent()` the script spawns is + * attributed to it (cwd, lineage, depth flow through the subagent seam). + */ interface WorkflowStartRequest { + /** The plain-JS script body (top-level await allowed; ends with `return `). */ script: string + /** The workflow's identity block, as plain JSON data (shape-validated by the engine). */ meta: WorkflowMeta + /** Optional input exposed verbatim to the script as the `args` global. */ args?: unknown + /** The agent on whose behalf the run executes (parent of every child). */ parent: Agent + /** Cancels the run when aborted (the tool's `exec.signal`). */ signal?: AbortSignal } ``` @@ -25,10 +38,21 @@ interface WorkflowStartRequest { The identity block carried as data on the start request (the tool's `meta` parameter; the field vocabulary matches the Claude Code dynamic-workflows meta block). `phases` is progress vocabulary only: `phase()` calls match titles for observers; no execution structure is implied. ```ts type-equiv +/** + * The script's identity block, provided as plain JSON data alongside the + * script body (the model-facing tool carries it as its `meta` parameter) and + * validated by the engine before the body runs. `name`/`description` are + * required; the rest is optional annotation. The field vocabulary matches the + * Claude Code dynamic-workflows meta block. + */ interface WorkflowMeta { + /** Short kebab-case workflow name (display + persistence key). */ name: string + /** One-line description of what the workflow does. */ description: string + /** Optional guidance on when this workflow applies (shown in listings). */ whenToUse?: string + /** Optional phase declarations matched by `phase()` calls. */ phases?: WorkflowPhase[] } ``` @@ -38,10 +62,27 @@ interface WorkflowMeta { The outcome of one run, resolved by `WorkflowRun.result`. `value` is the script's materialized return value — plain host-realm JSON data (`null` when the script returned nothing) — meaningful only for `completed`. `stopReason` is a CLOSED union (engine-owned; consumers may exhaust it): `completed` | `cancelled` | `error`. A non-`completed` reason carries the failure in `error`, and the consumer maps it to an `isError` tool result rather than reporting partial output as success. ```ts type-equiv +/** + * The outcome of one run, resolved by {@link WorkflowRun.result}. `value` is + * the script's materialized return value (plain host-realm JSON data; `null` + * when the script returned `undefined`) — meaningful only for `completed`. + * A non-`completed` reason carries the failure in `error`; the consumer maps + * it to an `isError` tool result rather than reporting partial output. + */ interface WorkflowResult { + /** The script's return value (host JSON data; `null` for no return). */ value: unknown + /** Why the run settled. */ stopReason: WorkflowStopReason + /** The failure message (present iff `stopReason` is not `completed`). */ error?: string + /** + * How many `agent()` calls the run accepted over its whole lifetime. On a + * graceful settlement this is the script-side count (calls still queued for + * a concurrency slot included); on a termination path (grace force-settle, + * worker death) it degrades to the host-observed count — calls queued + * inside a terminated script are unknowable then. + */ agentsStarted: number } ``` @@ -51,11 +92,20 @@ interface WorkflowResult { The handle the consumer holds while a script executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path. `result` does NOT reject — a script failure resolves with `stopReason: 'error'` — and once the run is cancelled it SETTLES within the engine's bounded grace even if the script itself never settles (the engine force-settles `cancelled`; the worker-thread engine then terminates the script's worker), so a consumer awaiting `result` is never wedged past a cancellation. `dispose()` = cancel + that bounded settle + child quiescence; it never hangs on a stuck script. ```ts type-equiv +/** + * Holder-owned live workflow. `result` never rejects and settles within the + * engine's cancellation grace; failures resolve through `stopReason`. Consumers + * may cancel and must call idempotent `dispose()` on every path to await bounded + * script settlement and child quiescence. + */ interface WorkflowRun { readonly id: WorkflowRunId + /** The validated meta block (available before the body runs). */ readonly meta: WorkflowMeta readonly result: Promise + /** Cancel the run: children abort, pending hooks reject, the script dies at its next await (or is force-settled at the grace). */ cancel(reason?: string): void + /** Cancel + bounded-grace settle; safe to call on every path (idempotent). */ dispose(): Promise } ``` diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 494eb09922..30da04eb96 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: b3c338f03f548b4de4b676850732731323d611f1 -development.zh.md: 20f5c585dd378a7b0a3bd6b0af8ebf7dc5e0fd3c +development.md: 4efb0a1fafb9a8dc34fdbabf3dbf9baa3281929c +development.zh.md: 010b0d48a9597b578e938fd7900c4f7e5949a481 diff --git a/docs/development.md b/docs/development.md index b3c338f03f..4efb0a1faf 100644 --- a/docs/development.md +++ b/docs/development.md @@ -133,13 +133,13 @@ Pick the tag that matches the urgency so anyone scanning the code can tell a rel ## Documenting types verbatim (`ts type-equiv`) -The [core data structures](core-data-structures/core.md) docs paste real type definitions so a reader sees the exact shape. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: +The [core data structures](core-data-structures/core.md) docs paste real type declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration from source via the TypeScript parser and asserts the block matches it (whitespace- and comment-insensitive, so a doc block may show a clean definition and the prose can carry the semantics). It also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented type, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. +`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate also enforces a 1:1 correspondence: every `ts type-equiv` block has exactly one manifest entry and vice-versa, so a block can't go silently unchecked and a stale entry can't linger. `doc-typecheck` skips `ts type-equiv` blocks (they aren't standalone-compilable) and excludes them from its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a block, update the manifest in the same change. ## Architecture context diff --git a/docs/development.zh.md b/docs/development.zh.md index 20f5c585dd..010b0d48a9 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -133,13 +133,13 @@ pnpm run demo:acp ## 逐字记录类型(`ts type-equiv`) -[核心数据结构](core-data-structures/core.md)文档粘贴真实的类型定义,让读者看到确切的形状。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: +[核心数据结构](core-data-structures/core.md)文档会把真实类型声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号: ```json { "doc": "docs/core-data-structures/session.md", "symbol": "SessionEvent", "source": "packages/core/session/src/types.ts" } ``` -`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明,并断言文档块与之一致(对空白和注释不敏感,因此文档块可以展示干净的定义,语义由行文承载)。它还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个被记录的类型时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 +`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁还强制 1:1 对应:每个 `ts type-equiv` 块恰好有一条 manifest 条目,反之亦然;因此不会有块被静默漏检,也不会有陈旧条目滞留。`doc-typecheck` 跳过 `ts type-equiv` 块(它们不能独立编译),并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个块时,请在同一个变更里更新 manifest。 ## 架构上下文 diff --git a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md index aaac6fbd3c..bf70879c71 100644 --- a/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-core-data-structures-catalog.md @@ -10,7 +10,7 @@ So the work had two intertwined questions: **what belongs in such a catalog** (t ## Decision -A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type definition byte-identical to its source. +A new `docs/core-data-structures/` folder catalogs the vocabulary, with a new `verify-type-equiv` doc-sync gate that keeps every pasted type declaration and its JSDoc synchronized with source. ### What counts as "core" — the spine-vs-seam line @@ -27,10 +27,10 @@ The rule that settled the remaining cases: ***the type you write, hold, or recei ### The `ts type-equiv` mechanism — literal AND drift-proof -The durability requirement was specific: the doc should show the **literal** current type definition (so a reader sees the real shape, not a paraphrase) **and** be mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability*, not *byte-equality* — a renamed field with the same type would pass. So: +The durability requirement was specific: the doc shows the **literal** current type declaration and original JSDoc (so a reader sees the real shape and source contract, not a paraphrase) **and** is mechanically guaranteed to match source. The repo already compiles fenced ` ```ts ` blocks (`doc-typecheck`), but a real typechecked block needs import noise and proves only *assignability* — a renamed field or changed JSDoc can pass. So: -- Type definitions are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. -- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts a **verbatim source match** against the declared symbol — chosen over a compiled `_Check` assertion precisely because byte-equality, not assignability, is the property we want. +- Type declarations and their JSDoc are pasted verbatim into a dedicated ` ```ts type-equiv ` fence. `doc-typecheck` recognizes the fence and skips it (a bare definition is not standalone-compilable), and **excludes it from the opt-out ratio** — it is a separately-checked category, not an unchecked sketch. +- A new `scripts/verify-type-equiv.ts` extracts each block via the TypeScript parser and asserts that its declaration structure and every JSDoc comment match the declared symbol, ignoring only formatting whitespace and non-JSDoc comments. This is chosen over a compiled `_Check` assertion because source names and documentation identity, not assignability, are the properties the catalog preserves. - Provenance lives in a central `scripts/type-equiv.manifest.json` (`{ doc, symbol, source }` entries), **not** in directive comments in the prose. The script enforces a **1:1 correspondence**: every type-equiv block has exactly one manifest entry and vice versa, so a block can never be silently unchecked and an entry can never rot. - Wired into `doc-sync`, so it runs in the same lefthook pre-push and CI paths as the other doc gates. @@ -41,7 +41,7 @@ The durability requirement was specific: the doc should show the **literal** cur ## Alternatives considered - **A flat dump of all cross-package vocabulary** — the `BashExecRequest` test case killed it: if seam vocabulary is "core", the catalog helps no one; the tiered spine-vs-seam structure won. -- **A compiled `_Check` assignability assertion** instead of the verbatim source match — rejected because byte-equality, not assignability, is the property we want: a renamed field with the same type would pass assignability. +- **A compiled `_Check` assignability assertion** instead of the source match — rejected because assignability does not preserve names or JSDoc: a renamed field with the same type or a changed contract comment would pass. - **Provenance as directive comments in the prose** — rejected for the central manifest, whose enforced 1:1 correspondence means a block can never be silently unchecked and an entry can never rot. ## Verification lesson diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index cfa345ebdc..566bdade7b 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -1,5 +1,5 @@ { - "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source symbol it must match verbatim. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", + "comment": "Maps each ` ```ts type-equiv ` block (by doc + declared symbol) to the source declaration and original JSDoc it must match. verify-type-equiv.ts enforces a 1:1 correspondence: every type-equiv block has exactly one entry here, and every entry resolves to exactly one block. Add an entry when you add a type-equiv block; remove it when you remove the block.", "entries": [ { "doc": "docs/core-data-structures/core.md", "symbol": "Branded", "source": "packages/util/brand/src/index.ts" }, { "doc": "docs/core-data-structures/core.md", "symbol": "ContentBlockMap", "source": "packages/llm/llm/src/types.ts" }, diff --git a/scripts/verify-type-equiv.ts b/scripts/verify-type-equiv.ts index 08278a6a8f..585796206c 100644 --- a/scripts/verify-type-equiv.ts +++ b/scripts/verify-type-equiv.ts @@ -1,7 +1,8 @@ /** * Verify every `ts type-equiv` block against the source symbol named by the * manifest. Blocks and entries have a one-to-one relationship; comparison - * ignores comments and whitespace but preserves declaration structure. + * ignores whitespace and non-JSDoc comments but preserves declaration + * structure and every original JSDoc comment. */ import { globSync, readFileSync, existsSync } from 'node:fs' @@ -34,12 +35,8 @@ interface EquivBlock { code: string } -/** - * Remove comments and normalize whitespace so prose-only edits do not drift - * structural copies. This is intentionally not a general tokenizer: repo type - * declarations do not contain comment delimiters inside string literals. - */ -function normalize(code: string): string { +/** Normalize declaration structure independently of comments and whitespace. */ +function normalizeStructure(code: string): string { return code .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/.*$/gm, '$1') @@ -47,6 +44,15 @@ function normalize(code: string): string { .trim() } +/** + * Extract normalized JSDoc comments in source order. Type declarations in this + * repository do not contain comment delimiters inside string literals. + */ +function normalizeJSDoc(code: string): string[] { + return [...code.matchAll(/\/\*\*[\s\S]*?\*\//g)] + .map(match => match[0].replace(/\s+/g, ' ').trim()) +} + /** Strip source-only export modifiers. */ function stripExport(code: string): string { return code.replace(/^export\s+(default\s+)?/, '') @@ -54,8 +60,14 @@ function stripExport(code: string): string { /** Parse the declared symbol name from a type-equiv block body. */ function blockSymbol(code: string): string | null { - const m = /(?:export\s+(?:default\s+)?)?(?:abstract\s+)?(?:interface|type|class|enum)\s+([A-Za-z0-9_]+)/.exec(code) - return m?.[1] ?? null + const sf = ts.createSourceFile('type-equiv.ts', code, ts.ScriptTarget.Latest, /* setParentNodes */ false, ts.ScriptKind.TS) + for (const stmt of sf.statements) { + const named = + ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) + || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt) + if (named && stmt.name) return stmt.name.text + } + return null } /** Extract every ` ```ts type-equiv ` block from one Markdown file. */ @@ -88,11 +100,12 @@ function extractEquivBlocks(docRel: string): EquivBlock[] { return blocks } -/** The declaration text of `symbol` in `sourceRel`, with `export` stripped, or +/** + * The declaration text of `symbol` in `sourceRel`, with `export` stripped, or * null when the symbol is not declared there. Uses the TS parser so it spans * interfaces, type aliases (including mapped/generic ones), classes, and enums - * uniformly, and excludes the leading JSDoc (getStart skips leading trivia) - * while keeping inline member comments. */ + * uniformly while including declaration and member JSDoc. + */ function sourceDeclaration(sourceRel: string, symbol: string): string | null { const abs = resolve(root, sourceRel) const text = readFileSync(abs, 'utf8') @@ -102,7 +115,13 @@ function sourceDeclaration(sourceRel: string, symbol: string): string | null { ts.isInterfaceDeclaration(stmt) || ts.isTypeAliasDeclaration(stmt) || ts.isClassDeclaration(stmt) || ts.isEnumDeclaration(stmt) if (named && stmt.name?.text === symbol) { - return stripExport(stmt.getText(sf)) + const declarationStart = stmt.getStart(sf) + const jsDoc = ts.getJSDocCommentsAndTags(stmt) + .filter(ts.isJSDoc) + .map(doc => text.slice(doc.pos, doc.end)) + .join('\n') + const declaration = stripExport(text.slice(declarationStart, stmt.getEnd())) + return jsDoc === '' ? declaration : `${jsDoc}\n${declaration}` } } return null @@ -178,11 +197,18 @@ for (const e of entries) { errors.push(`symbol ${e.symbol} not found in ${e.source} (manifest entry for ${e.doc})`) continue } - if (normalize(decl) !== normalize(stripExport(b.code))) { + const doc = stripExport(b.code) + const sourceStructure = normalizeStructure(decl) + const docStructure = normalizeStructure(doc) + const sourceJSDoc = normalizeJSDoc(decl) + const docJSDoc = normalizeJSDoc(doc) + if (sourceStructure !== docStructure || JSON.stringify(sourceJSDoc) !== JSON.stringify(docJSDoc)) { errors.push( `DRIFT: ${e.doc}:${b.line} — type-equiv block for ${e.symbol} does not match ${e.source}.\n` - + ` source: ${normalize(decl)}\n` - + ` doc: ${normalize(stripExport(b.code))}`, + + ` source structure: ${sourceStructure}\n` + + ` doc structure: ${docStructure}\n` + + ` source JSDoc: ${JSON.stringify(sourceJSDoc)}\n` + + ` doc JSDoc: ${JSON.stringify(docJSDoc)}`, ) continue } @@ -190,7 +216,7 @@ for (const e of entries) { } if (errors.length === 0) { - console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source (1:1 with manifest).`) + console.log(`verify-type-equiv: ${verified} type-equiv block(s) match source structure and JSDoc (1:1 with manifest).`) process.exit(0) } From aa21c0b83f882397511b86217d4a09028d92c4be Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:41:38 +0800 Subject: [PATCH 161/163] docs(tui): remove demo image assets Remove the animated, dark, and light terminal captures and their bilingual RFC section. The implemented RFC retains the durable front-door decision and verification contract without carrying binary demo artifacts that need visual maintenance. Tighten the package README ownership wording and refresh the bilingual consistency record so no repository link or prose claim refers to the removed files. --- docs/assets/tui/jokes-dark.png | Bin 66598 -> 0 bytes docs/assets/tui/jokes-dynamic.gif | Bin 78162 -> 0 bytes docs/assets/tui/jokes-light.png | Bin 70844 -> 0 bytes ...dicated-full-screen-tui-front-door.i18n.yaml | 4 ++-- ...7-17-dedicated-full-screen-tui-front-door.md | 10 ---------- ...7-dedicated-full-screen-tui-front-door.zh.md | 10 ---------- packages/ui/tui/README.md | 2 +- 7 files changed, 3 insertions(+), 23 deletions(-) delete mode 100644 docs/assets/tui/jokes-dark.png delete mode 100644 docs/assets/tui/jokes-dynamic.gif delete mode 100644 docs/assets/tui/jokes-light.png diff --git a/docs/assets/tui/jokes-dark.png b/docs/assets/tui/jokes-dark.png deleted file mode 100644 index 876ea9c24c9422e76aa839bdead1edd6cb6df22f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 66598 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 a`$p*P1=7cj$ra$88&Gk1u|iQjum1%LHms5W diff --git a/docs/assets/tui/jokes-dynamic.gif b/docs/assets/tui/jokes-dynamic.gif deleted file mode 100644 index f0036572e8a2eeb0721d6929cafe404e0f6099ad..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 78162 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�%^@#dq}3j{IDaY(s{Y&2k(r-F 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 Date: Sun, 19 Jul 2026 12:52:34 +0800 Subject: [PATCH 162/163] docs: include JSDoc in cordis catalog blocks --- docs/cordis-catalog/events.md | 354 +++++++- docs/cordis-catalog/services.md | 849 +++++++++++++++++- .../2026-06-20-generated-cordis-catalog.md | 6 +- ...26-07-04-cordis-jsdoc-completeness-gate.md | 6 +- .../agent/tests/gen-cordis-catalog.spec.ts | 9 +- scripts/gen-cordis-api.ts | 4 +- scripts/gen-cordis-catalog.ts | 57 +- 7 files changed, 1259 insertions(+), 26 deletions(-) diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 1fc8f99af8..12f877ad48 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -3,9 +3,9 @@ # Cordis Events Catalog -Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration's JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. +Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. -This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns, grouped by scope. The **inherited tier** at the end is the cordis-core + loader/hmr/timer event surface a plugin also sees — pinned vendor source, summarized tersely. @@ -18,6 +18,16 @@ Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `n A fully configured agent and live session were published. Setup is composition-only; `agent/session-start` is the first startup-driving seam. Synchronous listener failure vetoes publication, while returned-promise rejection is reported. Detach requested during dispatch waits until every creation listener has observed the stable entry. ```ts cordis-catalog +/** + * A fully configured agent and live session were published. Setup is + * composition-only; `agent/session-start` is the first startup-driving seam. + * Synchronous listener failure vetoes publication, while returned-promise + * rejection is reported. Detach requested during dispatch waits until every + * creation listener has observed the stable entry. + * @param agent - the newly registered agent with its live session and completed setup. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/created'(this: Scoped, agent: Agent): void ``` @@ -30,6 +40,14 @@ Source: [`packages/core/agent/src/types.ts:141`](../../packages/core/agent/src/t An agent left the registry; AgentLoop emits this after driver quiescence but before session detachment and scoped-registration unwind. Custom registry users own their driver-ordering contract. ```ts cordis-catalog +/** + * An agent left the registry; AgentLoop emits this after driver quiescence + * but before session detachment and scoped-registration unwind. Custom + * registry users own their driver-ordering contract. + * @param agent - the exact agent removed from the registry. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/disposed'(this: Scoped, agent: Agent): void ``` @@ -42,6 +60,16 @@ Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/t A step or turn errored. The loop reports a failure here (plus the logger) even when the error has no in-turn position for a session `error` event. ```ts cordis-catalog +/** + * A step or turn errored. The loop reports a failure here (plus the logger) + * even when the error has no in-turn position for a session `error` event. + * @param agent - the agent whose turn errored. + * @param turn - the turn in which the failure surfaced. + * @param step - the step at which the failure surfaced. + * @param error - the failure, verbatim. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/error'(this: Scoped, agent: Agent, turn: number, step: number, error: Error): void ``` @@ -54,6 +82,22 @@ Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/t Awaited serial checkpoint for session-surface mutation after prompt assembly and before `step/start`; appends land outside the pending step. The loop derives history once afterward, so compaction records and replacements are included without rewriting an assembled request. The prompt and prefix are the exact pressure inputs for that request, and `signal` cancels listener work. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Awaited serial checkpoint for session-surface mutation after prompt + * assembly and before `step/start`; appends land outside the pending step. + * The loop derives history once afterward, so compaction records and + * replacements are included without rewriting an assembled request. The + * prompt and prefix are the exact pressure inputs for that request, and + * `signal` cancels listener work. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent opening the step. + * @param turn - the open turn number. + * @param step - the pending step number. + * @param fullSystemPrompt - the assembled prompt. + * @param sessionPrefix - the frozen request prefix. + * @param signal - the turn abort signal. + * @mode serial + */ 'agent/pre-step'(this: Scoped, agent: Agent, turn: number, step: number, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal): Promise | void ``` @@ -66,6 +110,15 @@ Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/t Allow, rewrite, or block one drained prompt before it becomes a user message. Call `next()` for the unchanged default. ```ts cordis-catalog +/** + * Allow, rewrite, or block one drained prompt before it becomes a user + * message. Call `next()` for the unchanged default. + * @param agent - the agent draining its inbox. + * @param content - the drained message's blocks, as queued. + * @param source - the message's resolved source. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/prompt-submit'(this: Scoped, agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise): Promise ``` @@ -78,6 +131,15 @@ Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/t Detached, frozen content entered the agent's inbox. Source defaults have already been applied, so these are the exact values retained for the log. ```ts cordis-catalog +/** + * Detached, frozen content entered the agent's inbox. Source defaults have + * already been applied, so these are the exact values retained for the log. + * @param agent - the agent whose inbox received the message. + * @param content - the accepted content blocks retained by the inbox. + * @param info - the accepted source plus whether it entered as steering. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/queued'(this: Scoped, agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void ``` @@ -90,6 +152,17 @@ Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/t Replace the frozen call configuration. Model-visible content must use logged channels; this seam cannot mutate messages. Injection here joins the next request because the current step boundary is already fixed. ```ts cordis-catalog +/** + * Replace the frozen call configuration. Model-visible content must use + * logged channels; this seam cannot mutate messages. Injection here joins + * the next request because the current step boundary is already fixed. + * @param agent - the agent making the model call. + * @param turn - the open turn number. + * @param step - the step whose request this is. + * @param config - the config the loop would use (frozen); return a replacement to switch. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/request'(this: Scoped, agent: Agent, turn: number, step: number, config: LlmCallConfig, next: () => Promise): Promise ``` @@ -102,6 +175,20 @@ Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/t Compose request-only messages placed before derived history. The frozen result is computed once per loop instance, logged on its anchoring request header, and reused so the provider prefix remains stable. Interrupted composition is discarded. Composition precedes the first `agent/pre-step` and request boundary, so listener appends join the current request and pressure accounting sees the composed prefix. Changing context belongs in history; contributors should prepend to `await next()` to preserve registration order. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Compose request-only messages placed before derived history. The frozen + * result is computed once per loop instance, logged on its anchoring request + * header, and reused so the provider prefix remains stable. Interrupted + * composition is discarded. Composition precedes the first `agent/pre-step` + * and request boundary, so listener appends join the current request and + * pressure accounting sees the composed prefix. Changing context belongs in + * history; contributors should prepend to `await next()` to preserve registration order. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param agent - the agent whose session prefix is being composed. + * @param prefix - the frozen seed; return an extended replacement. + * @param signal - aborts composition when the step is torn down. + * @mode waterfall + */ 'agent/session-prefix'(this: Scoped, agent: Agent, prefix: Message[], signal: AbortSignal, next: () => Promise): Promise ``` @@ -114,6 +201,16 @@ Source: [`packages/core/agent/src/types.ts:241`](../../packages/core/agent/src/t The session lifecycle began, once before the first turn. Use `agent.inject()` to seed model-facing context. This is a notification, not a veto; disposal requested by a lifecycle owner is rechecked before the driver starts. ```ts cordis-catalog +/** + * The session lifecycle began, once before the first turn. Use + * `agent.inject()` to seed model-facing context. This is a notification, not + * a veto; disposal requested by a lifecycle owner is rechecked before the + * driver starts. + * @param agent - the agent whose session lifecycle began. + * @param source - why the session started (fresh startup, resume, …). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/session-start'(this: Scoped, agent: Agent, source: SessionStartSource): void ``` @@ -126,6 +223,14 @@ Source: [`packages/core/agent/src/types.ts:182`](../../packages/core/agent/src/t Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does not enter `running` synchronously; drive lifecycle from this event. ```ts cordis-catalog +/** + * Agent status changed (`idle` ⇄ `running`, or → `disposed`). `send()` does + * not enter `running` synchronously; drive lifecycle from this event. + * @param agent - the agent whose status flipped. + * @param status - the status just entered (the transition's destination). + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode emit + */ 'agent/status'(this: Scoped, agent: Agent, status: AgentStatus): void ``` @@ -138,6 +243,16 @@ Source: [`packages/core/agent/src/types.ts:159`](../../packages/core/agent/src/t Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). ```ts cordis-catalog +/** + * Waterfall: post-process the assembled assistant {@link Message} before + * tool dispatch (validation, content rewriting, …). + * @param agent - the agent that received the step's response. + * @param turn - the open turn number. + * @param step - the step that produced the message. + * @param message - the assistant message as assembled from the stream. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/step-result'(this: Scoped, agent: Agent, turn: number, step: number, message: Message, next: () => Promise): Promise ``` @@ -150,6 +265,15 @@ Source: [`packages/core/agent/src/types.ts:252`](../../packages/core/agent/src/t Override whether the turn continues. The default continues after tool calls or steering and stops otherwise; a continue reason becomes steering. ```ts cordis-catalog +/** + * Override whether the turn continues. The default continues after tool + * calls or steering and stops otherwise; a continue reason becomes steering. + * @param agent - the agent deciding whether to run another step. + * @param turn - the turn being continued or stopped. + * @param defaultDecision - what the loop would do absent an override. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode waterfall + */ 'agent/turn-continuation'(this: Scoped, agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise): Promise ``` @@ -162,6 +286,15 @@ Source: [`packages/core/agent/src/types.ts:262`](../../packages/core/agent/src/t Monotonic terminal-stop checkpoint after continuation and steering are folded; a stop remains authoritative through turn close and flush: steering queued in that window is discarded, while ordinary sends survive. ```ts cordis-catalog +/** + * Monotonic terminal-stop checkpoint after continuation and steering are + * folded; a stop remains authoritative through turn close and flush: + * steering queued in that window is discarded, while ordinary sends survive. + * @param agent - the agent whose composed continuation outcome may be stopped. + * @param turn - the turn at its terminal-stop checkpoint. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @mode serial + */ 'agent/turn-stop'(this: Scoped, agent: Agent, turn: number): ContinuationStop | undefined ``` @@ -176,6 +309,15 @@ Source: [`packages/core/agent/src/types.ts:272`](../../packages/core/agent/src/t A declarative agent entry failed before it could publish a live agent. Consumers that buffer work for the configured identity use this transient signal to reject that work instead of waiting forever. Normal factory teardown suppresses failures from the cancelled startup attempt. ```ts cordis-catalog +/** + * A declarative agent entry failed before it could publish a live agent. + * Consumers that buffer work for the configured identity use this + * transient signal to reject that work instead of waiting forever. Normal + * factory teardown suppresses failures from the cancelled startup attempt. + * @param sessionId - exact shared agent/session identity that failed startup. + * @param error - persistence, setup, or publication failure. + * @mode emit + */ 'agent-loop/config-start-failed'(sessionId: SessionId, error: unknown): void ``` @@ -188,6 +330,13 @@ Source: [`packages/core/agent-loop/src/index.ts:362`](../../packages/core/agent- Ask composed answerers for one decision. Return an outcome to claim the request or call `next()`; failure yields the fail-closed default. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. ```ts cordis-catalog +/** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()`; failure yields the fail-closed default. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @mode waterfall + */ 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise ``` @@ -202,6 +351,13 @@ Source: [`packages/ui/user-approval/src/index.ts:31`](../../packages/ui/user-app Single-slot decision for the next FileSystem.editText. Calling `next()` yields an unconditional edit; the first returned guard wins. ```ts cordis-catalog +/** + * Single-slot decision for the next {@link FileSystem.editText}. Calling + * `next()` yields an unconditional edit; the first returned guard wins. + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> ``` @@ -214,6 +370,14 @@ Source: [`packages/fs/fs/src/index.ts:61`](../../packages/fs/fs/src/index.ts) Record a successful observation. Listeners must be synchronous recorders: throws fail the tool call and returned promises are not awaited. ```ts cordis-catalog +/** + * Record a successful observation. Listeners must be synchronous recorders: + * throws fail the tool call and returned promises are not awaited. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. + * @mode emit + */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void ``` @@ -226,6 +390,14 @@ Source: [`packages/fs/fs/src/index.ts:70`](../../packages/fs/fs/src/index.ts) Single-slot decision for the next FileSystem.writeText. Calling `next()` yields the bare provider's unconditional write; the first listener that returns an intent owns the decision rather than composing with peers. ```ts cordis-catalog +/** + * Single-slot decision for the next {@link FileSystem.writeText}. Calling + * `next()` yields the bare provider's unconditional write; the first listener + * that returns an intent owns the decision rather than composing with peers. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. + * @mode waterfall + */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise): Promise ``` @@ -240,6 +412,17 @@ Source: [`packages/fs/fs/src/index.ts:53`](../../packages/fs/fs/src/index.ts) Waterfall around every streaming model call (retry, replay, routing). Bound to the LlmService; call `next()` to reach the resolved adapter's stream, or yield your own chunks to short-circuit. ```ts cordis-catalog +/** + * Waterfall around every streaming model call (retry, replay, routing). + * Bound to the {@link LlmService}; call `next()` to reach the resolved + * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request. A LOOP-built request arrives + * deep-frozen (mutation throws): its content is a pure function of the + * session log (the reconstructability RFC), so listeners read it, never + * rewrite it. A hand-built one-shot (compaction summarize) is the + * caller's own object and stays mutable here. + * @mode waterfall + */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` @@ -254,6 +437,17 @@ Source: [`packages/llm/llm/src/index.ts:40`](../../packages/llm/llm/src/index.ts Creation announcement during session publication. A synchronous throw vetoes and rolls back with a paired disposal; detach requested during dispatch is deferred. A returned-promise rejection is logged but cannot retroactively veto this synchronous boundary. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only sessions entered through that agent's context. ```ts cordis-catalog +/** + * Creation announcement during session publication. A synchronous throw vetoes and rolls + * back with a paired disposal; detach requested during dispatch is deferred. + * A returned-promise rejection is logged but cannot retroactively veto this + * synchronous boundary. + * 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): void ``` @@ -264,6 +458,15 @@ Source: [`packages/core/session/src/index.ts:47`](../../packages/core/session/sr Emitted once when an announced session leaves the store, including publication rollback, but never for an entry whose creation announcement did not begin. Listener failures are logged and contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the owner scope. ```ts cordis-catalog +/** + * Emitted once when an announced session leaves the store, including + * publication rollback, but never for an entry whose creation announcement + * 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): void ``` @@ -274,6 +477,17 @@ Source: [`packages/core/session/src/index.ts:57`](../../packages/core/session/sr Post-commit, fire-and-forget append feed. The listener snapshot resolves before the log push, but callbacks run after it; observer failures are logged and contained without making the committed append fail. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only events from sessions entered through that agent's context. ```ts cordis-catalog +/** + * Post-commit, fire-and-forget append feed. The listener snapshot resolves + * before the log push, but callbacks run after it; observer failures are + * logged and contained without making the committed append fail. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners + * 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, event: SessionEvent): void ``` @@ -286,6 +500,15 @@ Source: [`packages/core/session/src/index.ts:69`](../../packages/core/session/sr Awaited parallel durability checkpoint: every listener runs and the caller awaits all of them, with no waterfall veto. Dispatch through SessionStore.flush. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`) reuses the session's owner scope. ```ts cordis-catalog +/** + * Awaited parallel durability checkpoint: every listener runs and the + * caller awaits all of them, with no waterfall veto. Dispatch through + * {@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): Promise | void ``` @@ -298,6 +521,14 @@ Source: [`packages/core/session/src/index.ts:79`](../../packages/core/session/sr A ready child settled. Scope-filtered dispatch uses the same delegating parent carrier as `subagent/start`, so the lifecycle pair reaches the same scoped audience. ```ts cordis-catalog +/** + * A ready child settled. Scope-filtered dispatch uses the same delegating + * 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, info: SubagentRunEndInfo): void ``` @@ -308,6 +539,11 @@ Source: [`packages/subagent/subagent/src/index.ts:112`](../../packages/subagent/ A provider became resolvable in the registry. ```ts cordis-catalog +/** + * A provider became resolvable in the registry. + * @param provider - the registered provider. + * @mode emit + */ 'subagent/provider-added'(provider: SubagentProvider): void ``` @@ -318,6 +554,11 @@ Source: [`packages/subagent/subagent/src/index.ts:86`](../../packages/subagent/s A provider left the registry. Accepted runs remain holder-owned. ```ts cordis-catalog +/** + * A provider left the registry. Accepted runs remain holder-owned. + * @param name - the provider name that no longer resolves. + * @mode emit + */ 'subagent/provider-removed'(name: string): void ``` @@ -328,6 +569,16 @@ Source: [`packages/subagent/subagent/src/index.ts:92`](../../packages/subagent/s A provider established a ready child. For in-process providers, `ctx.agents.get(info.id)` resolves during this notification. Scope-filtered dispatch keys the carrier by the delegating parent, so a parent-scoped listener observes only its own delegations. Paired with `subagent/end`. ```ts cordis-catalog +/** + * A provider established a ready child. For in-process providers, + * `ctx.agents.get(info.id)` resolves during this notification. + * Scope-filtered dispatch keys the carrier by the delegating parent, so a + * 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, info: SubagentRunInfo): void ``` @@ -340,6 +591,14 @@ Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/ Expert waterfall over the assembled sections, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. ```ts cordis-catalog +/** + * Expert waterfall over the assembled sections, tools, and variables. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners + * receive only that scope's assemblies. The returned value is authoritative. + * @param assembly - the mutable assembly built from registered providers. + * @param context - the caller's per-assembly context. + * @mode waterfall + */ 'system-prompt/assemble'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise ``` @@ -350,6 +609,11 @@ Source: [`packages/core/system-prompt/src/index.ts:27`](../../packages/core/syst Emitted when any prompt provider changes. This registry notification is unfiltered because a global change affects every scope. ```ts cordis-catalog +/** + * Emitted when any prompt provider changes. This registry notification is + * unfiltered because a global change affects every scope. + * @mode emit + */ 'system-prompt/change'(): void ``` @@ -362,6 +626,15 @@ Source: [`packages/core/system-prompt/src/index.ts:33`](../../packages/core/syst A tool was registered or unregistered, or a scoped restriction changed (the available tool set changed — possibly for one scope only). An UNFILTERED registry-subject notification, deliberately not scope-filtered dispatch: a global change concerns every agent's next assembly, so a scoped listener subscribing here sees every change, not just its own scope's. ```ts cordis-catalog +/** + * A tool was registered or unregistered, or a scoped restriction changed + * (the available tool set changed — possibly for one scope only). An + * UNFILTERED registry-subject notification, deliberately not scope-filtered + * dispatch: a global change concerns every agent's next assembly, so a + * scoped listener subscribing here sees every change, not just its own + * scope's. + * @mode emit + */ 'tools/change'(): void ``` @@ -372,6 +645,14 @@ Source: [`packages/core/tools/src/index.ts:116`](../../packages/core/tools/src/i Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns a normalized result; wrappers may change only `exec.signal`, while call identity remains immutable. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Around-dispatch waterfall for timeout, retry, or metrics. `next()` returns + * a normalized result; wrappers may change only `exec.signal`, while call + * identity remains immutable. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the allowed call about to dispatch (name, parsed arguments, caller agent, signal). + * @mode waterfall + */ 'tools/execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` @@ -384,6 +665,14 @@ Source: [`packages/core/tools/src/index.ts:89`](../../packages/core/tools/src/in Accept, replace, enrich, or block a normalized dispatch result. `next()` accepts it unchanged; thrown tools still reach this seam as errors. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Accept, replace, enrich, or block a normalized dispatch result. `next()` + * accepts it unchanged; thrown tools still reach this seam as errors. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. + * @mode waterfall + */ 'tools/post-execute'(this: Scoped, exec: ToolExecution, result: Readonly, next: () => Promise): Promise ``` @@ -396,6 +685,13 @@ Source: [`packages/core/tools/src/index.ts:98`](../../packages/core/tools/src/in Allow, deny, or ask before dispatch. `next()` delegates to allow; missing approval support turns `ask` into denial. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. ```ts cordis-catalog +/** + * Allow, deny, or ask before dispatch. `next()` delegates to allow; missing + * approval support turns `ask` into denial. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent's calls. + * @param exec - the pending call (name, parsed arguments, caller agent). + * @mode waterfall + */ 'tools/pre-execute'(this: Scoped, exec: ToolExecution, next: () => Promise): Promise ``` @@ -408,6 +704,13 @@ Source: [`packages/core/tools/src/index.ts:80`](../../packages/core/tools/src/in Observe the frozen, lossless-JSON final outcome. Listener failures are contained. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. ```ts cordis-catalog +/** + * Observe the frozen, lossless-JSON final outcome. Listener failures are contained. + * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): keyed by `exec.agent`. + * @param exec - the execution object that traversed the pipeline. + * @param result - a deep-frozen snapshot of the final returned result. + * @mode emit + */ 'tools/result'(this: Scoped, exec: Readonly, result: Readonly): undefined ``` @@ -422,6 +725,16 @@ Source: [`packages/core/tools/src/index.ts:106`](../../packages/core/tools/src/i One `agent()` call settled (clean result, child failure, or run cancellation). Paired with Events['workflow/agent-start'] by `agent.seq`, exactly once per started call on every stop path — on an engine termination path (a worker killed past its grace) the end is engine-synthesized with outcome `'cancelled'`. ```ts cordis-catalog +/** + * One `agent()` call settled (clean result, child failure, or run + * cancellation). Paired with {@link Events['workflow/agent-start']} by + * `agent.seq`, exactly once per started call on every stop path — on an + * engine termination path (a worker killed past its grace) the end is + * engine-synthesized with outcome `'cancelled'`. + * @param info - the run's identity snapshot. + * @param agent - the call identity plus its outcome. + * @mode emit + */ 'workflow/agent-end'(info: WorkflowRunInfo, agent: WorkflowAgentEndInfo): void ``` @@ -432,6 +745,15 @@ Source: [`packages/workflow/workflow/src/index.ts:81`](../../packages/workflow/w One `agent()` call established a ready child run. Paired with Events['workflow/agent-end'] by `agent.seq`. A call that never receives a ready run from the provider emits neither event in this pair. ```ts cordis-catalog +/** + * One `agent()` call established a ready child run. Paired with + * {@link Events['workflow/agent-end']} by `agent.seq`. A call that never + * receives a ready run from the provider emits neither + * event in this pair. + * @param info - the run's identity snapshot. + * @param agent - the call's sequence number, label, phase, and child id. + * @mode emit + */ 'workflow/agent-start'(info: WorkflowRunInfo, agent: WorkflowAgentInfo): void ``` @@ -442,6 +764,15 @@ Source: [`packages/workflow/workflow/src/index.ts:70`](../../packages/workflow/w A workflow run settled (any stop reason). Fired when WorkflowRun.result resolves. Paired with Events['workflow/start']. ```ts cordis-catalog +/** + * A workflow run settled (any stop reason). Fired when + * {@link WorkflowRun.result} resolves. Paired with + * {@link Events['workflow/start']}. + * @param info - the run's identity snapshot. + * @param result - the outcome data (stop reason, error, agent count) — + * deliberately WITHOUT the result value (see {@link WorkflowResultInfo}). + * @mode emit + */ 'workflow/end'(info: WorkflowRunInfo, result: WorkflowResultInfo): void ``` @@ -452,6 +783,12 @@ Source: [`packages/workflow/workflow/src/index.ts:91`](../../packages/workflow/w The script emitted a narration line (a `log(message)` call). ```ts cordis-catalog +/** + * The script emitted a narration line (a `log(message)` call). + * @param info - the run's identity snapshot. + * @param message - the logged message, verbatim. + * @mode emit + */ 'workflow/log'(info: WorkflowRunInfo, message: string): void ``` @@ -462,6 +799,13 @@ Source: [`packages/workflow/workflow/src/index.ts:60`](../../packages/workflow/w The script entered a phase (a `phase(title)` call) — progress grouping for observers; no execution semantics. ```ts cordis-catalog +/** + * The script entered a phase (a `phase(title)` call) — progress grouping + * for observers; no execution semantics. + * @param info - the run's identity snapshot. + * @param title - the phase title, verbatim. + * @mode emit + */ 'workflow/phase'(info: WorkflowRunInfo, title: string): void ``` @@ -472,6 +816,12 @@ Source: [`packages/workflow/workflow/src/index.ts:53`](../../packages/workflow/w A workflow run started — the script's meta block validated, the body about to execute. Paired with Events['workflow/end']. ```ts cordis-catalog +/** + * A workflow run started — the script's meta block validated, the body + * about to execute. Paired with {@link Events['workflow/end']}. + * @param info - the run's identity snapshot (id + meta). + * @mode emit + */ 'workflow/start'(info: WorkflowRunInfo): void ``` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index dd637444f0..78ec4c82c0 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -3,9 +3,9 @@ # Cordis Services Catalog -Every `ctx.` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. -This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them. +This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them. The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary this repo owns. The **inherited tier** at the end is the cordis-core + loader/hmr/timer `ctx` surface a plugin also sees — pinned vendor source, summarized tersely. @@ -14,8 +14,31 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary Concrete agent factory and driver service. ```ts cordis-catalog +/** + * Create an agent and session under one caller-supplied identity, owned by + * the accessing fiber. Constructor-driven config calls mint a fresh combined + * id before entering this boundary. + * @param id - shared agent/session identity. + * @param options - concrete loop options. + * @param meta - optional fresh-session workspace metadata. + * @returns the published running agent. + */ create(id: SessionId, options: AgentOptions = {}, meta: Pick = {}): Agent + +/** + * Create an owned agent on a caller-supplied session id. + * @param ownerCtx - caller context that structurally owns the transaction. + * @param options - identities, session seed/metadata, loop options, setup, and cancellation. + * @returns the published handle. + */ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise + +/** + * Resume an owned agent from the configured persistence service. + * @param ownerCtx - caller context that owns load, setup, and the live lifecycle. + * @param options - persisted identity, loop options, setup, and cancellation. + * @returns the published handle. + */ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise ``` @@ -28,15 +51,115 @@ Source: [`packages/core/agent-loop/src/index.ts:407`](../../packages/core/agent- Agent registry (`ctx.agents`): tracks live agents so UI, hook, and orchestrator plugins can find them without depending on the concrete loop package. Agent *creation* is provided by whichever plugin implements the AgentFactory (`@deepseek-ai/dsh-agent-loop`), registered via setFactory. ```ts cordis-catalog +/** + * Register the agent-creation factory (the loop calls this on construction, + * effect-scoped). A traced Cordis service is canonicalized to its concrete + * target; each create/resume call is then traced through that caller's + * context so ownership follows the caller without stacking proxy layers. + * Throws if a factory is already registered. Returns the disposer; on + * dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @returns the disposer that clears the factory slot. The exact + * Cordis effect disposer (single-shot): composite (generator) effects may + * yield it directly — exact identity nests the teardown in order. + */ setFactory(factory: AgentFactory): () => void + +/** + * Create and publish a new agent through the registered factory. + * Distinct from {@link register} (which records an already-constructed + * agent): this constructs the agent and its session. Rejects if no factory is + * registered or creation/setup fails. The resolved {@link AgentHandle} lets + * the owner tear down exactly this agent. + * @param options - shared identity, session seed/metadata, and agent options. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async create(options: CreateAgentOptions): Promise + +/** + * Load a persisted session and resume an agent on it through the registered + * factory. Rejects if no factory is registered; the factory rejects if + * session persistence is not configured or persistence/setup fails. + * @param options - persisted identity, configuration, and optional setup. + * @returns the handle after setup, rollback-covered publication, and loop start complete. + */ async resume(options: ResumeAgentOptions): Promise + +/** + * Register a live agent. Throws if an agent with the same id is already + * registered. Emits `agent/created` on registration and `agent/disposed` + * when the calling fiber is disposed — both with the agent's scope carrier + * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the + * emits are scope-filtered regardless of which context invoked `register` + * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always + * requires passing the carrier). Returns the disposer. + * @param agent - the already-constructed agent to record in the store. + * @returns the EXACT Cordis effect disposer (single-shot; a repeat call + * returns undefined without awaiting an in-flight teardown). Exact + * identity is load-bearing: a composite (generator) effect that owns a + * teardown ORDER — the agent factory's lifecycle chain — must yield THIS + * function so Cordis nests the unregistration at that yield position; + * yielding a wrapper would leave it disposing as a concurrent sibling on + * owner unload, unregistering the agent (and emitting `agent/disposed`) + * while its final turn is still draining. + */ register(agent: Agent): () => void + +/** + * Insert an already-constructed agent without announcing it. This is the + * advanced ordered-lifecycle primitive used by the async agent factory: it + * first completes setup while the agent is unpublished, then assigns the + * returned detach closure into its pre-installed composite teardown before + * calling {@link announce}. Ordinary callers use {@link register}. + * @param agent - the prepared, unpublished agent. + * @param owner - live agent whose scoped context created this agent, or + * undefined for a top-level runtime root. This is runtime ownership, not + * the resumed session's durable parent lineage. + * @returns an idempotent closure that removes this exact entry and emits + * `agent/disposed` with listener failures contained. When called from a + * synchronous `agent/created` listener, removal and disposal wait until + * that creation dispatch unwinds. + */ enter(agent: Agent, owner: Agent | undefined): () => void + +/** + * Announce an agent previously inserted with {@link enter}. + * @param agent - the live inserted agent to announce. + * @throws if `agent` is not the exact live registry entry for its id, or its + * creation announcement already began (including a reentrant call from a + * creation listener). + */ announce(agent: Agent): void + +/** + * Look up a live agent. + * @param id - the shared agent/session id to look up. + * @returns the agent, or undefined when no live agent has that id. + */ get(id: SessionId): Agent | undefined + +/** + * Test whether a live agent was created through one exact parent agent's + * scoped context. Runtime ownership is independent of durable session + * lineage and remains unambiguous when unrelated providers reuse an id. + * @param id - the candidate child agent's shared agent/session id. + * @param owner - the expected runtime creator agent. + * @returns true only while the exact child entry is live under that owner. + */ isOwnedBy(id: SessionId, owner: Agent): boolean + +/** + * All live agents, in registration order. + * @returns a fresh array; mutating it does not affect the registry. + */ list(): Agent[] + +/** + * All live top-level agents in registration order. A top-level agent was + * created without an owning agent context; durable session lineage does not + * affect this runtime relation, so a resumed fork may still be a root. + * @returns a fresh array; mutating it does not affect the registry. + */ roots(): Agent[] ``` @@ -49,6 +172,24 @@ Source: [`packages/core/agent/src/index.ts:201`](../../packages/core/agent/src/i Approval service that applies session policy before answerers and logs every ask/outcome pair to the requesting session. It exposes deterministic policy changes to the model through prompt and pre-step notices. ```ts cordis-catalog +/** + * Ask the composed answerers to decide one readonly same-process request. + * The service borrows the request, agent, session, and live signal directly. + * The request requires an open turn because the audit pair must be enclosed + * by the durable log's commit/replay boundary; an idle ask rejects before + * appending anything. The answerer phase always produces an outcome: an + * aborted signal yields `'cancelled'`, a missing or throwing answerer yields + * `'unavailable'` (fail closed), and a rogue non-vocabulary return value is + * normalized to `'unavailable'`. A failure that prevents either audit append + * from committing still rejects because returning an unlogged decision would + * violate the pair. Session contains post-commit observer failures, so an + * authoritative append cannot reject the request or suppress its matching + * audit event. + * @param req - the pending decision (agent, tool identity, reason, signal). + * @returns the closed outcome; `'allowed-once'` is the only grant. + * @throws when no turn is open or either audit event fails before the session + * append commit point. + */ async request(req: ApprovalRequest): Promise ``` @@ -68,8 +209,27 @@ Implementations must honor these semantics: - Disposal kills all running background processes and awaits their exit. ```ts cordis-catalog +/** + * Apply implementation-owned defaults and caps to a request before execution. + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. + */ abstract resolve(request: BashExecRequest): BashExecSpec + +/** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise + +/** + * Start a background process and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live process handle (reads, kill, quiescence promise). + */ abstract start(spec: BashExecSpec): BashProcess ``` @@ -82,8 +242,25 @@ Source: [`packages/bash/bash/src/index.ts:49`](../../packages/bash/bash/src/inde Registry (`ctx.bashEnv`) for trusted, per-execution `DSH_*` variables. The namespace is rebuilt for every model bash call: ambient `DSH_*` values are discarded by the executor, then the registry's current snapshot is injected. Built-in shell facts remain owned by the registry itself while plugins can register additional, enumerable facts with effect-scoped disposal. ```ts cordis-catalog +/** + * Register one environment contributor. Names and keys are unique; built-in + * keys are reserved. Registration is disposed with the calling plugin fiber. + * @param contributor - declared key ownership and per-execution resolver. + * @returns the disposer that unregisters the contribution. + */ register(contributor: BashEnvContributor): () => void + +/** + * Build the trusted `DSH_*` snapshot for one bash tool execution. + * @param execution - the current tool execution. + * @returns an immutable environment overlay containing built-ins and current contributions. + */ collect(execution: ToolExecution): DshEnvironment + +/** + * Enumerate plugin-contributed variables without executing their resolvers. + * @returns declarations sorted by environment variable name. + */ list(): BashEnvVariableInfo[] ``` @@ -96,6 +273,15 @@ Source: [`packages/bash/tool-bash/src/index.ts:102`](../../packages/bash/tool-ba Registers one `ctx.codeRuntime` implementation. Program, budget, abort, and substrate failures resolve in CodeRunResult; only seam misuse rejects. Implementations bridge structured-cloneable bindings while treating programs as hostile peers, isolate runs from one another, and terminate and await in-flight runs during disposal. ```ts cordis-catalog +/** + * Execute one program against the request's bindings and capture what it + * emitted. See the class doc for the resolution contract (error is a result + * field; rejection means seam misuse only). + * @param request - the program, its bindings, and the abort signal; the + * request carries everything the runtime acts on, with no hidden defaults. + * @returns the run's outcome: completion value (when transferable), the + * ordered log capture, and the failure (if any). + */ abstract run(request: CodeRunRequest): Promise ``` @@ -108,7 +294,40 @@ Source: [`packages/code-runtime/code-runtime/src/index.ts:30`](../../packages/co Abstract compaction service. Implementations own trigger policy, retention, and summarization, and may consume a separate measurement service. A successful run replaces the selected surface span with one summary node and prevents concurrent compaction of the same session. Load one implementation per context as `ctx.compact`. ```ts cordis-catalog +/** + * Check token pressure and compact if the conversation is too large. + * Estimate the next request, including its session prefix, derived history, + * and system prompt. Above threshold, compact a head-anchored range ending at + * a balanced tool boundary and reconsolidate any prior automatic checkpoint. + * Return `null` when no compaction is needed or an open tail leaves no safe + * cutoff. A single oversized retained unit or prefix cannot be repaired here. + * + * @param agent - agent context owning the session surface and model options. + * @param fullSystemPrompt - assembled system prompt, counted toward the estimate. + * @param sessionPrefix - the instance's composed session prefix, counted toward the + * estimate. + * @param signal - cancellation signal; model-backed implementations must forward it. + * @returns the compaction result, or `null` if no compaction was needed. + */ abstract compactIfNeeded( agent: CompactAgentContext, fullSystemPrompt: string, sessionPrefix: readonly Message[], signal: AbortSignal, ): Promise + +/** + * Forcibly compact a range of surface nodes into a single summary node. + * `start` and `end` name an inclusive span by surface position, not numeric seq + * order; replacements can make visible seqs non-monotonic. Both edges must be + * balanced so assistant tool calls remain paired with their results. A model- + * backed implementation forwards cancellation and rejects active, missing, + * reversed, or unbalanced ranges. The target session is `agent.session`. + * Use {@link toolPairingBalancedBefore} and {@link toolPairingBalancedAfter} + * for the edge checks. + * + * @param start - first surface seq, inclusive. + * @param end - last surface seq, inclusive. + * @param agent - context whose session is mutated and whose routing options guide summarization. + * @param signal - optional cancellation; model-backed implementations must forward it. + * @throws when compaction is active or the range is missing, reversed, or unbalanced. + * @returns the appended event seqs, summary, replaced range, and token accounting. + */ abstract compactRegion( start: number, end: number, agent: CompactAgentContext, signal?: AbortSignal, ): Promise ``` @@ -121,13 +340,90 @@ Source: [`packages/compact/compact/src/index.ts:38`](../../packages/compact/comp Abstract filesystem provider. Targets must preserve identity across aliases; reads expose regular UTF-8 text or typed errors, listings are stable and content-free, and mutations are atomic. Optional guards add stale protection without changing the unguarded provider contract. ```ts cordis-catalog +/** + * Resolve a model/plugin-supplied path into a stable {@link FsTarget}. May perform I/O (a + * remote/sandboxed backend may need a round-trip to map a path to a stable identity), hence + * async even though the local backend only normalizes + realpaths. + * + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - optional cwd override and cancellation signal. + * @returns the stable target; the same file yields the same `targetKey`. + */ abstract resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise + +/** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Return path metadata without following the final path component when it is a + * symbolic link. This is intentionally path-shaped, not target-shaped: + * {@link resolve} follows symlinks to produce the stable identity used by + * normal reads/writes, while `lstat` lets a consumer reject the path itself + * before that follow happens. + * + * `opts.cwd` follows {@link resolve}'s cwd rules. `undefined` means the path is + * absent. + * @param path - the path to inspect; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent path. + */ abstract lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise + +/** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Stream the whole regular text file as decoded text chunks (same text + * semantics as {@link readText}, for large files). The backend owns + * cross-chunk UTF-8 decoding and binary rejection so the policy layer never + * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. + */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + +/** + * List direct children of a directory in stable name order. Returns resolved + * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. + */ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise + +/** + * Atomically create or replace UTF-8 text. `expected` guards intent and + * staleness; omission allows unconditional overwrite. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. + */ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise + +/** + * Atomically edit literal text. When supplied, the version guard is checked + * before matching so stale content reports `FS_STALE_VERSION`; omission edits + * the current content without a freshness precondition. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. + */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise ``` @@ -140,9 +436,39 @@ Source: [`packages/fs/fs/src/index.ts:80`](../../packages/fs/fs/src/index.ts) The abstract `llm` service: an adapter registry plus a streaming model-call surface, interceptable via the `llm/stream` waterfall. ```ts cordis-catalog +/** + * Register an adapter for the given provider routes. Throws `LlmError` with code + * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing). + * Disposed with the fiber. + * @param providers - every provider route this adapter should serve. + * @param adapter - the adapter that streams calls for those providers. + * @returns the disposer that unregisters all of them. + */ registerAdapter(providers: string[], adapter: LlmAdapter): () => void + +/** + * Describe provider routes with a registered adapter. + * @returns detached provider metadata in registration order. + */ listProviders(): LlmProviderInfo[] + +/** + * Discover models advertised by one registered provider. Catalog membership + * is advisory and never changes routing or request validation. + * @param provider - registered provider route to inspect. + * @returns detached model metadata in adapter-preferred order. + */ async listModels(provider: string): Promise + +/** + * Stream one model call as raw chunks (token-level deltas). Throws + * `LlmError` with code `NO_ADAPTER` if no adapter is registered for + * `options.provider`. Replay state is retained only when the same adapter + * instance owns its historical provider and the target provider. Dispatches + * through the `llm/stream` waterfall. + * @param options - the full request; `options.provider` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. + */ stream(options: GenerateOptions): AsyncIterable ``` @@ -155,9 +481,38 @@ Source: [`packages/llm/llm/src/index.ts:94`](../../packages/llm/llm/src/index.ts Owns the deployment's permission presets and their write path. Requires a confining `ctx.bash` executor and `ctx.approval`; unmatched knob values are reported as CUSTOM_PRESET, not an error. ```ts cordis-catalog +/** + * Resolve the preset matching the effective knob values. A still-matching + * last selection wins shared-bundle ties; otherwise the first table match + * wins, or {@link CUSTOM_PRESET} when no entry matches. + * @param events - the session's events in log order. + * @returns the effective preset name, or `custom` when nothing matches. + */ current(events: readonly SessionEvent[]): string + +/** + * Resolve a preset's knob bundle. + * @param name - the preset name to resolve. + * @returns the configured bundle. + * @throws when `name` is not in the table. + */ resolve(name: string): PresetSpec + +/** + * Build the client option for a table entry or {@link CUSTOM_PRESET}. A + * missing label falls back to the table key. + * @param name - a table key, or `custom`. + * @returns the option a client renders. + * @throws when `name` is neither a table key nor `custom`. + */ optionOf(name: string): PresetOption + +/** + * Record a changed preset, then update each changed knob through its own + * setter. Selecting the effective preset again appends nothing. + * @param session - the session the switch belongs to. + * @param name - the preset to switch to; unknown names throw. + */ set(session: Session, name: string): void ``` @@ -170,6 +525,17 @@ Source: [`packages/ui/permission/src/index.ts:94`](../../packages/ui/permission/ Abstract process-sandbox service. confine must return enforcing argv or fail closed at wrap or runner-execution time; silent unconfined passthrough is forbidden. Functional probes arbitrate multi-runner chains and may be skipped for a sole candidate, whose own refusal remains the fail-closed end. ```ts cordis-catalog +/** + * Wrap `argv` so it executes confined under `policy` on this host; the + * caller spawns the returned argv in place of its own. + * @param argv - the exact argv the caller is about to spawn (program plus + * arguments), NOT a shell string — a shell-shaped consumer passes + * `['bash', '-c', command]`. + * @param policy - the file-effect policy this execution runs under, + * carried per call (see {@link SandboxPolicy}). + * @returns the argv to spawn instead, plus the enforcement completeness + * the selected backend achieves for it. + */ abstract confine(argv: readonly string[], policy: SandboxPolicy): ConfinedArgv ``` @@ -182,10 +548,49 @@ Source: [`packages/sandbox/sandbox/src/index.ts:111`](../../packages/sandbox/san Durable append-only session storage. Implementations preserve contiguous, losslessly JSON-serializable events; append resolves only after durability, and load balances a complete interrupted tail without rewriting committed events. ```ts cordis-catalog +/** + * Resolve this backend's independent local artifact for a session without + * reading, creating, flushing, or otherwise materializing it. Backends such + * as SQLite that do not own one artifact per session return `undefined`. + * @param meta - the immutable session header whose artifact is requested. + * @returns the backend-specific absolute location, when one exists. + */ abstract locate(meta: SessionHeader): SessionLocation | undefined + +/** + * Register a new session's metadata. A backend MAY defer the physical write + * until the first {@link append} (lazy materialization), in which case a + * created-but-never-appended session is absent from {@link list} + * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. + */ abstract create(meta: SessionHeader): Promise + +/** + * Durably persist a batch of events (called from the write-behind drain at + * the `session/flush` checkpoint). Honors the append-only and contiguous-seq + * contracts: the first event's `seq` MUST equal the stored next-seq (after + * `load` has durably closed any interrupted turn). Rejects non-JSON- + * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. + */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise + +/** + * Load a header and balanced contiguous log. A complete interrupted final + * turn is preserved and durably closed with missing tool errors plus any open + * step and turn boundaries; only a torn final record is discarded. Unknown + * versions and corruption in the committed prefix reject. + * @param id - the persisted session to reload. + * @returns the header and a log ending on a balanced `turn/end`. + */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> + +/** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise ``` @@ -198,10 +603,40 @@ Source: [`packages/session-persistence/session-persistence/src/index.ts:42`](../ Live-preferred logical-corpus exact-read and relationship-tracing service. ```ts cordis-catalog +/** + * List the complete logical corpus using live-preferred records. + * @returns deterministic newest-first cloned session records. + */ listSessions(): Promise + +/** + * List lightweight raw-log event records for one logical session. + * @param sessionId - live-preferred session id to read. + * @returns event records in ascending seq order. + */ async listEvents(sessionId: SessionId): Promise + +/** + * Trace known ancestry and descendants from one corpus observation. + * @param sessionId - logical session id to trace. + * @returns a complete lineage or an explicit unresolved parent boundary. + * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles. + */ async traceSession(sessionId: SessionId): Promise + +/** + * Trace one event's direct positional and provenance relationships. + * @param request - target session id and event seq. + * @returns direct links plus the target's positional replacement chain. + * @throws when source resolution fails, the target is absent, or surface/provenance validation fails. + */ async traceEvent(request: SessionEventTraceRequest): Promise + +/** + * Read one full event plus a bounded raw-log context window. + * @param request - target session/seq and context sizes. + * @returns cloned target and neighboring events. + */ async readEvent(request: SessionEventReadRequest): Promise ``` @@ -214,13 +649,119 @@ In-memory session store (`ctx.sessions`). Persistence is intentionally not implemented here — persistence plugins subscribe to `session/event` and flush on `session/flush` / dispose. ```ts cordis-catalog +/** + * Create a session owned by the calling fiber: disposing that fiber stops + * event notification and removes the session from the store. `options.seed` + * populates the session with a copy of those events (replay/fork); + * `options.meta` attaches creation metadata (validated absolute `cwd`, + * `parentSession` lineage) as the immutable {@link SessionHeader} (the store + * fills `version`/`id`/`createdAt`). + * + * For an agent whose session must be torn down IN ORDER with its loop (so the + * loop's final flush is captured before the store attachment ends), do NOT use this + * — fold the session lifecycle into the agent's own effect via + * {@link prepare} + {@link enter} + {@link announce} (see + * `dsh-agent-loop`'s creation transaction). + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path (storage backends key directories off it). + */ create(id?: SessionId, options?: CreateSessionOptions): Session + +/** + * Build a session WITHOUT entering it into the store — validate the id/cwd and + * construct the {@link Session} (with its immutable {@link SessionHeader}). + * Pairs with {@link enter} + {@link announce}: a caller that owns a composite + * `ctx.effect` (the agent factory) folds the session lifecycle into that ONE + * effect so a fiber unload tears the session + agent down as a single ORDERED + * chain rather than as racing sibling effects — which would remove the publication hooks + * before the loop's closing `session/flush`, dropping the closing events. + * + * @param id - the session id; omitted, the store mints `session-`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. + * @throws if a session with `id` already exists, metadata is not a plain + * lossless-JSON record with valid scalar fields, or `meta.cwd` is a + * non-absolute path. + */ prepare(id?: SessionId, options?: CreateSessionOptions): Session + +/** + * Enter a {@link prepare}d session into the store: install the module-private + * append publication hooks and add it to the store. Returns the DETACH + * disposer (hooks + store removal). Does NOT emit `session/created` — + * the caller yields this disposer inside its effect and THEN calls + * {@link announce}, so a throwing `session/created` listener rolls the attach + * back instead of leaking it. + * + * Re-checks the id for a duplicate: `prepare` and `enter` are public + * cross-package primitives and a caller may interleave arbitrary work (or + * another create) between them, so a stale prepared session must NOT overwrite + * a live store entry of the same id — its detach disposer would later delete + * the REAL session. The {@link create} convenience and the agent factory call + * the two back-to-back so they never trip this, but the public seam cannot + * assume that. + * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (publication hooks + store removal). When called from + * a synchronous `session/created` listener, removal and disposal wait until + * that creation dispatch unwinds. + * @throws if a session with this id is already in the store. + */ enter(session: Session): () => void + +/** Emit `session/created` exactly once for an {@link enter}ed session (with + * the carrier {@link enter} captured). Separate from {@link enter} so the + * caller can yield the detach disposer first (rollback safety — see + * {@link enter}). + * @param session - the entered session to announce to listeners. + * @throws if the session is not live or its announcement already began, + * including a reentrant call from a creation listener. */ announce(session: Session): void + +/** + * Dispatch the awaited `session/flush` durability checkpoint for `session`, + * with the carrier captured at {@link enter}. THE flush entry point: the + * store owns the carrier, so callers (the loop's turn-end checkpoint, idle + * injection, teardown drains) must come through here rather than dispatch a + * raw `ctx.parallel('session/flush', …)` — one owner, one spelling, and the + * scoped-dispatch invariant can pin it. + * @param session - the session whose buffered events must reach durable storage. + * @returns resolves when every flush listener has settled; after all settle, + * rejects with the first registered listener failure if any listener failed. + */ async flush(session: Session): Promise + +/** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined + +/** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] + +/** + * Create a live child session from a turn-enclosed prefix of a live source. + * `boundary` is an inclusive source event seq; omitted means the source's + * current last event. A non-empty selected slice must end at `turn/end`. + * + * @param source - Live source session object or id. + * @param boundary - Inclusive source event seq to fork through; omitted means + * the source's current last event, and omitted on an empty source forks an + * empty child. + * @param childSessionId - Optional child session id; omitted delegates to + * `SessionStore`'s id policy. + * @returns The created live child session. + */ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Session ``` @@ -231,9 +772,42 @@ Source: [`packages/core/session/src/index.ts:577`](../../packages/core/session/s Registry of skill providers. It merges provider catalogs with stable first-wins duplicate handling, exposes sorted model-visible summaries, and loads full skill bodies on demand. ```ts cordis-catalog +/** + * Register a borrowed same-process provider synchronously during plugin apply. Duplicate and + * reserved names throw; remote initialization belongs in `list()`. Fiber disposal unregisters + * the provider and invalidates catalog caches. + * @param provider - the provider to register by `provider.name`. + * @returns the exact Cordis effect disposer that unregisters this provider; + * composite effects may yield it directly to preserve teardown ordering. + */ registerProvider(provider: SkillProvider): () => void + +/** + * Register a borrowed readonly runtime skill. Project entries outrank runtime entries, which + * outrank user entries. Same-name runtime entries are first-wins; a duplicate logs a warning and + * receives a no-op disposer so it cannot remove the winner. + * @param skill - the complete skill definition to expose for discovery. + * @returns the exact Cordis effect disposer, preserving composite teardown order and invalidating caches. + */ register(skill: SkillRegistration): () => void + +/** + * List model-invocable skill summaries for a workspace. Lookup options and + * provider candidates are readonly same-process values borrowed throughout + * discovery. + * @param options - lookup options; `cwd` selects project roots and `signal` cancels discovery. + * @returns sorted summaries, excluding skills disabled for model invocation. + */ async list(options: SkillLookupOptions = {}): Promise + +/** + * Load and validate the winning candidate, passing its opaque discovery locator back to the + * provider. Cancellation is rechecked after selection, including cache hits, and raced against + * loading so an uncooperative provider cannot hang the caller. + * @param name - kebab-case skill name. + * @param options - lookup options; `cwd` selects workspace-sensitive skills and `signal` cancels work. + * @returns the full skill, including body content, or `undefined`. + */ async get(name: string, options: SkillLookupOptions = {}): Promise ``` @@ -250,6 +824,11 @@ Semantics every implementation must honor: - `saveText` REJECTS on a real storage failure (permissions, ENOSPC, backend unavailable); the caller decides how to degrade (the spill policy treats a rejection as best-effort and keeps the inline result). ```ts cordis-catalog +/** + * Persist `input.content` to a session-scoped spill artifact. + * @param input - the owner, provenance, suggested name, and full text to save. + * @returns the saved artifact's {@link SpillRef}; rejects on a storage failure. + */ abstract saveText(input: SaveTextSpill): Promise ``` @@ -260,9 +839,37 @@ Source: [`packages/spill/spill/src/index.ts:45`](../../packages/spill/spill/src/ Named provider registry and capability-checked start surface. ```ts cordis-catalog +/** + * Register a provider under its name. Registration is effect-scoped and HMR + * safe; removing a provider blocks new starts but does not revoke runs that + * were already returned to their holders. + * @param provider - the trusted provider implementation. + * @returns the exact Cordis effect disposer. + */ registerProvider(provider: SubagentProvider): () => void + +/** + * Look up a provider by name. + * @param name - the provider name. + * @returns the provider, or undefined when absent. + */ getProvider(name: string): SubagentProvider | undefined + +/** + * List registered provider names in insertion order. + * @returns the registered names. + */ list(): string[] + +/** + * Establish a ready child on the named provider. Capability and semantic + * checks run before delegation. Provider ownership lasts until its promise + * fulfills; a rejection therefore has no run for the caller to dispose and + * emits no run lifecycle events. + * @param name - the provider to use. + * @param request - child prompt, parent, signal, and optional capabilities. + * @returns the ready holder-owned run. + */ async start(name: string, request: SubagentStartRequest): Promise ``` @@ -273,9 +880,42 @@ Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/ Registry service for the prompt inputs assembled before each model step. ```ts cordis-catalog +/** + * Register an ordered prompt section in the calling context's scope. A scoped + * section shadows a global section with the same name; duplicates within one + * layer and non-finite orders throw. Registration and disposal emit + * `system-prompt/change`. + * @param section - the section to register. + * @returns the exact Cordis effect disposer. + */ section(section: PromptSection): () => void + +/** + * Register a tool-schema provider in the calling context's scope. Global and + * matching scoped providers both contribute; returning the reserved + * {@link TOOL_ORDER_REST} name makes assembly fail. + * @param provider - evaluated for each assembly with its context. + * @returns the exact Cordis effect disposer. + */ tools(provider: (context: AssembleContext) => ToolProviderResult): () => void + +/** + * Register a prompt variable in the calling context's scope. Scoped values + * shadow globals; invalid or duplicate names throw. A provider may return + * `undefined`, but rendering a section that references that value then fails. + * @param name - the `[a-z][a-z0-9_]*` reference name. + * @param provider - evaluated for each assembly. + * @returns the exact Cordis effect disposer. + */ variable(name: string, provider: (context: AssembleContext) => string | undefined): () => void + +/** + * Assemble global and scoped providers, detach tool parameters, apply + * canonical ordering, then run the assembly waterfall. Scoped sections and + * variables shadow globals; the returned waterfall value is authoritative. + * @param context - the optional scope and plugin-defined assembly fields. + * @returns the authoritative post-waterfall assembly. + */ async assemble(context: AssembleContext = {}): Promise ``` @@ -286,13 +926,83 @@ Source: [`packages/core/system-prompt/src/index.ts:209`](../../packages/core/sys The `tasks` service: the runtime-global background task registry. See the module doc for the ownership, isolation, and lifecycle contracts. ```ts cordis-catalog +/** + * Preflight access, validation, and owner cleanup before starting and + * atomically registering work. A throwing starter leaves nothing registered; + * after it returns, registration cannot fail. Settlement records the outcome, + * notifies listeners, and releases waiters. + * @param spec - task identity, owner, and synchronous starter. + * @returns the registry-issued `-N` id. + */ start(spec: TaskStart): TaskId + +/** + * List caller-owned and unowned tasks in registration order without exposing + * another session's labels. + * @param caller - reading agent; a non-agent caller sees only unowned tasks. + * @returns fresh snapshots. + */ list(caller?: Agent): TaskSnapshot[] + +/** + * Return a non-consuming snapshot without changing its read cursor or notice + * state. Throws for an unknown or foreign task. + * @param id - task to look up. + * @param caller - reading agent checked against the owner. + * @returns a fresh snapshot. + */ get(id: TaskId, caller?: Agent): TaskSnapshot + +/** + * Read the next stream delta, or the idempotent final output after settlement. + * A terminal read marks the task reported. Throws for an unknown or foreign + * task. + * @param id - task to read. + * @param caller - reading agent checked against the owner. + * @returns output text and the post-read snapshot. + */ read(id: TaskId, caller?: Agent): TaskRead + +/** + * Request cancellation, then mark the task stopping and reported. A producer + * throw propagates without changing task state. Throws for an unknown or + * foreign task. + * @param id - task to cancel. + * @param caller - killing agent checked against the owner. + * @param reason - logged reason forwarded to the producer. + * @returns `requested` for live work, otherwise `already-finished`. + */ kill(id: TaskId, caller?: Agent, reason?: string): 'requested' | 'already-finished' + +/** + * Wait for settlement or timeout without cancelling the task. Caller abort + * rejects only while the task is live; after settlement it returns the + * terminal snapshot so a notice suppressed for this waiter is still delivered. + * Timed-out and aborted waits detach their resolvers. Throws for invalid, + * unknown, or foreign input. + * @param id - task to wait for. + * @param timeoutMs - positive finite wait bound in milliseconds. + * @param caller - waiting agent checked against the owner. + * @param signal - optional cancellation of the wait itself. + * @returns snapshot at settlement or timeout. + */ async wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise + +/** + * Register an effect-scoped completion listener. Each listener is contained; + * returned promises are observed but not awaited. No listener runs after + * service disposal. + * @param listener - receives each terminal snapshot and its exact owner. + * @returns disposer that unregisters the listener. + */ onTaskDone(listener: TaskDoneListener): () => void + +/** + * Attach an effect-scoped surface that can read and stop tasks. {@link start} + * refuses work while none is attached. + * @param name - diagnostic label; duplicate names remain independent. + * @returns disposer that detaches this surface. + */ attachSurface(name: string): () => void ``` @@ -305,7 +1015,29 @@ Source: [`packages/tasks/tasks/src/index.ts:76`](../../packages/tasks/tasks/src/ Replay owner for one service-wide estimator and isolated per-session folds. ```ts cordis-catalog +/** + * Measure current request pressure and surface through the durable tail. + * + * Provider usage is reused only when the latest successful call's canonical + * request envelope matches `requestHeader` and its total is no lower than + * that call's full heuristic anchor; otherwise the complete envelope and + * surface are heuristically repriced. + * + * `requestHeader` affects request pressure only; surface fields always + * describe the current session surface. Every call clones those positional + * nodes, so measurement is O(surface). + * + * @param session - session to replay through its current durable tail. + * @param requestHeader - optional effective request envelope replacing the latest logged header. + * @returns a detached deeply immutable pressure and surface measurement. + */ measure(session: Session, requestHeader?: EpochHeader): TokenMeasurement + +/** + * Heuristically price one model-visible message. + * @param message - message to price without mutation. + * @returns content and role-framing tokens under the fixed service heuristic. + */ estimateMessage(message: Message): number ``` @@ -318,12 +1050,72 @@ Source: [`packages/llm/token-meter/src/index.ts:106`](../../packages/llm/token-m Tool registry and execution pipeline. Scoped registrations shadow globals; one visibility resolver feeds presentation, lookup, and dispatch. ```ts cordis-catalog +/** + * Register globally or in the calling agent scope. Scoped tools shadow + * globals; duplicates within one layer and the reserved `run_code` name fail. + * @param definition - the tool schema, execution, and optional presentation functions. + * @returns the exact disposer that unregisters the tool. + */ register(definition: ToolDefinition): () => void + +/** + * Restrict global tools for the calling agent scope. Empty filters, unknown + * names, scope-local names, and reserved transport names fail. Restrictions + * intersect; scoped registrations remain visible. + * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove). + * @returns the exact disposer that lifts this restriction. + */ restrict(filter: ToolRestriction): () => void + +/** + * Register a monotonic guard after the extensible `tools/pre-execute` + * waterfall. A plain-context guard applies globally; one registered through + * `agent.ctx` applies only to that agent. Any matching guard may deny by + * returning a reason, while no guard can force-allow a call another guard + * denied. The exact effect disposer is returned for ordered ownership and + * HMR cleanup. + * @param guard - synchronous check; a returned string denies the execution. + * @returns the exact disposer that unregisters the guard. + */ guard(guard: ToolGuard): () => void + +/** + * Look up a tool as one scope sees it (scoped + * shadows global; a restricted-away global reads as absent). Presenters pass + * the calling agent so the rendered card matches the definition that + * actually executed. + * @param name - the tool name as registered. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns the definition the scope resolves, or undefined when none is visible. + */ get(name: string, scope?: ScopeKey): ToolDefinition | undefined + +/** + * Project visible definitions onto the allowlisted model-facing schema fields, + * excluding execution and presentation callbacks. + * @param scope - the viewing scope (the agent); omitted = the global view. + * @returns one deep-cloned schema per visible tool. + */ schemas(scope?: ScopeKey): ToolSchema[] + +/** + * Classify a pending call through the caller's visible tool definition. Only + * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or + * throwing classifiers are exclusive. + * @param exec - call name, parsed arguments, and optional agent scope. + * @returns the fail-closed scheduling mode. + */ executionMode(exec: ToolExecutionInput): ToolExecutionMode + +/** + * Execute through pre-policy, guards, around-dispatch, post-policy, and final + * notification. Tool and listener failures resolve as materialized error + * results; an invisible tool reports `UNKNOWN_TOOL`. The returned outcome is + * the same lossless, frozen snapshot final observers receive. + * @param exec - the typed same-process call input. The registry assigns its + * correlation token before policy begins. + * @returns the materialized final result. + */ async execute(exec: ToolExecutionInput): Promise ``` @@ -336,7 +1128,20 @@ Source: [`packages/core/tools/src/index.ts:438`](../../packages/core/tools/src/i `ctx.userInteraction`: one active UI provider plus an `ask()` surface. ```ts cordis-catalog +/** + * Register the UI provider. Only one provider may be active in a context. + * + * @param provider UI-side implementation that collects answers. + * @returns Disposer that unregisters this provider. + */ registerProvider(provider: UserInteractionProvider): () => void + +/** + * Ask the active UI provider and wait for the user's answer. + * + * @param request Questions, owner agent, and abort signal. + * @returns The answer chosen or typed by the human. + */ async ask(request: AskUserQuestionRequest): Promise ``` @@ -356,9 +1161,43 @@ Selection semantics (resolved at execution time, never order-dependent): - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. ```ts cordis-catalog +/** + * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for search. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerSearchProvider(provider: WebSearchProvider): () => void + +/** + * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` + * if its id is already registered for fetch. Returns a disposer; disposed + * with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. + */ registerFetchProvider(provider: WebFetchProvider): () => void + +/** + * Run one search through the selected provider. Resolves the provider at call + * time with the selection rules above; throws {@link WebError} when the + * capability cannot run. The seam enforces `request.maxResults` on the result: + * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. + */ async search(request: WebSearchRequest, signal?: AbortSignal): Promise + +/** + * Retrieve one URL through the selected provider. Resolves the provider at + * call time with the selection rules above; throws {@link WebError} when the + * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param signal - optional cancellation signal forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. + */ async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise ``` @@ -369,6 +1208,12 @@ Source: [`packages/web/web/src/index.ts:74`](../../packages/web/web/src/index.ts Workflow execution seam. Invalid requests throw before publication; a live run is holder-owned, its result never rejects, cancellation and disposal are bounded, and disposal waits for child cleanup within that bound. Lifecycle listener failures are contained, and `workflow/end` fires exactly once as the result settles. ```ts cordis-catalog +/** + * Parse and execute a workflow script. + * @param request - the script, its `args`, the parent agent, and an + * optional cancel signal. + * @returns the live run; its `result` resolves when the script settles. + */ abstract start(request: WorkflowStartRequest): WorkflowRun ``` diff --git a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md index eafce4feae..c4c57d36d6 100644 --- a/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md +++ b/docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md @@ -12,7 +12,7 @@ This is the wiring-axis complement to the [core-data-structures catalog](../../. Generate the catalog from source instead of hand-maintaining a table and verifying a subset. -`scripts/gen-cordis-catalog.ts` uses the TypeScript compiler API to emit separate event and service references from declarations and source JSDoc. Events include dispatch modes; services include public signatures. Deterministic `--write` and `--check` modes make both pages generated artifacts, with freshness enforced by `doc-sync`. +`scripts/gen-cordis-catalog.ts` uses the TypeScript compiler API to emit separate event and service references from declarations and source JSDoc. Events include dispatch modes and their original member JSDoc; services include public signatures with each method's original JSDoc. Deterministic `--write` and `--check` modes make both pages generated artifacts, with freshness enforced by `doc-sync`. Pure generation is correct here because the codebase is disciplined enough that the AST is the whole truth: every event/service name is a string literal that round-trips to a static declaration — there are no dynamically-named events and no runtime-only services. So a generated doc cannot be wrong, and it closes the undocumented-event gap structurally (generation enumerates source rather than checking a hand-written subset). @@ -21,7 +21,7 @@ Specific choices: - **`@mode` tag, cross-checked.** Each harness event's JSDoc carries an explicit `@mode emit|waterfall|parallel|serial` tag; the generator hard-errors on a missing tag. Where the signature shape is conclusive — a trailing `next: () => …` parameter is structurally a waterfall — it asserts the tag agrees and hard-errors on a contradiction. The emit/parallel/serial distinction is not structurally visible (`session/flush` returns `Promise | void` with no `next`, as does the ordered `agent/pre-step` checkpoint), so it is trusted from the tag. The authoring rule lives in [AGENTS.md](../../../../AGENTS.md). - **Tiered scope.** The harness tier (the 8 `@deepseek-ai/dsh-*` services + their events) is rendered in full from source. The inherited tier (cordis-core `ctx.on/emit/effect/provide/…` + the `internal/*` events + loader/hmr/timer) is pinned vendor source a plugin also sees; it is rendered tersely (name + one-line + source pointer) from a curated table in the generator, NOT walked from the vendor AST — the cordis-core `Context` mixes true ctx members with non-service fields (`root`, `baseUrl`, `logger`), and the vendor surface changes only on a deliberate vendor sync. - **Cross-links to the data-structure catalog.** A type name in a signature (`GenerateOptions`, `StreamChunk`, `ToolDefinition`, …) links to the core-data-structures page that documents it. The map is a small hand-curated const in the generator — NOT `type-equiv.manifest.json`, which documents the `…Map` symbols while signatures reference the derived union names, and lists a few symbols on two pages. -- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string that `doc-typecheck` recognizes and skips (a bare signature fragment is not standalone-compilable), excluded from the opt-out ratio — the same treatment `type-equiv` blocks get. +- **A dedicated fence.** Signature blocks use a ` ```ts cordis-catalog ` info string and place the original event or public-method JSDoc immediately before its declaration. `doc-typecheck` recognizes and skips the bare fragments, excluding them from the opt-out ratio — the same treatment `type-equiv` blocks get. This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md): `verify-event-taxonomy` and its `docs/architecture.md` table are retired (the architecture.md heading stays, its body now points at the catalog; the Service-map role table stays as curated prose). doc-typecheck, verify-md-wrap, verify-md-links, and verify-type-equiv are unchanged. @@ -34,6 +34,6 @@ This **supersedes the event-taxonomy half** of [doc-sync enforcement](2026-06-11 ## Consequences - The catalog cannot drift: a source change that the committed file doesn't reflect fails `verify-cordis-catalog` in the pre-push hook and CI. A new event with no `@mode` tag, or a tag that contradicts its signature, fails the generator outright. -- Event prose now has a single home — the JSDoc at the declaration. Thin JSDoc yields a thin catalog entry, which pressures authors to document at the source (the generator is a forcing function for the AGENTS.md "every export has a semantic JSDoc" rule). +- Event and service-method contracts have a single home — the JSDoc at the declaration. The catalog repeats that original JSDoc inside its generated signature block and uses its description portion as entry prose, so thin source documentation yields a thin catalog entry. - The inherited tier is hand-summarized, so a vendor sync that adds/renames a cordis-core event or `ctx` member needs a matching edit to the curated table in `gen-cordis-catalog.ts`. This is the deliberate cost of not walking pinned vendor source; it changes rarely and is called out in the generator. - `verify-event-taxonomy.ts` is deleted and the `docs/architecture.md` event table is gone; anyone who linked to a specific table row now lands on the generated catalog instead. diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md index 42ab306bb0..1c7e515c4f 100644 --- a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -20,14 +20,14 @@ The contract: - **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match). - **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged. -The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. +The generator keeps two views of the same source comment: `parseJsDoc` ends entry prose at the first block tag, while the `ts cordis-catalog` signature block includes the original JSDoc with `@param`, `@returns`, and `@mode` intact. Readers therefore see the complete source contract without block-tag text leaking into the surrounding prose. Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule. ## Alternatives considered - **An ESLint rule** — cannot see the scope's machine definition (which `interface Events` members and which `ctx.` classes are the cordis surface); the catalog generator computes exactly that mapping on every run, so the gate lives there. -- **Rendering the tags into the catalog** — restructuring the services section into per-method entries was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. +- **Expanding every method into a separate prose section** — rejected: the catalog stays skimmable by keeping one service section and one signature block, while the JSDoc attached to each declaration preserves the full method contract in place. - **An escape-hatch tag** — none exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off. ## Consequences @@ -36,4 +36,4 @@ Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` dr - The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. - The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. - `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. -- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate. +- Each generated event or method fragment carries its original JSDoc, while the prose summary remains tag-free. Source edits therefore refresh both the readable index and the exact contract shown beside the signature. diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index 856ef899f7..24841c8c8e 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -6,7 +6,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts' +import { collectEvents, collectServices, renderEvents, renderServices } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ @@ -58,6 +58,8 @@ describe('gen-cordis-catalog collectEvents', () => { )) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) + expect(events[0]?.jsDoc).toBe('/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */') + expect(renderEvents(events)).toContain("```ts cordis-catalog\n/**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n'fix/happened'(id: string): void\n```") }) it('classifies a trailing-next signature as a waterfall', () => { @@ -158,6 +160,11 @@ export class FixService { expect(services).toHaveLength(1) expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' }) expect(services[0]?.methods).toHaveLength(3) + expect(services[0]?.methods[0]).toEqual({ + signature: 'run(id: string): string', + jsDoc: '/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */', + }) + expect(renderServices(services)).toContain('```ts cordis-catalog\n/**\n * Do the thing.\n * @param id - which thing to do.\n * @returns the outcome of doing it.\n */\nrun(id: string): string\n\n/** Fire and forget (void needs no @returns). */\npoke(): void') }) it('hard-errors on a public method with no JSDoc at all', () => { diff --git a/scripts/gen-cordis-api.ts b/scripts/gen-cordis-api.ts index cc87c379e9..5d8a70fba4 100644 --- a/scripts/gen-cordis-api.ts +++ b/scripts/gen-cordis-api.ts @@ -80,7 +80,7 @@ function referencedTypes(seeds: string[], decls: Map): { name: s function render(): string { const services = collectServices() const events = collectEvents().sort((a, b) => a.name.localeCompare(b.name)) - const types = referencedTypes(services.flatMap(service => service.methods), collectTypeDecls()) + const types = referencedTypes(services.flatMap(service => service.methods.map(method => method.signature)), collectTypeDecls()) const lines: string[] = [ '/**', ' * Generated by scripts/gen-cordis-api.ts — do not edit by hand; run', @@ -145,7 +145,7 @@ function render(): string { lines.push(' methods: [],') } else { lines.push(' methods: [') - for (const method of service.methods) lines.push(` ${quote(method)},`) + for (const method of service.methods) lines.push(` ${quote(method.signature)},`) lines.push(' ],') } lines.push(' },') diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 66bc33851b..08f04947da 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -72,6 +72,8 @@ interface EventEntry { scope: string /** Full signature text (the method-signature member, JSDoc stripped). */ signature: string + /** Original declaration JSDoc, dedented from its containing interface. */ + jsDoc: string /** Dispatch mode from the `@mode` tag. */ mode: Mode /** Description prose (JSDoc minus the `@mode` tag), one line per paragraph. */ @@ -80,6 +82,14 @@ interface EventEntry { source: string } +/** One public service method and the source contract attached to it. */ +interface ServiceMethodEntry { + /** Public method signature (body stripped). */ + signature: string + /** Original method JSDoc, dedented from its containing class. */ + jsDoc: string +} + /** One harness service, extracted from an `interface Context` block. */ interface ServiceEntry { /** The `ctx.` name, e.g. `llm`. */ @@ -90,8 +100,8 @@ interface ServiceEntry { abstract: boolean /** Class-level JSDoc prose, one line per paragraph. */ doc: string - /** Public method signatures (bodies stripped), in source order. */ - methods: string[] + /** Public methods (bodies stripped), in source order. */ + methods: ServiceMethodEntry[] /** Source pointer of the class declaration. */ source: string } @@ -115,6 +125,22 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() } +/** + * Copy a node's original JSDoc while removing only the indentation imposed by + * its containing interface or class. + */ +function jsDocText(text: string, sf: ts.SourceFile, node: ts.Node): string { + const raw = rawJsDoc(text, node) + if (!raw) return '' + const start = text.lastIndexOf(raw, node.getStart(sf)) + const { line } = sf.getLineAndCharacterOfPosition(start) + const lineStart = sf.getPositionOfLineAndCharacter(line, 0) + const indent = text.slice(lineStart, start) + return raw.split('\n') + .map((lineText, index) => index > 0 && lineText.startsWith(indent) ? lineText.slice(indent.length) : lineText) + .join('\n') +} + /** Walk every harness `interface Events` block and extract its events, hard- * erroring (aggregated) on any JSDoc-completeness violation: a missing/ * contradicted `@mode`, missing description prose, or an undocumented payload @@ -155,7 +181,7 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { const { params } = parseTags(raw) checkParams(where, 'event', member.parameters, params, sf, p => (ts.isIdentifier(p.name) && p.name.text === 'this') || (hasNext && p === last), violations) - if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, jsDoc: jsDocText(text, sf, member), mode, doc, source: src }) } } reportViolations('gen-cordis-catalog', violations) @@ -180,7 +206,7 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { if (!body) continue // Resolve each ctx key to its service class (shared walk) and emit an entry. for (const { key, type, cls, abstract, doc: clsDoc } of serviceClasses(body, sf, rel, violations)) { - const methods: string[] = [] + const methods: ServiceMethodEntry[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue // Only instance methods callable through `ctx.` are surface; @@ -193,9 +219,9 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { if (nonPublic) continue const memberName = member.name.getText(sf) if (memberName.startsWith('[')) continue // computed/symbol members - methods.push(memberSignature(member, sf)) const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})` const raw = rawJsDoc(text, member) + methods.push({ signature: memberSignature(member, sf), jsDoc: jsDocText(text, sf, member) }) if (!raw) { violations.push(`${where} has no JSDoc.`); continue } if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) const { params, returns } = parseTags(raw) @@ -275,7 +301,7 @@ function typeLinks(signature: string): string { function renderEvent(e: EventEntry): string[] { const out = [`### \`${e.name}\` — ${e.mode}`, ''] if (e.doc) out.push(e.doc, '') - out.push('```' + FENCE, e.signature, '```', '') + out.push('```' + FENCE, e.jsDoc, e.signature, '```', '') const links = typeLinks(e.signature) if (links) out.push(links, '') out.push(`Source: [\`${e.source}\`](../../${e.source.split(':')[0]})`, '') @@ -288,8 +314,13 @@ function renderService(s: ServiceEntry): string[] { const out = [`## \`ctx.${s.key}\` — \`${s.type}\`${kind}`, ''] if (s.doc) out.push(s.doc, '') if (s.methods.length) { - out.push('```' + FENCE, ...s.methods, '```', '') - const links = typeLinks(s.methods.join('\n')) + const declarations = s.methods.flatMap((method, index) => [ + ...(index > 0 ? [''] : []), + method.jsDoc, + method.signature, + ]) + out.push('```' + FENCE, ...declarations, '```', '') + const links = typeLinks(s.methods.map(method => method.signature).join('\n')) if (links) out.push(links, '') } out.push(`Source: [\`${s.source}\`](../../${s.source.split(':')[0]})`, '') @@ -304,15 +335,15 @@ const BANNER = [ ] /** The shared GENERATED + freshness-gate + fence notice paragraph. */ -const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence (skipped by doc-typecheck, since a bare signature is not standalone-compilable). Type names in a signature link to the page that documents them.' +const GATE_NOTICE = 'This file is GENERATED from source (`scripts/gen-cordis-catalog.ts`) and verified fresh by `pnpm run verify-cordis-catalog` (part of `doc-sync`) — do not edit it by hand. Signature blocks use a `ts cordis-catalog` fence and include the original source JSDoc immediately before each event or service method. doc-typecheck skips these bare declaration fragments; type names in a signature link to the page that documents them.' /** Render the events catalog (pure, deterministic given sorted inputs). */ -function renderEvents(events: EventEntry[]): string { +export function renderEvents(events: EventEntry[]): string { const lines: string[] = [ ...BANNER, '# Cordis Events Catalog', '', - 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and the declaration\'s JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', + 'Every cordis event a plugin can listen to: exact signature, dispatch mode, and original declaration JSDoc. This is one axis of the **wiring** reference a plugin author works against — the callable `ctx.` surface is the sibling [services catalog](services.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around.', '', GATE_NOTICE, '', @@ -342,12 +373,12 @@ function renderEvents(events: EventEntry[]): string { } /** Render the services catalog (pure, deterministic given sorted inputs). */ -function renderServices(services: ServiceEntry[]): string { +export function renderServices(services: ServiceEntry[]): string { const lines: string[] = [ ...BANNER, '# Cordis Services Catalog', '', - 'Every `ctx.` service a plugin can call: the exact public interface plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', + 'Every `ctx.` service a plugin can call: the exact public interface with original method JSDoc, plus the class JSDoc. This is one axis of the **wiring** reference a plugin author works against — the events a plugin listens to are the sibling [events catalog](events.md), and [core-data-structures/](../core-data-structures/core.md) catalogs the *data structures* these signatures move around. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against.', '', GATE_NOTICE, '', From 2faeabb05a2ed90f4853d04162bbc3dc57c86164 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 19 Jul 2026 13:13:14 +0800 Subject: [PATCH 163/163] refactor(examples): rename coding-agent leaf to repl-agent Name the runnable leaf for the line-oriented front door it owns, matching the existing tui-agent and acp-agent organization. Move the complete config, Code Mode overlay, tests, metadata, and generated composition graph together, then update every loader path and repository reference. Keep the shared model identity independent of its terminal front door by phrasing the persona as a coding-agent role rather than retaining the retired leaf name. Regenerate graph and tool catalogs and re-record each affected bilingual pair so derived documentation cannot point at the removed path. --- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 4 +- docs/development.zh.md | 4 +- docs/graph-atlas.md | 2 +- ...t-variables-and-tool-guidance-ownership.md | 6 +- .../2026-07-08-tool-output-spill-files.md | 2 +- .../2026-06-18-compaction-capability-seam.md | 2 +- ...6-07-09-bash-backed-grep-glob-discovery.md | 2 +- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 4 +- ...dedicated-full-screen-tui-front-door.zh.md | 4 +- .../2026-07-03-documentation-graph-atlas.md | 2 +- .../2026-07-04-fold-stdio-ui-helper.md | 2 +- ...-18-tui-terminal-state-snapshots.i18n.yaml | 4 +- ...2026-07-18-tui-terminal-state-snapshots.md | 2 +- ...6-07-18-tui-terminal-state-snapshots.zh.md | 2 +- docs/tool-catalog.md | 4 +- examples/README.md | 12 +-- examples/coding-agent/composition.md | 88 ------------------- examples/cordis-agent/README.md | 2 +- examples/echo-agent/README.md | 2 +- .../{coding-agent => repl-agent}/README.md | 4 +- .../code-mode.cordis.yml | 2 +- examples/repl-agent/composition.md | 88 +++++++++++++++++++ .../{coding-agent => repl-agent}/cordis.yml | 2 +- .../{coding-agent => repl-agent}/package.json | 2 +- .../tests/code-mode-keyless-smoke.e2e.ts | 0 .../tests/code-mode.e2e.ts | 2 +- .../tests/coding-task.e2e.ts | 0 .../tests/compaction.e2e.ts | 0 .../tests/full-loop.e2e.ts | 0 .../tests/harness.ts | 2 +- .../tests/keyless-smoke.e2e.ts | 8 +- .../tests/resume.e2e.ts | 0 .../tests/todo-write.e2e.ts | 0 examples/tui-agent/README.md | 4 +- examples/tui-agent/code-mode.cordis.yml | 6 +- examples/tui-agent/composition.md | 2 +- examples/tui-agent/cordis.yml | 6 +- package.json | 2 +- packages/examples/stdio-demo/src/bin.ts | 2 +- packages/examples/stdio-demo/src/index.ts | 2 +- packages/ui/tui/src/index.ts | 2 +- scripts/demo-code-mode.mjs | 2 +- scripts/gen-doc-graphs.ts | 20 ++--- scripts/gen-tool-catalog.ts | 2 +- website/zh-CN/guide/config.md | 4 +- website/zh-CN/guide/quickstart.md | 4 +- 51 files changed, 167 insertions(+), 167 deletions(-) delete mode 100644 examples/coding-agent/composition.md rename examples/{coding-agent => repl-agent}/README.md (95%) rename examples/{coding-agent => repl-agent}/code-mode.cordis.yml (93%) create mode 100644 examples/repl-agent/composition.md rename examples/{coding-agent => repl-agent}/cordis.yml (98%) rename examples/{coding-agent => repl-agent}/package.json (81%) rename examples/{coding-agent => repl-agent}/tests/code-mode-keyless-smoke.e2e.ts (100%) rename examples/{coding-agent => repl-agent}/tests/code-mode.e2e.ts (98%) rename examples/{coding-agent => repl-agent}/tests/coding-task.e2e.ts (100%) rename examples/{coding-agent => repl-agent}/tests/compaction.e2e.ts (100%) rename examples/{coding-agent => repl-agent}/tests/full-loop.e2e.ts (100%) rename examples/{coding-agent => repl-agent}/tests/harness.ts (98%) rename examples/{coding-agent => repl-agent}/tests/keyless-smoke.e2e.ts (81%) rename examples/{coding-agent => repl-agent}/tests/resume.e2e.ts (100%) rename examples/{coding-agent => repl-agent}/tests/todo-write.e2e.ts (100%) diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 241ab178a4..3829d4eca7 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -extension-cookbook.md: 1a1f0b200801a54f067faf9866b72ccadfdc62d3 -extension-cookbook.zh.md: 891a808b4ed87b1fd92d0b68c580895956f9ab8c +extension-cookbook.md: d271ceee208e276d97188a6ebbe91e1e90a4219f +extension-cookbook.zh.md: bdd2c5f0861aa55c6f0104764a34784e5e834d10 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 1a1f0b2008..d271ceee20 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Five runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool, `pnpm run demo:echo`), [`examples/coding-agent`](../../examples/coding-agent) (DeepSeek V4 + coding tools through a line-oriented readline REPL, `pnpm run demo:repl`), [`examples/tui-agent`](../../examples/tui-agent) (the same coding composition through full-screen pi-tui, `pnpm run demo:tui`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). The terminal leaves load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine through [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Five runnable leaves load their plugin trees from `cordis.yml`: [`examples/echo-agent`](../../examples/echo-agent) (mock model + echo tool, `pnpm run demo:echo`), [`examples/repl-agent`](../../examples/repl-agent) (DeepSeek V4 + coding tools through a line-oriented readline REPL, `pnpm run demo:repl`), [`examples/tui-agent`](../../examples/tui-agent) (the same coding composition through full-screen pi-tui, `pnpm run demo:tui`), [`examples/cordis-agent`](../../examples/cordis-agent) (self-inspection and dynamic plugin mounting, `pnpm run demo:cordis`), and [`examples/acp-agent`](../../examples/acp-agent) (an ACP server over JSON-RPC stdio, `pnpm run demo:acp`). The terminal leaves load [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo), the ACP leaf loads [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and both app packages share the spine through [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 891a808b4e..bdd2c5f086 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -87,7 +87,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -五个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具,`pnpm run demo:echo`)、[`examples/coding-agent`](../../examples/coding-agent)(DeepSeek V4 + coding 工具,通过面向行的 readline REPL 交互,`pnpm run demo:repl`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 pi-tui 复用相同的 coding 组装,`pnpm run demo:tui`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。终端叶子加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 +五个可运行叶子从 `cordis.yml` 加载各自的插件树:[`examples/echo-agent`](../../examples/echo-agent)(mock 模型 + echo 工具,`pnpm run demo:echo`)、[`examples/repl-agent`](../../examples/repl-agent)(DeepSeek V4 + coding 工具,通过面向行的 readline REPL 交互,`pnpm run demo:repl`)、[`examples/tui-agent`](../../examples/tui-agent)(通过全屏 pi-tui 复用相同的 coding 组装,`pnpm run demo:tui`)、[`examples/cordis-agent`](../../examples/cordis-agent)(自我检查和动态插件挂载,`pnpm run demo:cordis`)与 [`examples/acp-agent`](../../examples/acp-agent)(通过 JSON-RPC stdio 暴露的 ACP 服务器,`pnpm run demo:acp`)。终端叶子加载 [`@deepseek-ai/dsh-stdio-demo`](../../packages/examples/stdio-demo),ACP 叶子加载 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),两个 app 包通过 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 共享主干。 ## 功能→机制映射 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index e5b3feba33..692113eb2a 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -development.md: 37811f7215001fc371ac4943fe109dd5512ea8b0 -development.zh.md: 5036e3e75516fcaf063675fc9ab4e63c1fca851a +development.md: 452f4e82beaeacb23aa6bc3e7a60c0f2b1e2c95e +development.zh.md: 2285482726c43aa02d31c7d2094e2058de1d3863 diff --git a/docs/development.md b/docs/development.md index 37811f7215..452f4e82be 100644 --- a/docs/development.md +++ b/docs/development.md @@ -109,13 +109,13 @@ The echo demo does not need API credentials: pnpm run demo:echo ``` -The coding-agent REPL uses the line-oriented readline front door and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: +The repl-agent demo uses the line-oriented readline front door and needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh pnpm run demo:repl ``` -The full-screen TUI reuses the coding-agent composition through the pi-tui front door and needs the same credentials: +The full-screen TUI reuses the repl-agent composition through the pi-tui front door and needs the same credentials: ```sh pnpm run demo:tui diff --git a/docs/development.zh.md b/docs/development.zh.md index 5036e3e755..2285482726 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -109,13 +109,13 @@ echo 演示不需要 API 凭证: pnpm run demo:echo ``` -coding-agent REPL 使用面向行的 readline 前端,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: +repl-agent 示例使用面向行的 readline 前端,并需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh pnpm run demo:repl ``` -全屏 TUI 通过 pi-tui 前端复用 coding-agent 组装,并需要相同的凭证: +全屏 TUI 通过 pi-tui 前端复用 repl-agent 组装,并需要相同的凭证: ```sh pnpm run demo:tui diff --git a/docs/graph-atlas.md b/docs/graph-atlas.md index 5220cfa81f..987d8ad7a5 100644 --- a/docs/graph-atlas.md +++ b/docs/graph-atlas.md @@ -13,7 +13,7 @@ The process decision behind this index is recorded in [the documentation graph R | [tool schema catalog and package map](tool-catalog.md) | `generated` | | [capability seams and core services](capability-seams.md) | `hybrid generated` | | [echo-agent app composition](../examples/echo-agent/composition.md) | `hybrid generated` | -| [coding-agent app composition](../examples/coding-agent/composition.md) | `hybrid generated` | +| [repl-agent app composition](../examples/repl-agent/composition.md) | `hybrid generated` | | [tui-agent app composition](../examples/tui-agent/composition.md) | `hybrid generated` | | [cordis-agent app composition](../examples/cordis-agent/composition.md) | `hybrid generated` | | [acp-agent app composition](../examples/acp-agent/composition.md) | `hybrid generated` | diff --git a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 854807c109..c154b2080b 100644 --- a/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/docs/rfc/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -8,9 +8,9 @@ The assembled system prompt had four defects, all of one family: facts the harne **The model could not know its own name.** `AgentOptions.model` drives every request, but no prompt text carried it — and nothing COULD carry it: sections in `dsh-system-prompt` were context-global while the model name is per-agent, and `assemble()` took no per-agent input at all. -**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. +**Tool guidance was hand-written prose in leaf YAML.** The bash/subagent/todo_write usage guidance lived in the `systemPrompt` strings of `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml` — two drifting copies (the ACP one was already abridged) — while `dsh-tool-fs` and `dsh-tool-web` owned their guidance as `ctx.systemPrompt.section()` contributions. Loading or dropping a tool plugin meant editing every deployment's persona by hand; both YAMLs carried a `FIXME(config-comments)` apologizing for a symptom of the split, and the stdio welcome banner hand-enumerated the tool set too. -**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are coding-agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. +**The persona rendered after tool guidance.** The loop string-joined `agent.options.systemPrompt` AFTER the assembled sections, so the model read "Use the read tool…" before "You are a coding agent" — backwards relative to the identity-first convention (Claude Code, Codex) and a second composition path besides the section pipeline. **The fork tool's description was false.** `dsh-tool-subagent` hardcoded one description written for spawn semantics — "a separate agent that works in its own context … it does not see this conversation" — and the `subagent_fork` instance (whose child inherits the parent's completed turns) got the same words; the YAML prose corrected the lie out-of-band. Minor kin: `PromptSection.name` was documented "(diagnostics / dedup)" but duplicates were silently accepted. @@ -56,7 +56,7 @@ Per-tool semantics and selection guidance live in tool descriptions. Prompt sect ## Shipped invariants -- The coding-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. +- The repl-agent prompt renders identity, persona with the interpolated model, then fs/bash/web guidance through one assembly path. - Fork and fresh subagent descriptions reflect whether the provider inherits completed conversation turns; the tool appears, disappears, and is reworded with provider lifecycle changes. - Unknown, valueless, malformed, or unbalanced variable references name the section and throw; duplicate section, variable, and tool registrations also throw. - Snapshot replay is prompt-independent: it keys recorded chunk streams by turn and step without comparing the outgoing request. diff --git a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md index 3a60c5c223..bbca065d87 100644 --- a/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/docs/rfc/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -162,7 +162,7 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - `dsh-spill-local` unit tests cover `saveText`, `encodeSegment` sanitization (separators/tilde/whole-segment dots/empty), the session-hash directory, owner-only permissions, distinct paths per save, the configured/private root, and a storage-failure rejection. - `dsh-spill-policy` unit tests drive real tools through `ctx.tools.execute`: disabled-mode no-op, oversized-text replacement, small/non-text passthrough, `read` skip, best-effort fallback (save failure / no backend / no owner), and downstream-composition (bounding a replaced result, preserving `additionalContexts`). - `dsh-tool-web` integration drives `web_fetch` through `ctx.tools.execute` with the real `spill-local` backend + policy, proving the model-facing text changes only by the deliberate spill notice while the spill file holds the full formatted result. -- The `coding-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). +- The `repl-agent` example loads `spill-local` + `spill-policy`, so its keyless Loader smoke exercises the real load path (the namespace-plugin export shape + `inject`). ## Consequences diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 1986ad7624..f32355326f 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -115,7 +115,7 @@ Two failure paths, both documented: - **`SessionEventMap`** gains `compact/start` / `compact/summary` / `compact/end` by declaration merging (merge-extensible); `SurfaceEventType` is **not** touched. These are session events, not cordis `Events`, so the event-taxonomy gate needs no entry. - **`dsh-compact`** owns `toolPairingBalancedBefore(session, seq)` and `toolPairingBalancedAfter(session, seq)`, the cached surface-edge checks that `compactRegion` and `compactIfNeeded` use to avoid splitting a tool-call/result pair. The cache validates current membership by seq and answers both edges from one per-cut balance sequence; stale or missing seqs and orphan results reject. `dsh-session` continues to own the surface `replace` operation, ordered event sequences, and rewrite generation. - **`dsh-invariants`** drops its `surface replace: start must be <= end` assertion: a head-anchored compaction lands a high-seq replacement entry at an older range's *position*, so `start > end` numerically is normal and valid (the range is positional, validated by the surface's `indexOf` checks that remain). The turn-enclosure invariant is reused unchanged. -- **Wiring**: `examples/coding-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. +- **Wiring**: `examples/repl-agent/cordis.yml` loads zero-config `dsh-token-meter` before `dsh-compact-basic`; the service-wide window and compact defaults make the pair usable without repeated numeric policy. ## Testing diff --git a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md index bc8a452c91..35f65c38f2 100644 --- a/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md +++ b/docs/rfc/implemented/feature/2026-07-09-bash-backed-grep-glob-discovery.md @@ -153,7 +153,7 @@ If the complete logical result fits under the inline cap, no formatted spill art - The tools execute through `ctx.bash.resolve(request)` → `ctx.bash.run(spec)`, forward `exec.signal`, never call `ctx.bash.start()`, and never expose a bash task id. The bash request workdir comes from `exec.agent?.session.header.cwd` when available; the resolved `spec.workdir` drives execution and relative-path display. - The tools request `stdoutMaxBytes: rawOutputMaxBytes` from the bash seam, parse only untruncated stdout within that cap, and treat over-cap or still-truncated raw output as a clear search failure; raw `rg` output is never exposed to the model. - Oversized complete formatted results are saved through `ctx.spillStore.saveText()` when available while inline results stay bounded; spill failure, a missing backend, or a missing owner preserves the inline result and reports the unsaved remainder — never an `isError`. -- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the coding-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement. +- The package README, the generated config catalog, and exported JSDoc document the Config fields and `SEARCH_*` codes; the repl-agent example ships the tools (the acp-agent tree waits on the snapshot re-record above); the fs group README records the co-located bash/filesystem deployment requirement. ## Risks diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index d57bf423f5..8618fd884f 100644 --- a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-17-dedicated-full-screen-tui-front-door.md: 5e66b85fa23ef394b88fd16880cf218ac5cc2202 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 4ccfde2d633115a41b24227ee78f7e4010bd70ee +2026-07-17-dedicated-full-screen-tui-front-door.md: c834594b3af1e1f348aec2cf67324d5123a1ed2d +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 8cb8eb6d2812e6ecf9d98d20c64da24c2943363c diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index 5e66b85fa2..c834594b3a 100644 --- a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -The app layer selects a concrete terminal front door before mounting it. `@deepseek-ai/dsh-stdio-demo` can resolve `auto` from the two process streams, while the `coding-agent` and `tui-agent` leaves explicitly select readline and TUI respectively. The TUI leaf reuses the coding agent's backend and tool composition through an asserted include patch, so the three runnable agent leaves remain symmetric without duplicating deployment choices. +The app layer selects a concrete terminal front door before mounting it. `@deepseek-ai/dsh-stdio-demo` can resolve `auto` from the two process streams, while the `repl-agent` and `tui-agent` leaves explicitly select readline and TUI respectively. The TUI leaf reuses the repl-agent backend and tool composition through an asserted include patch, so the three runnable agent leaves remain symmetric without duplicating deployment choices. The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. @@ -38,7 +38,7 @@ The implemented [TUI terminal-state snapshot RFC](../testing/2026-07-18-tui-term - **Keep readline and full-screen modes inside `@deepseek-ai/dsh-stdio`** — rejected because line-oriented output and differential TTY rendering have different dependencies, input rules, logging ownership, and teardown obligations. Separate packages keep the pipe-safe contract small and explicit. - **Let the TUI plugin silently downgrade when either stream is not a TTY** — rejected because a fallback hides deployment mistakes and changes interaction semantics. The app bundle may select a front door with `auto`; an explicitly mounted TUI fails loud. -- **Keep TUI wiring and tests under the readline `coding-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the coding agent's backend composition. +- **Keep TUI wiring and tests under the readline `repl-agent` leaf** — rejected because one leaf would represent two distinct front doors and break symmetry with `acp-agent`. A dedicated `tui-agent` leaf owns TUI overlays and tests while reusing the repl-agent backend composition. ## Consequences diff --git a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 4ccfde2d63..8cb8eb6d28 100644 --- a/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/docs/rfc/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -14,7 +14,7 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -应用组合层在挂载前选择具体的终端入口。`@deepseek-ai/dsh-stdio-demo` 可以根据两个进程流通过 `auto` 作出选择,`coding-agent` 和 `tui-agent` 叶节点则分别明确选择 readline 与 TUI。TUI 叶节点通过带断言的 include patch 复用 coding agent 的后端和工具组合,使三个可运行的 agent 叶节点保持对称,同时避免重复部署选项。 +应用组合层在挂载前选择具体的终端入口。`@deepseek-ai/dsh-stdio-demo` 可以根据两个进程流通过 `auto` 作出选择,`repl-agent` 和 `tui-agent` 叶节点则分别明确选择 readline 与 TUI。TUI 叶节点通过带断言的 include patch 复用 repl-agent 的后端和工具组合,使三个可运行的 agent 叶节点保持对称,同时避免重复部署选项。 所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 @@ -38,7 +38,7 @@ agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调 - **把 readline 与全屏模式都保留在 `@deepseek-ai/dsh-stdio` 中**:不予采纳,因为逐行输出和差分 TTY 渲染具有不同的依赖、输入规则、日志所有权和资源清理义务。拆分为独立包可以让管道安全契约保持精简、明确。 - **当任一进程流不是 TTY 时,让 TUI 插件静默降级**:不予采纳,因为回退会掩盖部署错误并改变交互语义。应用包可以通过 `auto` 选择入口;明确挂载的 TUI 会快速失败。 -- **把 TUI 接线与测试保留在 readline `coding-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 coding agent 的后端组合。 +- **把 TUI 接线与测试保留在 readline `repl-agent` 叶节点下**:不予采纳,因为一个叶节点会代表两个不同入口,也会破坏它与 `acp-agent` 的对称性。独立的 `tui-agent` 叶节点负责 TUI 浮层和测试,同时复用 repl-agent 的后端组合。 ## 后果 diff --git a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md index a17973ea2e..4e3148ead6 100644 --- a/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md +++ b/docs/rfc/implemented/process/2026-07-03-documentation-graph-atlas.md @@ -34,7 +34,7 @@ The first index links ten relationship surfaces. Package topology and tool-packa | [tool schema catalog and package map](../../../tool-catalog.md) | generated | boot-harvested tool schemas plus tool-package service/effect metadata | | [capability seams and core services](../../../capability-seams.md) | hybrid generated | Cordis service declarations plus a role manifest in `gen-doc-graphs.ts` | | [echo-agent app composition](../../../../examples/echo-agent/composition.md) | hybrid generated | `examples/echo-agent/cordis.yml` plugin list plus curated app/bundle expansion | -| [coding-agent app composition](../../../../examples/coding-agent/composition.md) | hybrid generated | `examples/coding-agent/cordis.yml` plugin list plus curated app/bundle expansion | +| [repl-agent app composition](../../../../examples/repl-agent/composition.md) | hybrid generated | `examples/repl-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [acp-agent app composition](../../../../examples/acp-agent/composition.md) | hybrid generated | `examples/acp-agent/cordis.yml` plugin list plus curated app/bundle expansion | | [event producer/consumer matrix](../../../event-producer-consumer.md) | hybrid generated | Cordis event declarations, AST-scanned `ctx.on/emit/parallel/serial/waterfall` sites, and explicit dynamic dispatch overrides | | [agent turn and step lifecycle](../../../agent-lifecycle.md) | curated | architecture.md loop lifecycle, Cordis catalog links, and session event semantics | diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index d3775754b9..43f35ee770 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). +The helper lives in `@deepseek-ai/dsh-stdio` as the terminal-channel plugin (`packages/ui/stdio/src/index.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio/tests/stdio.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/repl-agent` keep proving the composed tree boots through the real Loader (the stdio package's plugin-shape unit suite pins the explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index b68f7e7e78..f111c337dc 100644 --- a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-18-tui-terminal-state-snapshots.md: 280c2b4faec3bd5e14a24a5df7bc901ad31718cd -2026-07-18-tui-terminal-state-snapshots.zh.md: d1c609c6ce511bba1f498b72444af662b4444578 +2026-07-18-tui-terminal-state-snapshots.md: a1363a521372c11dab8b239cad87df5ff5ca8f22 +2026-07-18-tui-terminal-state-snapshots.zh.md: aa365ba1f2ba17e5bc5409dbdc3736cbc29fe26a diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 280c2b4fae..a1363a5213 100644 --- a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the readline `coding-agent` and `acp-agent` leaves. It reuses the coding agent's backend and tool composition through an asserted include patch while fixing the shared terminal app to `ui.mode: tui`; TUI snapshots and PTY tests live with that leaf. +The runnable TUI has its own `examples/tui-agent` leaf beside the readline `repl-agent` and `acp-agent` leaves. It reuses the repl-agent backend and tool composition through an asserted include patch while fixing the shared terminal app to `ui.mode: tui`; TUI snapshots and PTY tests live with that leaf. ### Recorded-session replay diff --git a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index d1c609c6ce..aa365ba1f2 100644 --- a/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/docs/rfc/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 readline `coding-agent` 和 `acp-agent` 叶节点并列。它通过带断言的 include patch 复用 coding agent 的后端与工具组合,只把共享终端应用固定为 `ui.mode: tui`;TUI 快照和 PTY 测试也归属这个叶节点。 +可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 readline `repl-agent` 和 `acp-agent` 叶节点并列。它通过带断言的 include patch 复用 repl-agent 的后端与工具组合,只把共享终端应用固定为 `ui.mode: tui`;TUI 快照和 PTY 测试也归属这个叶节点。 ### 已录制会话回放 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index f6a2e0625f..3dacb56902 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -22,7 +22,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are bash-backed discovery tools: they run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `context/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: a background bash command and a background subagent are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist or ACP plan. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -444,7 +444,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/examples/README.md b/examples/README.md index 03b798e164..17aa65bb8e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -9,21 +9,21 @@ A mock model + echo tool on the stdio chat app — the all-mock skeleton. The le - A thin leaf `cordis.yml` loading the `@deepseek-ai/dsh-stdio-demo` app - Registering a mock `LlmAdapter` (streaming scripted responses) - Registering a tool via `ctx.tools.register()` -- "Swap the backend, keep the app" — the only difference from `coding-agent` is the adapter +- "Swap the backend, keep the app" — the only difference from `repl-agent` is the adapter Run with: `pnpm run demo:echo`. When prompted, type "echo " to trigger a tool call round-trip. -## coding-agent +## repl-agent -A coding-agent REPL: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door. +A coding agent with DeepSeek V4, the `read`/`write`/`edit` filesystem tools, the bash tool suite, `subagent` delegation, and the `todo_write` task tracker on the `@deepseek-ai/dsh-stdio-demo` app's readline front door. -Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [coding-agent/README.md](coding-agent/README.md) for details. +Run with: `pnpm run demo:repl` (needs `DEEPSEEK_API_KEY` in the environment or a gitignored repo-root `.env`). See [repl-agent/README.md](repl-agent/README.md) for details. -Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](coding-agent/README.md#code-mode) for its composition and a sample task. +Run the Code Mode overlay with `pnpm run demo:code-mode`, or pass `acp` for the ACP example. See the [Code Mode example](repl-agent/README.md#code-mode) for its composition and a sample task. ## tui-agent -The full-screen terminal sibling of `coding-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios. +The full-screen terminal sibling of `repl-agent`: it reuses the same coding backends and tools while forcing the shared terminal app to `dsh-tui`. It is the home of TUI PTY and snapshot scenarios. Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). See [tui-agent/README.md](tui-agent/README.md) for controls and composition. diff --git a/examples/coding-agent/composition.md b/examples/coding-agent/composition.md deleted file mode 100644 index 2956291dc8..0000000000 --- a/examples/coding-agent/composition.md +++ /dev/null @@ -1,88 +0,0 @@ - - -# Coding Agent App Composition - -The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. - -```mermaid -flowchart LR - cfg["examples/coding-agent
cordis.yml"] - plugin_coding_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_coding_hmr - plugin_coding_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_coding_llm_deepseek - plugin_coding_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_coding_bash - plugin_coding_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] - cfg --> plugin_coding_stdio_agent - plugin_coding_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_coding_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_coding_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_coding_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_coding_token_meter - plugin_coding_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] - cfg --> plugin_coding_compact_basic - plugin_coding_subagent["subagent
@deepseek-ai/dsh-subagent"] - cfg --> plugin_coding_subagent - plugin_coding_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] - cfg --> plugin_coding_subagent_spawn - plugin_coding_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] - cfg --> plugin_coding_subagent_fork - plugin_coding_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_coding_tool_subagent - plugin_coding_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_coding_tool_subagent_fork - plugin_coding_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] - cfg --> plugin_coding_workflow_workerthread - plugin_coding_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] - cfg --> plugin_coding_tool_workflow - plugin_coding_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_coding_tool_todo - plugin_coding_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_coding_fs_local - plugin_coding_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_coding_fs_policy - plugin_coding_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_coding_tool_fs - plugin_coding_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_coding_tool_fs_search - plugin_coding_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] - cfg --> plugin_coding_timeout_policy - plugin_coding_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] - cfg --> plugin_coding_spill_local - plugin_coding_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] - cfg --> plugin_coding_spill_policy -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | -| `subagent` | `@deepseek-ai/dsh-subagent` | -| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | -| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | -| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | -| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | -| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | -| `spill-local` | `@deepseek-ai/dsh-spill-local` | -| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | - -Source config: [`examples/coding-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md index 1ddf8b1d3b..5900d6a0f0 100644 --- a/examples/cordis-agent/README.md +++ b/examples/cordis-agent/README.md @@ -1,6 +1,6 @@ # cordis-agent -The self-referential harness demo: the coding-agent spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). +The self-referential harness demo: the coding spine (DeepSeek V4 + local bash on the stdio chat app) plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which hands the model three tools over the **live cordis runtime it is running inside** — inspect it, mount new plugins into it, and dispose them again. The `ctx.fs` and `ctx.web` services are mounted (provider-only, no model-facing file/web tools) so the plugins the agent writes have real capabilities to build on; Node built-ins are trapped in the sandbox and redirect to those services. The design (sandbox semantics, mount lifecycle, cross-mount composition, caveats) lives in [the toolset RFC](../../docs/rfc/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). ## Run it diff --git a/examples/echo-agent/README.md b/examples/echo-agent/README.md index 183268f5da..f397cc633f 100644 --- a/examples/echo-agent/README.md +++ b/examples/echo-agent/README.md @@ -9,7 +9,7 @@ This example is just a leaf `cordis.yml`: it loads the [`@deepseek-ai/dsh-stdio- - `mock-llm.ts` — a mock `LlmAdapter` that streams scripted responses and calls the `echo` tool when the user types "echo ". Registered with `ctx.llm.registerAdapter(['mock-echo'], …)`. - `echo-tool.ts` — a tool registered via `ctx.tools.register(defineTool(…))` with typed `execute` args; echoes text back uppercased. -Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `coding-agent` — the same app, a different backend. +Swapping `mock-llm` for the real `llm-deepseek` adapter is all that separates this from `repl-agent` — the same app, a different backend. ## Plugin files diff --git a/examples/coding-agent/README.md b/examples/repl-agent/README.md similarity index 95% rename from examples/coding-agent/README.md rename to examples/repl-agent/README.md index 40e8b7087f..2e559d129a 100644 --- a/examples/coding-agent/README.md +++ b/examples/repl-agent/README.md @@ -1,6 +1,6 @@ -# coding-agent +# repl-agent -The coding-agent REPL wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. +The repl-agent wiring: DeepSeek V4 + the `read`/`write`/`edit` filesystem tools + the bash tool suite + subagent delegation + `todo_write` + readline chat + JSONL persistence, loaded from `cordis.yml`. The sibling [`tui-agent`](../tui-agent/README.md) fixes the same agent composition to the full-screen terminal front door. ## Run it diff --git a/examples/coding-agent/code-mode.cordis.yml b/examples/repl-agent/code-mode.cordis.yml similarity index 93% rename from examples/coding-agent/code-mode.cordis.yml rename to examples/repl-agent/code-mode.cordis.yml index b1f62829bc..8802b510ba 100644 --- a/examples/coding-agent/code-mode.cordis.yml +++ b/examples/repl-agent/code-mode.cordis.yml @@ -23,7 +23,7 @@ ui: mode: readline persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. You work by writing TypeScript programs for run_code: batch related tool work into one program, loop and branch where it helps, and print diff --git a/examples/repl-agent/composition.md b/examples/repl-agent/composition.md new file mode 100644 index 0000000000..af3f810585 --- /dev/null +++ b/examples/repl-agent/composition.md @@ -0,0 +1,88 @@ + + +# REPL Agent App Composition + +The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package. + +```mermaid +flowchart LR + cfg["examples/repl-agent
cordis.yml"] + plugin_repl_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_repl_hmr + plugin_repl_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_repl_llm_deepseek + plugin_repl_bash["bash
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_repl_bash + plugin_repl_stdio_agent["stdio-agent
@deepseek-ai/dsh-stdio-demo"] + cfg --> plugin_repl_stdio_agent + plugin_repl_stdio_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] + plugin_repl_stdio_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] + plugin_repl_stdio_agent --> frontdoor_stdio["@deepseek-ai/dsh-stdio
pre-created main agent"] + bundle_agent_core --> spine_llm["ctx.llm"] + bundle_agent_core --> spine_sessions["ctx.sessions"] + bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] + bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + plugin_repl_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] + cfg --> plugin_repl_token_meter + plugin_repl_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] + cfg --> plugin_repl_compact_basic + plugin_repl_subagent["subagent
@deepseek-ai/dsh-subagent"] + cfg --> plugin_repl_subagent + plugin_repl_subagent_spawn["subagent-spawn
@deepseek-ai/dsh-subagent-spawn"] + cfg --> plugin_repl_subagent_spawn + plugin_repl_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] + cfg --> plugin_repl_subagent_fork + plugin_repl_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_repl_tool_subagent + plugin_repl_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] + cfg --> plugin_repl_tool_subagent_fork + plugin_repl_workflow_workerthread["workflow-workerthread
@deepseek-ai/dsh-workflow-workerthread"] + cfg --> plugin_repl_workflow_workerthread + plugin_repl_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] + cfg --> plugin_repl_tool_workflow + plugin_repl_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] + cfg --> plugin_repl_tool_todo + plugin_repl_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_repl_fs_local + plugin_repl_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_repl_fs_policy + plugin_repl_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_repl_tool_fs + plugin_repl_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_repl_tool_fs_search + plugin_repl_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] + cfg --> plugin_repl_timeout_policy + plugin_repl_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] + cfg --> plugin_repl_spill_local + plugin_repl_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] + cfg --> plugin_repl_spill_policy +``` + +| Plugin id | Package / module | +| --- | --- | +| `hmr` | `@cordisjs/plugin-hmr` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `bash` | `@deepseek-ai/dsh-bash-local` | +| `stdio-agent` | `@deepseek-ai/dsh-stdio-demo` | +| `token-meter` | `@deepseek-ai/dsh-token-meter` | +| `compact-basic` | `@deepseek-ai/dsh-compact-basic` | +| `subagent` | `@deepseek-ai/dsh-subagent` | +| `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | +| `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | +| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | +| `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | +| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | +| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | +| `spill-local` | `@deepseek-ai/dsh-spill-local` | +| `spill-policy` | `@deepseek-ai/dsh-spill-policy` | + +Source config: [`examples/repl-agent/cordis.yml`](cordis.yml). + +Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/coding-agent/cordis.yml b/examples/repl-agent/cordis.yml similarity index 98% rename from examples/coding-agent/cordis.yml rename to examples/repl-agent/cordis.yml index 497e2a80c8..00a6b4b9a6 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/repl-agent/cordis.yml @@ -42,7 +42,7 @@ # Keep the persona to identity and behavior; tool plugins own tool guidance. # The loop resolves {{model}} from this agent's configuration. persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/examples/coding-agent/package.json b/examples/repl-agent/package.json similarity index 81% rename from examples/coding-agent/package.json rename to examples/repl-agent/package.json index b3594ff597..34c7db6918 100644 --- a/examples/coding-agent/package.json +++ b/examples/repl-agent/package.json @@ -1,5 +1,5 @@ { - "name": "coding-agent-example", + "name": "repl-agent-example", "private": true, "version": "0.0.1", "type": "module", diff --git a/examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts b/examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts similarity index 100% rename from examples/coding-agent/tests/code-mode-keyless-smoke.e2e.ts rename to examples/repl-agent/tests/code-mode-keyless-smoke.e2e.ts diff --git a/examples/coding-agent/tests/code-mode.e2e.ts b/examples/repl-agent/tests/code-mode.e2e.ts similarity index 98% rename from examples/coding-agent/tests/code-mode.e2e.ts rename to examples/repl-agent/tests/code-mode.e2e.ts index aab4da376f..c7f5d48562 100644 --- a/examples/coding-agent/tests/code-mode.e2e.ts +++ b/examples/repl-agent/tests/code-mode.e2e.ts @@ -25,7 +25,7 @@ import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context' * each `tool/code-dispatch`. The keyless Loader smoke is in the sibling test. */ -const PERSONA = 'You are coding-agent. You work by writing TypeScript programs for run_code: ' +const PERSONA = 'You are a coding agent. You work by writing TypeScript programs for run_code: ' + 'batch related tool work into one program and print or return ONLY the findings that matter.' const WORKSPACE_PROBE = 'dragonfruit-8675309' diff --git a/examples/coding-agent/tests/coding-task.e2e.ts b/examples/repl-agent/tests/coding-task.e2e.ts similarity index 100% rename from examples/coding-agent/tests/coding-task.e2e.ts rename to examples/repl-agent/tests/coding-task.e2e.ts diff --git a/examples/coding-agent/tests/compaction.e2e.ts b/examples/repl-agent/tests/compaction.e2e.ts similarity index 100% rename from examples/coding-agent/tests/compaction.e2e.ts rename to examples/repl-agent/tests/compaction.e2e.ts diff --git a/examples/coding-agent/tests/full-loop.e2e.ts b/examples/repl-agent/tests/full-loop.e2e.ts similarity index 100% rename from examples/coding-agent/tests/full-loop.e2e.ts rename to examples/repl-agent/tests/full-loop.e2e.ts diff --git a/examples/coding-agent/tests/harness.ts b/examples/repl-agent/tests/harness.ts similarity index 98% rename from examples/coding-agent/tests/harness.ts rename to examples/repl-agent/tests/harness.ts index 290ffcdf0b..eeba57fc61 100644 --- a/examples/coding-agent/tests/harness.ts +++ b/examples/repl-agent/tests/harness.ts @@ -14,7 +14,7 @@ import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' /** - * Shared harness for the coding-agent e2e suites: the full plugin stack + * Shared harness for the repl-agent e2e suites: the full plugin stack * with the real DeepSeek adapter and the real bash + todo_write tools. Lives * outside the *.e2e.ts pattern so importing it never re-registers another * file's tests. diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/repl-agent/tests/keyless-smoke.e2e.ts similarity index 81% rename from examples/coding-agent/tests/keyless-smoke.e2e.ts rename to examples/repl-agent/tests/keyless-smoke.e2e.ts index 831d0daf5c..62eb43f55a 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/repl-agent/tests/keyless-smoke.e2e.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' /** - * Keyless Loader-path smoke for examples/coding-agent: boot the real example + * Keyless Loader-path smoke for examples/repl-agent: boot the real example * through the stdio-agent bin and its `cordis.yml`, then close stdin without a * prompt and assert the banner. The dummy key satisfies adapter construction; * immediate EOF guarantees there is no model call. @@ -13,11 +13,11 @@ const binScript = fileURLToPath(new URL('../../../packages/examples/stdio-demo/s const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -describe('coding-agent keyless smoke (real cordis.yml via the Loader)', () => { +describe('repl-agent keyless smoke (real cordis.yml via the Loader)', () => { it('boots the full plugin tree, prints its banner, and exits cleanly on EOF', async () => { const { stdout } = await runLoaderSmoke({ - label: 'coding-agent', - tempDirPrefix: 'coding-smoke-', + label: 'repl-agent', + tempDirPrefix: 'repl-smoke-', binScript, configPath, tsconfigPath, diff --git a/examples/coding-agent/tests/resume.e2e.ts b/examples/repl-agent/tests/resume.e2e.ts similarity index 100% rename from examples/coding-agent/tests/resume.e2e.ts rename to examples/repl-agent/tests/resume.e2e.ts diff --git a/examples/coding-agent/tests/todo-write.e2e.ts b/examples/repl-agent/tests/todo-write.e2e.ts similarity index 100% rename from examples/coding-agent/tests/todo-write.e2e.ts rename to examples/repl-agent/tests/todo-write.e2e.ts diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index ee5b610d53..b4074881fb 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -1,6 +1,6 @@ # tui-agent -The full-screen terminal counterpart to the [`coding-agent`](../coding-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door. +The full-screen terminal counterpart to the [`repl-agent`](../repl-agent/README.md) readline REPL and [`acp-agent`](../acp-agent/README.md) server. It reuses the coding agent's backends and tool composition, then fixes the shared terminal app to the `dsh-tui` front door. ## Run it @@ -16,7 +16,7 @@ Run `pnpm run demo:code-mode tui` for the sibling Code Mode overlay. ## Composition -[`cordis.yml`](cordis.yml) includes the readline coding-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the coding agent's Code Mode overlay. +[`cordis.yml`](cordis.yml) includes the readline repl-agent leaf so the LLM, bash, filesystem, compaction, subagent, workflow, todo, timeout, and spill choices have one owner. Its asserted patch replaces only the terminal app config and forces `ui.mode: tui`; [`code-mode.cordis.yml`](code-mode.cordis.yml) applies the same front-door patch to the repl-agent Code Mode overlay. ## Snapshot tests diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 7bb7932ba0..75d2cea38a 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -1,9 +1,9 @@ -# Code Mode keeps the TUI front door while reusing the coding-agent overlay's +# Code Mode keeps the TUI front door while reusing the repl-agent overlay's # worker runtime and one-tool registry composition. - id: base name: '@cordisjs/plugin-include' config: - path: ../coding-agent/code-mode.cordis.yml + path: ../repl-agent/code-mode.cordis.yml patches: - id: stdio-agent name: '@deepseek-ai/dsh-stdio-demo' @@ -23,7 +23,7 @@ showReasoning: true maxToolOutputLines: 12 persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. You work by writing TypeScript programs for run_code: batch related tool work into one program, loop and branch where it helps, and print diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 511f76d41e..94515c32c4 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -3,7 +3,7 @@ # TUI Agent App Composition -The TUI agent reuses the coding-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door. +The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door. ```mermaid flowchart LR diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 0f329c38e2..515274b2a8 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -1,10 +1,10 @@ -# Full-screen TUI front door over the same coding-agent composition used by the +# Full-screen TUI front door over the same repl-agent composition used by the # readline REPL. The include keeps backends and optional tools aligned; the # patch owns only the terminal-specific app config. - id: base name: '@cordisjs/plugin-include' config: - path: ../coding-agent/cordis.yml + path: ../repl-agent/cordis.yml patches: - id: stdio-agent name: '@deepseek-ai/dsh-stdio-demo' @@ -22,7 +22,7 @@ showReasoning: true maxToolOutputLines: 12 persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. diff --git a/package.json b/package.json index ca85fd32d2..19f6d15005 100644 --- a/package.json +++ b/package.json @@ -79,7 +79,7 @@ "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-events && pnpm run verify-website-api && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations && pnpm run verify-website-yaml", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "demo:echo": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/echo-agent/cordis.yml", - "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/coding-agent/cordis.yml", + "demo:repl": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/repl-agent/cordis.yml", "demo:tui": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/tui-agent/cordis.yml", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node --expose-internals --import tsx packages/examples/stdio-demo/src/bin.ts examples/cordis-agent/cordis.yml", diff --git a/packages/examples/stdio-demo/src/bin.ts b/packages/examples/stdio-demo/src/bin.ts index 278bb5faca..3d8a0c2a33 100644 --- a/packages/examples/stdio-demo/src/bin.ts +++ b/packages/examples/stdio-demo/src/bin.ts @@ -2,7 +2,7 @@ /** * Boot a stdio app from a leaf `cordis.yml`; usage is `dsh-stdio-demo [config]`, defaulting to the * cwd file. Shared `.env` loading, fail-loud Loader guards, and settled-tree boot live in - * dsh-app-boot. The echo and coding-agent demos invoke this bin with their own leaf configs. + * dsh-app-boot. The echo-agent and repl-agent demos invoke this bin with their own leaf configs. * @module @deepseek-ai/dsh-stdio-demo/bin */ diff --git a/packages/examples/stdio-demo/src/index.ts b/packages/examples/stdio-demo/src/index.ts index 69a0be1171..531c4a3d5e 100644 --- a/packages/examples/stdio-demo/src/index.ts +++ b/packages/examples/stdio-demo/src/index.ts @@ -174,7 +174,7 @@ export function composeTerminalApp(ctx: Context, config: Config, isTTY: boolean) /** Compose the configured terminal front door with the agent app. */ /* v8 ignore start -- production stream capability wiring; composeTerminalApp is unit-covered, - and the coding-agent PTY smoke covers the interactive process path */ + and the repl-agent PTY smoke covers the interactive process path */ export function apply(ctx: Context, config: Config): void { composeTerminalApp(ctx, config, process.stdin.isTTY && process.stdout.isTTY) } diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 0906b7bc9f..1c3fc1315c 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -1342,7 +1342,7 @@ export function mountTui(ctx: Context, config: Config, runtime: TuiRuntime): voi /** Cordis entry point using the process terminal; explicit TUI composition requires a TTY pair. */ /* v8 ignore start -- production process wiring; fake-terminal tests cover mountTui/createTuiChat, - and the coding-agent PTY smoke covers the real entry */ + and the repl-agent PTY smoke covers the real entry */ export function apply(ctx: Context, config: Config): void { if (!process.stdin.isTTY || !process.stdout.isTTY) { throw new Error('ui-tui: both stdin and stdout must be TTYs; use @deepseek-ai/dsh-stdio for pipes') diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 7a5e054cc0..43bff2d4ba 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -9,7 +9,7 @@ import { spawn } from 'node:child_process' // the overlay config (the stdio bin keeps --expose-internals for the cordis // Loader's HMR path). const UIS = new Map([ - ['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/coding-agent/code-mode.cordis.yml']], + ['repl', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/repl-agent/code-mode.cordis.yml']], ['tui', ['--expose-internals', '--import', 'tsx', 'packages/examples/stdio-demo/src/bin.ts', 'examples/tui-agent/code-mode.cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 7164e65a5c..7c698c0cb9 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -427,12 +427,12 @@ const APP_EXAMPLES = [ summary: 'The echo demo swaps in a local mock LLM and teaching echo tool, then loads the stdio app package for the shared spine and terminal front door.', }, { - id: 'coding', - rel: 'examples/coding-agent/composition.md', - title: 'Coding Agent App Composition', - label: 'examples/coding-agent', - config: 'examples/coding-agent/cordis.yml', - summary: 'The coding-agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', + id: 'repl', + rel: 'examples/repl-agent/composition.md', + title: 'REPL Agent App Composition', + label: 'examples/repl-agent', + config: 'examples/repl-agent/cordis.yml', + summary: 'The REPL agent demo adds the real DeepSeek adapter, filesystem tools, todo_write, compaction, and both subagent transports on top of the stdio app package.', }, { id: 'tui', @@ -440,7 +440,7 @@ const APP_EXAMPLES = [ title: 'TUI Agent App Composition', label: 'examples/tui-agent', config: 'examples/tui-agent/cordis.yml', - summary: 'The TUI agent reuses the coding-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.', + summary: 'The TUI agent reuses the repl-agent backend and tool composition while fixing the shared terminal app to the full-screen dsh-tui front door.', }, { id: 'cordis', @@ -470,7 +470,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string if (pluginName === '@deepseek-ai/dsh-stdio-demo') { const frontDoor = exampleId === 'tui' ? '@deepseek-ai/dsh-tui
pre-created main agent' - : exampleId === 'coding' + : exampleId === 'repl' ? '@deepseek-ai/dsh-stdio
pre-created main agent' : 'dsh-tui (TTY) / dsh-stdio (pipes)
pre-created main agent' lines.push(` ${appNode} --> ${nodeId('frontdoor', 'stdio')}["${frontDoor}"]`) @@ -990,7 +990,7 @@ function renderIndex(docs: GraphDoc[]): string { const labels: Record = { 'docs/capability-seams.md': 'capability seams and core services', 'examples/echo-agent/composition.md': 'echo-agent app composition', - 'examples/coding-agent/composition.md': 'coding-agent app composition', + 'examples/repl-agent/composition.md': 'repl-agent app composition', 'examples/tui-agent/composition.md': 'tui-agent app composition', 'examples/cordis-agent/composition.md': 'cordis-agent app composition', 'examples/acp-agent/composition.md': 'acp-agent app composition', @@ -1002,7 +1002,7 @@ function renderIndex(docs: GraphDoc[]): string { const modes: Record = { 'docs/capability-seams.md': 'hybrid generated', 'examples/echo-agent/composition.md': 'hybrid generated', - 'examples/coding-agent/composition.md': 'hybrid generated', + 'examples/repl-agent/composition.md': 'hybrid generated', 'examples/tui-agent/composition.md': 'hybrid generated', 'examples/cordis-agent/composition.md': 'hybrid generated', 'examples/acp-agent/composition.md': 'hybrid generated', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index ca69768227..8606d30895 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -206,7 +206,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/coding-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/repl-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-tasks', diff --git a/website/zh-CN/guide/config.md b/website/zh-CN/guide/config.md index 07943288b8..35fb42185b 100644 --- a/website/zh-CN/guide/config.md +++ b/website/zh-CN/guide/config.md @@ -39,7 +39,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 persistenceRoot: './.sessions' ``` -### coding-agent 的配置 +### repl-agent 的配置 真实场景——接入 DeepSeek API,带完整工具链: @@ -81,7 +81,7 @@ Harness 使用 `cordis.yml` 描述一个 Agent 加载哪些插件、以什么参 persistenceRoot: './.sessions' welcome: 'agent REPL ready. Give it a coding task.' persona: | - You are coding-agent, a coding assistant powered by the {{model}} model. + You are a coding agent powered by the {{model}} model. Verify your work by running the code or tests. Keep answers brief and factual. # Token 计量:统一定义模型能看到的 token 上限 diff --git a/website/zh-CN/guide/quickstart.md b/website/zh-CN/guide/quickstart.md index 76ff1fd3fe..27725fc6b9 100644 --- a/website/zh-CN/guide/quickstart.md +++ b/website/zh-CN/guide/quickstart.md @@ -69,7 +69,7 @@ echo-agent ready. Type a message ("echo " triggers the tool). DEEPSEEK_API_KEY=sk-your-key-here ``` -### 启动 coding-agent +### 启动 repl-agent ```sh pnpm run demo:repl @@ -90,7 +90,7 @@ agent REPL ready. Give it a coding task. ## 回头看 -echo-agent 和 coding-agent 用的是同一个应用框架(`@deepseek-ai/dsh-stdio-demo`),区别只在 `cordis.yml`——换了哪些插件、填了什么配置。你以后定制自己的 Agent 也是同样的方式。 +echo-agent 和 repl-agent 用的是同一个应用框架(`@deepseek-ai/dsh-stdio-demo`),区别只在 `cordis.yml`——换了哪些插件、填了什么配置。你以后定制自己的 Agent 也是同样的方式。 ## 下一步