diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 88979aab69..f164e3c4c4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,19 +33,17 @@ jobs: - name: Constraints run: pnpm run constraints - # Before lint: the type-aware ESLint config resolves vendor packages via - # their built declarations (tsconfig.typecheck.json -> vendor/*/lib), - # which `pnpm run typecheck` emits. Lint on a fresh checkout would otherwise - # see unresolved types and erupt with no-unsafe-* errors. + # Before lint: root typecheck validates the package/vendor reference graph + # and refreshes TSC intermediates so type-aware ESLint sees the same project + # boundaries as the build. - name: Typecheck (src + tests + examples) run: pnpm run typecheck - name: Lint run: pnpm run lint - # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the fenced ts blocks in - # the docs and resolves vendor packages via their built declarations, which - # the typecheck step above emits — so it runs after typecheck. The cordis + # Doc-sync gates (doc-sync-enforcement RFC). doc-typecheck compiles the + # fenced ts blocks against the root project-reference graph. The cordis # catalog freshness check, type-equiv check, and markdown wrap/link checks # only read source. Same `doc-sync` script the pre-push hook runs # (quality-gates RFC: one source of truth). @@ -71,12 +69,13 @@ jobs: run: pnpm run test:snapshot # Before hygiene: publint validates the packed artifacts (lib/index.js), - # which only the tsdown bundling step emits. + # which only the tsdown bundling step emits, and verify-node-next-types + # validates the built declarations. - name: Build (tsc -b + tsdown bundles) run: pnpm run build - - name: Hygiene (knip + publint) - run: pnpm run knip && pnpm run publint + - name: Hygiene (knip + publint + constraints + NodeNext types) + run: pnpm run hygiene - name: Demo smoke test run: | diff --git a/AGENTS.md b/AGENTS.md index a68e13ccef..e08dd02e5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -139,14 +139,13 @@ pnpm run test:snapshot # ACP snapshot tests (examples/*/tests/**/*.snapshot.ts) pnpm run test:snapshot:record # re-record fixtures + goldens against the real # API (needs DEEPSEEK_API_KEY); accept-the-diff = re-record # (or `pnpm run test:snapshot -u` to refresh goldens only) -pnpm run typecheck # tsc -b tsconfig.build.json (declarations) + tsc -p - # tsconfig.typecheck.json (tests/examples typecheck too) +pnpm run typecheck # tsc -b tsconfig.json pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix -pnpm run build # tsc -b tsconfig.build.json && tsdown (JS bundles into lib/) +pnpm run build # tsc emits lib/types, then tsdown bundles runtime lib/index.* pnpm run knip # dead-code / unused-dependency check pnpm run publint # package.json publish-correctness check (every packages/*/* package) -pnpm run hygiene # knip + publint + workspace constraints +pnpm run hygiene # knip + publint + workspace constraints + NodeNext type-consumer check pnpm run doc-typecheck # typecheck every ```ts block in README.md, docs/**/*.md, # packages/*/*.md + packages/*/*/*.md (doc/code drift gate) pnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events-and-services.md @@ -161,6 +160,8 @@ pnpm run verify-package-paths # assert every packages/ cited in Markdown pnpm run verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) +pnpm run verify-node-next-types # assert built declarations typecheck for a + # standard external NodeNext ESM TypeScript consumer pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton @@ -184,12 +185,12 @@ cordis.yml configs reference env vars with the `!!js` tag: `apiKey: !!js process **Lean on with-key e2e tests — we are DeepSeek and model inference is cheap.** A no-key test (mock adapter, or an operation that never reaches the model) is great for determinism and CI, but it can only prove the plumbing, not that the agent actually *works* against a real model. Do not ration real-API tests to save tokens: write many of them, cover the real flows (a real prompt that writes a file, a multi-turn conversation, tool use, cancellation mid-stream), and run them frequently while developing — locally and whenever you have a key in the environment. **Especially smoke tests**: a cheap with-key smoke test that boots the real example, sends one real prompt, and checks the world (a file on disk, a non-empty assistant turn) catches whole classes of "green unit tests, broken product" failures that mocks structurally cannot — the very gap that let the ACP inject bug ship (see [docs/postmortem/0001](docs/postmortem/0001-acp-default-export-drops-inject.md)). The self-skip rule is ONLY so CI (which has no secrets) stays green and so a contributor without a key isn't blocked — it is not a signal that real-API tests are expensive or second-class. When in doubt, add the with-key test AND run it. -Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.json` (`vitest` resolves through `tsconfig.test.json`). Building is only needed for publishing/consumption outside the repo — with one exception: `pnpm run lint`'s type-aware rules resolve vendor packages through their built declarations (`tsconfig.typecheck.json` → `vendor/*/lib`), so run `pnpm run typecheck` once after a fresh clone (CI does the same) or lint reports unresolved-type `no-unsafe-*` errors. +Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsconfig.json` (`vitest` resolves through that same root config). Building is only needed for publishing/consumption outside the repo. Non-published code (`examples`, tests, and scripts) is checked by root `tsconfig.json`, which sets `noEmit` and references the package/vendor graph so those sources stay checked under their own tsconfig boundaries. ## Conventions - **Package naming**: every npm package in this repo is `@deepseek-ai/dsh-` (vendored packages keep their upstream names and are `private: true`). -- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package imports use explicit `.ts` extensions (allowImportingTsExtensions). +- **ESM everywhere** (`"type": "module"`); imports between workspace packages use package names, never relative paths across package boundaries. In-package relative imports use explicit `.ts` extensions; `rewriteRelativeImportExtensions` turns those into `.js` in emitted JS, while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve to sibling `.d.ts` files. `lib/types/**/*.js` is a bundler-only intermediate, not a Node ESM entrypoint. - **`cordis` is a peerDependency** (+ devDependency) of every harness package, mirroring upstream convention. - **Registrations are effects**: anything a plugin contributes (adapter, tool, section, agent, event listener) goes through `ctx.effect()` / `ctx.on()` so disposal and HMR work. If you write a registry, `register()` must return the disposer. - **Typed events via declaration merging**: services declare their events in `declare module 'cordis' { interface Events { … } }`, and their ctx key in `interface Context`. Extensible unions use the merge-extensible-map pattern (see `ContentBlockMap`, `MessageSourceMap`). @@ -207,6 +208,7 @@ Dev/test/demo run **unbuilt** via tsx + the `paths` map in the root `tsconfig.js - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test (dispose the contributing fiber, assert cleanup). **Excessive tests are welcome** — when in doubt, write the test; err on the side of covering edge cases, error paths, event ordering, and concurrency races even if they seem unlikely. Review findings get regression tests (see `packages/core/agent-loop/tests/review-fixes.spec.ts`). The same generosity applies to **real-API (with-key) e2e tests — inference is cheap here (we are DeepSeek), so do not ration them**: cover the agent's real flows (a real prompt that writes a file, multi-turn, tool use, cancellation) and run them frequently while developing, especially cheap **smoke tests** that boot the real example and check the world. A green mock/no-key suite proves the plumbing, not the product — the with-key smoke test is what catches "green units, broken product". See § Secrets / .env for the with-key policy and why self-skip is a CI accommodation, not a verdict that real-API tests are expensive. - **Prefer the REAL implementation over a mock/stand-in in tests.** When the genuine collaborator is available in the repo, wire it up instead of hand-rolling a fake — a test that registers an inline `defineTool({ name: 'bash', … })` to stand in for `dsh-tool-bash` proves the *bridge* moves bytes but not that the *shipping tool* renders the way the test asserts; the two drift and the test passes while the product is wrong. Mock only the genuinely expensive/non-deterministic boundary (the LLM adapter, the network, the clock) and keep everything downstream real: a bridge tool-call test runs the scripted mock MODEL but the REAL tool + REAL executor (e.g. `makeBridgeHarness({ withBash: true })` plugs `dsh-bash-local` + `dsh-tool-bash` and runs an actual `echo`), so it verifies the actual `presentCall`/`presentResult` an editor sees. This is the unit-test echo of "verify the world, not a synthetic stand-in" (see § Defensive patterns) — a fake you wrote will agree with whatever you assumed; the real thing won't. - **A change that affects the editor-facing transcript or end-to-end agent UX needs a snapshot test (or an explicit note in the PR why none applies).** The snapshot tier (`examples/*/tests/**/*.snapshot.ts`, `pnpm run test:snapshot`) boots the real example subprocess, replays a recorded session JSONL deterministically (keyless), and diffs the normalized stdout transcript + re-persisted session log against committed goldens — the full-transcript regression net that mock-level unit tests structurally cannot be (it is what catches a bridge-translation or loop-structure regression that leaves every unit green). When you change the ACP bridge, the agent loop's observable output, tool presentation, or anything an editor renders, add or update a scenario under `examples/acp-agent/tests/snapshots/` and re-record with `pnpm run test:snapshot:record`. Reviewing the golden diff is part of the review. The rule is scoped to transcript/UX-affecting changes — a pure internal refactor with no observable-output change does not need one, but say so. See [docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md](docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md). +- **Designing a new subsystem includes designing its test infrastructure — END TO END, up front, as part of the same plan.** When you introduce a new capability seam, a new agent-lifecycle shape, or anything that produces an observable transcript (a new tool family, a subagent transport, a new UI surface), the plan must name how it will be covered at EVERY tier it touches — unit, real-API e2e, AND the full-transcript snapshot tier — and, critically, must check that the existing test infrastructure can actually express that coverage. Do not assume a snapshot/e2e harness built for one shape (e.g. a single top-level ACP session) transparently supports a new shape (e.g. a parent agent driving nested child agents): verify it, and if it cannot, the harness extension is in-scope work to plan and schedule, not a detail to discover mid-implementation. This rule exists because a real plan under-scoped exactly this: the subagent backends were planned with unit + e2e coverage but the snapshot tier turned out to assume one session per process (`dsh-llm-replay`'s single positional cursor, single-file harvest), so nested-agent snapshot coverage became unplanned net-new infrastructure (`TODO(subagent-snapshots)`). The cost of finding that during design is a paragraph; the cost of finding it mid-build is a re-plan. When the harness gap is large enough to be its own reviewable unit, schedule it as a dedicated stacked follow-up with its own RFC — but SAY SO in the originating plan, with the gap named, rather than letting it surface as a surprise. ## Defensive patterns (hard-won) diff --git a/docs/architecture.md b/docs/architecture.md index 2b05e613b1..216ed949b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -116,7 +116,7 @@ Tool schemas are deliberately **part of the assembly**: "what the model is told - `whenIdle()` — resolves once the agent reaches quiescence after settling out of `running` (resolves immediately when already idle; awaits the loop exit when disposed). A non-owner's quiescence-observation hook: it lets a consumer await the current work settling **without** disposing the agent. It is NOT teardown — it does not stop queued work, unregister the agent, or detach the session; a lifecycle owner tears an agent down with `await AgentHandle.dispose()` (which stops the loop, awaits its exit, and unregisters). - `session`, `status`, `options` -**TODO(sub-agents)**: `spawn`/`fork` land on `AgentLoop.create()` — fork seeds the child Session with the parent's event log, spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Inter-agent channels beyond these primitives are deliberately deferred. +**Subagents**: `spawn`/`fork` are realized by the [`@deepseek-ai/dsh-subagent`](../packages/subagent/subagent) seam (a named-provider registry on `ctx.subagents`), not a method on `Agent`. The in-process backends create the child via `ctx.agents.create` — fork seeds the child Session with a balanced completed-turn prefix of the parent's log (`CreateAgentOptions.seed`), spawn starts fresh; children are ordinary `Agent` handles so `steer()` and event subscription work uniformly. Out-of-process transports (ACP, and later A2A / Codex app-server / Claude Code SDK) register as sibling providers. See [docs/core-data-structures/subagent.md](core-data-structures/subagent.md) and [the subagent RFC](rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). Inter-agent channels beyond delegation remain deferred. ### Loop lifecycle (session / turn / step) diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index 1ac20c1c3a..3c9cdb07d5 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -5,29 +5,33 @@ The file-by-file checklist for a new `@deepseek-ai/dsh-` package. (Verifie ## 1. Create the package ``` -packages// +packages/// package.json # copy from packages/core/tools, adjust name/description/deps - tsconfig.json # extends ../../tsconfig.base.json, rootDir src, outDir lib, - # references: vendor/cosmokit, vendor/cordis (+ vendor/schemastery - # if you use Config, + ../ for each dsh dependency) + tsconfig.json # extends ../../../tsconfig.base.json, rootDir src, + # outDir lib/types, references: ../../../vendor/cosmokit, + # ../../../vendor/cordis (+ ../../../vendor/schemastery if + # you use Config, + ../..// for each dsh dep) src/index.ts # service default export or plugin (name/inject/apply/Config) tests/.spec.ts README.md # service API, events, extension points, design notes ``` -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. +Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. + +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, `version: 0.0.1`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list is precise: `lib/index.js`, `lib/types/**/*.d.ts`, `lib/types/**/*.d.ts.map`, and `src`; do not publish `lib/types` JS or JS-map intermediates or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. + +In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. ## 2. Register it in the root configs | File | Change | |---|---| -| `tsconfig.base.json` | add `"@deepseek-ai/dsh-": ["./packages//src"]` to `paths` | -| `tsconfig.typecheck.json` | same entry (this file overrides the map wholesale) | -| `tsconfig.build.json` | add `{ "path": "./packages/" }` to `references` | -| `scripts/publint-all.ts` | add `'packages/'` to the array | +| `tsconfig.base.json` | no edit for an existing group; for a new group, add a `./packages//*/src` candidate to the `@deepseek-ai/dsh-*` wildcard | +| `tsconfig.json` | add `{ "path": "./packages//" }` to `references` | +| `tsconfig.build.json` | add `{ "path": "./packages//" }` to `references` | | `knip.json` | only if the package has non-`*.spec.ts` entries (e.g. `*.e2e.ts` → add a per-workspace override like `packages/llm/llm-deepseek`) | -Covered automatically by globs — no edits needed: root `package.json` workspaces, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. +Covered automatically by globs or package-manifest discovery — no edits needed: root `package.json` workspaces, `scripts/publint-all.ts`, `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`, `scripts/check-workspace-constraints.ts`. ## 3. Decide the package topology @@ -39,7 +43,7 @@ For a swappable capability, split interface / implementation / consumer into sep pnpm install # registers the workspace pnpm run constraints && pnpm run typecheck && pnpm run lint pnpm run test:coverage # 100% per-file over src (types.ts exempt) -pnpm run build && pnpm run knip && pnpm run publint +pnpm run build && pnpm run hygiene ``` Test expectations: every registry/registration needs an HMR-safety test (register from a child fiber, dispose it, assert cleanup). Excessive tests are welcome — see AGENTS.md. diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 71eadb9108..59df2f617b 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -12,13 +12,13 @@ vendor// README.md LICENSE # if upstream ships them ``` -`tsconfig.json` mirrors the other vendored packages — `rootDir: src`, `outDir: lib`, the strictness relaxations upstream code needs, and a `references` entry for every other vendored package it imports: +`tsconfig.json` mirrors the other vendored packages — `rootDir: src`, `outDir: lib/types`, the strictness relaxations upstream code needs, and a `references` entry for every other vendored package it imports: ```jsonc { "extends": "../../tsconfig.base.json", "compilerOptions": { - "rootDir": "src", "outDir": "lib", + "rootDir": "src", "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, "noUnusedLocals": false, "noUnusedParameters": false }, @@ -27,19 +27,21 @@ vendor// } ``` -`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). + +Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build-shape divergence from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. ## 2. Register it in the root configs | File | Change | |---|---| | `tsconfig.base.json` | add `"": ["./vendor//src"]` to `paths` | -| `tsconfig.typecheck.json` | add `"": ["./vendor//lib"]` — this file points at built declarations, not src. If the package's `types` entry isn't `lib/index.d.ts`, point at that built file instead (e.g. `logger-console` maps to `./vendor/logger-console/lib/shared`, matching its `"types": "lib/shared.d.ts"`). | +| `tsconfig.json` | add `{ "path": "./vendor/" }` to `references` | | `tsconfig.build.json` | add `{ "path": "./vendor/" }` to `references` (before the `packages/*` entries) | | `vendor/README.md` | add a manifest table row (dir, npm name, version, upstream repo, commit SHA) and log any local modifications | | `scripts/publint-all.ts` | only if the vendored package is itself published from here (vendored deps normally are not — skip) | -Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`). +Covered automatically by globs — no edits needed: root `package.json` workspaces (`vendor/*`), `tsdown.config.ts`, `vitest.config.ts`, `eslint.config.mjs`. A per-package `vendor//tsdown.config.ts` is needed ONLY if the build shape diverges from the root default (dual ESM/CJS or multiple entries — see `vendor/schemastery` and `vendor/logger-console`); its entry should read the JS emitted under `lib/types`. ## 3. Mind the manifest guard @@ -49,8 +51,8 @@ Covered automatically by globs — no edits needed: root `package.json` workspac ```sh pnpm install # registers the workspace -pnpm run typecheck # the base→lib path split means: run once after a fresh add +pnpm run typecheck pnpm run build && pnpm run test && pnpm run constraints ``` -Note the `tsconfig` two-map split (called out in [AGENTS.md](../../AGENTS.md) § Secrets/.env): `lint`'s type-aware rules resolve vendored packages through their built `lib/` declarations, so run `pnpm run typecheck` (which builds them) once after adding the package or lint reports unresolved-type errors. +The source `paths` map is shared by build and root typecheck configs. The important isolation boundary is the project-reference graph: vendored source must be referenced through its own `vendor//tsconfig.json`, not pulled into a root strict program. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index f8c6b12c0d..737a4cc8ef 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -11,7 +11,7 @@ The **harness tier** below (the `@deepseek-ai/dsh-*` packages) is the vocabulary ## Events -Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 22 events across 5 scopes. +Dispatch modes: **emit** (fire-and-forget), **waterfall** (each listener gets `next()` and may transform or veto — see [waterfall semantics](../architecture.md#cordis-waterfall-semantics-important)), **parallel** (awaited fan-out, no veto). The harness declares 24 events across 6 scopes. ### `agent/*` @@ -25,7 +25,7 @@ An agent was registered in the AgentRegistry and is ready to receive messages. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:136`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:137`](../../packages/core/agent/src/types.ts) #### `agent/disposed` — emit @@ -37,7 +37,7 @@ An agent was disposed and removed from the registry; its fiber and any in-flight Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:142`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:143`](../../packages/core/agent/src/types.ts) #### `agent/error` — emit @@ -49,7 +49,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:219`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:220`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -61,7 +61,7 @@ A message entered the agent's inbox (queued or steering). `source` is 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:155`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:156`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -73,7 +73,7 @@ Waterfall: mutate the fully-assembled GenerateOptions before the model call (hoo Types: [Agent](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:188`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:189`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -85,7 +85,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:149`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:150`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -97,7 +97,7 @@ Steering content was injected into a running turn. 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:213`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:214`](../../packages/core/agent/src/types.ts) #### `agent/step-end` — emit @@ -109,7 +109,7 @@ A step ended. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:179`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:180`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -121,7 +121,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:194`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:195`](../../packages/core/agent/src/types.ts) #### `agent/step-start` — emit @@ -133,7 +133,7 @@ A step (one model call plus its tool dispatch) began. `step` is 1-based within t Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:174`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:175`](../../packages/core/agent/src/types.ts) #### `agent/stream-chunk` — emit @@ -145,7 +145,7 @@ A raw StreamChunk arrived from the model (token-level UI/log feed). Types: [Agent](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/core/agent/src/types.ts:208`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:209`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -157,7 +157,7 @@ Waterfall: override the turn-continuation decision. The default (computed by the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:201`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:202`](../../packages/core/agent/src/types.ts) #### `agent/turn-end` — emit @@ -169,7 +169,7 @@ A turn ended. `reason` distinguishes a clean stop from a truncated or aborted on Types: [Agent](../core-data-structures/core.md) · [TurnEndReason](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:168`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:169`](../../packages/core/agent/src/types.ts) #### `agent/turn-start` — emit @@ -181,7 +181,7 @@ A turn began. `turn` is the 1-based turn number within the session. Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:162`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) ### `llm/*` @@ -231,6 +231,28 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus Source: [`packages/core/session/src/index.ts:48`](../../packages/core/session/src/index.ts) +### `subagent/*` + +#### `subagent/end` — emit + +A subagent run settled — emitted when SubagentRun.result resolves (any stop reason). Paired with Events['subagent/start']. + +```ts cordis-catalog +'subagent/end'(info: SubagentRunEndInfo): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:65`](../../packages/subagent/subagent/src/index.ts) + +#### `subagent/start` — emit + +A subagent run started — emitted after the provider is resolved and its capabilities validated, as the child run begins. Paired with Events['subagent/end']. + +```ts cordis-catalog +'subagent/start'(info: SubagentRunInfo): void +``` + +Source: [`packages/subagent/subagent/src/index.ts:59`](../../packages/subagent/subagent/src/index.ts) + ### `system-prompt/*` #### `system-prompt/assemble` — waterfall @@ -279,7 +301,7 @@ Source: [`packages/core/tools/src/index.ts:43`](../../packages/core/tools/src/in ## Services -The 8 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. +The 9 `ctx.` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. ### `ctx.agentLoop` — `AgentLoop` @@ -310,7 +332,7 @@ list(): Agent[] Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/index.ts:105`](../../packages/core/agent/src/index.ts) +Source: [`packages/core/agent/src/index.ts:117`](../../packages/core/agent/src/index.ts) ### `ctx.bash` — `BashExecutor` (abstract seam) @@ -392,6 +414,19 @@ list(): Session[] Source: [`packages/core/session/src/index.ts:300`](../../packages/core/session/src/index.ts) +### `ctx.subagents` — `SubagentService` + +The `subagents` service: a registry of named SubagentProviders and a capability-checked start surface. + +```ts cordis-catalog +registerProvider(provider: SubagentProvider): () => void +getProvider(name: string): SubagentProvider | undefined +list(): string[] +start(name: string, request: SubagentStartRequest): SubagentRun +``` + +Source: [`packages/subagent/subagent/src/index.ts:103`](../../packages/subagent/subagent/src/index.ts) + ### `ctx.systemPrompt` — `SystemPrompt` Registry service (`ctx.systemPrompt`): plugins contribute ordered text sections and tool-schema providers; the agent loop calls `assemble()` once per step. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index b13cdc1de4..f2842d0238 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -20,6 +20,7 @@ Everything else is documented on a **sub-page**, not here. The rule that draws t | [persistence.md](persistence.md) | the durability seam: `SessionPersistence`, JSONL + SQLite backends, `session/flush`, crash recovery, `SessionHeader` | | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, the `tools/execute` waterfall | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashTask`s | +| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split | > 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. @@ -139,6 +140,20 @@ interface GenerateOptions { */ stop?: string[] signal?: AbortSignal + /** + * The id of the session this request belongs to — stamped by the agent loop + * from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener + * route a call by WHICH session issued it (the replay adapter keys its per-call + * cursor by session, so a parent and its in-process subagent — each with its + * own session on one context — replay from their own recorded scripts). + * + * Typed as `Branded<'SessionId'>` rather than importing `SessionId` from + * `dsh-session`: that package imports `Message` from here, so importing its + * `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a + * real session id assigns with no cast. (A future ids package could own the + * brand and dissolve this note.) + */ + sessionId?: Branded<'SessionId'> } ``` @@ -281,11 +296,12 @@ interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // 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. } ``` diff --git a/docs/core-data-structures/persistence.md b/docs/core-data-structures/persistence.md index 45c1d8dd6b..8d8f032514 100644 --- a/docs/core-data-structures/persistence.md +++ b/docs/core-data-structures/persistence.md @@ -34,12 +34,22 @@ interface SessionHeader { cwd?: string /** The session this one was forked from (seed lineage), if any. */ 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). + */ + seedLength?: number } ``` ## `CreateSessionOptions` — seeding and metadata -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, and — only when reconstructing a persisted session — the original `createdAt` to preserve it. +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 interface CreateSessionOptions { @@ -48,10 +58,16 @@ interface CreateSessionOptions { /** * 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, and — when reconstructing a - * persisted session — the original `createdAt` to preserve it). + * 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. */ - meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } + meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } } ``` diff --git a/docs/core-data-structures/subagent.md b/docs/core-data-structures/subagent.md new file mode 100644 index 0000000000..c64d370ff2 --- /dev/null +++ b/docs/core-data-structures/subagent.md @@ -0,0 +1,95 @@ +# Subagent + +The subagent seam — an agent delegating work to a child agent. Like [bash](bash.md) it is **one optional capability**, not part of the agent-loop spine, so its vocabulary lives here rather than in [core.md](core.md). But it differs from every other seam on one axis: **multiple provider implementations coexist** in one context, registered by name (`ctx.subagents`), where bash allows only one executor. The registry shape mirrors the [LLM adapter registry](llm-streaming.md), not the single-service bash executor. + +Interface: [dsh-subagent](../../packages/subagent/subagent) (`ctx.subagents` + the vocabulary below). Implementations are sibling packages (`dsh-subagent-spawn`, `-fork`, `-acp`); the model-facing consumer is [dsh-tool-subagent](../../packages/subagent/tool-subagent). The proposal and rationale: [the subagent RFC](../rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). + +Source: [`packages/subagent/subagent/src/types.ts`](../../packages/subagent/subagent/src/types.ts) + +## Two kinds of capability, discovered two ways + +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 +interface SubagentCapabilities { + outputSchema: boolean + depthLimit: boolean + toolFilter: boolean +} +``` + +## The start request + +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 the start-time capabilities against the named provider, then passes it to `provider.start`. `parent` is REQUIRED — in-process backends read `parent.session.header` for the working directory, the `parentSession` lineage, and the delegation depth. The three optional fields (`outputSchema`, `maxDepth`, `toolFilter`) each gate on the matching `SubagentCapabilities` flag. + +```ts type-equiv +interface SubagentStartRequest { + prompt: ContentBlock[] + parent: Agent + signal?: AbortSignal + agentOptions?: AgentOptions + outputSchema?: SchemaSpec + maxDepth?: number + toolFilter?: { allow?: string[]; deny?: string[] } +} +``` + +## The terminal result: `SubagentResult` + +The outcome of a run, resolved by `SubagentRun.result`. `structured` is present iff the request carried an `outputSchema` AND the provider honored it. 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 +interface SubagentResult { + output: ContentBlock[] + structured?: unknown + stopReason: SubagentStopReason +} +``` + +`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 +interface SubagentStopReasonMap { + completed: 'completed' + aborted: 'aborted' + error: 'error' + 'max-tokens': 'max-tokens' + refusal: 'refusal' +} +``` + +## A live run: `SubagentRun` + +The handle the consumer holds while a child executes. The consumer awaits `result`, may `cancel` mid-flight, and MUST `dispose` on every path to reach child quiescence (no leaked idle child / session). `result` does NOT reject on a child-level failure — a model/transport failure resolves with `stopReason: 'error'` — so the consumer maps a non-`completed` reason to an `isError` result; it rejects only on an infrastructure fault the seam cannot represent. `sendMessage` and `resume` are OPTIONAL: a provider that supports the runtime capability defines the method; one that doesn't omits it. + +```ts type-equiv +interface SubagentRun { + readonly id: AgentId + readonly result: Promise + cancel(reason?: string): void + dispose(): Promise + sendMessage?(content: ContentBlock[]): void + resume?(content: ContentBlock[]): SubagentRun +} +``` + +## 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. + +```ts type-equiv +interface SubagentProvider { + readonly name: string + readonly capabilities: SubagentCapabilities + start(request: SubagentStartRequest): SubagentRun +} +``` + +The service (`ctx.subagents`) emits `subagent/start` when a run begins and `subagent/end` when it settles (see the [events catalog](../cordis-catalog/events-and-services.md)). Both emits contain a thrown listener **per listener** (logged, never propagated): one bad subscriber can neither strand a live run, surface as an unhandled rejection on the detached settle hook, nor starve the listeners registered after it. + +## In-process backends: depth and seed + +The two in-process backends ([dsh-subagent-spawn](../../packages/subagent/subagent-spawn) fresh, [dsh-subagent-fork](../../packages/subagent/subagent-fork) seeded) run the child as a child `Agent` on the same context via `ctx.agents.create`. Two pieces of vocabulary ride on the existing agent/session types rather than new core types: + +- **Delegation depth** is a merge-extensible `AgentOptions.subagentDepth` field (`0` for a top-level agent, parent + 1 for a child). The seam owns it — the loop neither sets nor reads it — so a nested spawn reads its parent's depth from `parent.options.subagentDepth` and the `depthLimit` capability caps the tree by refusing a child whose depth would exceed `request.maxDepth`. +- **Fork seeding** uses `CreateAgentOptions.seed` (a `SessionEvent[]` prefix threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`, the same primitive `resume` uses). The fork backend passes a *balanced completed-turn prefix* of the parent's log — the parent's events up to and including its last `turn/end` — so the seed is contiguous-from-0 and the [invariants](../../packages/support/invariants) replay accepts it (the in-flight, unbalanced turn is excluded). diff --git a/docs/development.md b/docs/development.md index c2fb100177..99f67e671d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -31,7 +31,7 @@ Run typecheck once after a fresh clone: pnpm run typecheck ``` -That first typecheck builds declaration output used by type-aware linting for vendored packages. Without it, `pnpm run lint` can report unresolved-type `no-unsafe-*` errors even when source code is fine. +That first typecheck runs the package/vendor build graph and the root no-emit `tsconfig.json` graph for examples, tests, and scripts. The root graph uses the same source `paths` map but relies on project references so vendored code is checked under its own tsconfig settings. If you are preparing to push from a fresh clone or worktree, also build once: @@ -39,7 +39,7 @@ If you are preparing to push from a fresh clone or worktree, also build once: pnpm run build ``` -`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files. A fresh worktree has no bundled JS until `pnpm run build` runs. +`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs. ## Environment variables @@ -76,10 +76,10 @@ The GitHub workflow runs these gates on each pull request: - `pnpm run test:coverage` - `pnpm run test:snapshot` - `pnpm run build` -- `pnpm run knip && pnpm run publint` +- `pnpm run hygiene` - an echo-agent smoke test that checks the demo's tool call, tool result, and JSONL output -`pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints`; CI splits `pnpm run constraints` into its own earlier step, then runs `pnpm run knip && pnpm run publint` after `pnpm run build`. +`pnpm run hygiene` is the local shorthand for `pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types`; CI also runs `pnpm run constraints` as an earlier fail-fast step, then runs the full hygiene script after `pnpm run build`. ## Daily commands @@ -89,7 +89,7 @@ Use these from the repo root: pnpm run test # unit tests pnpm run test:coverage # unit tests with per-file coverage gates pnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY -pnpm run typecheck # build declarations, then typecheck source, tests, and examples +pnpm run typecheck # build package/vendor outputs, then typecheck examples, tests, and scripts pnpm run lint # eslint . pnpm run lint:fix # eslint . --fix pnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs @@ -100,8 +100,9 @@ pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from i pnpm run doc-sync # doc-typecheck, cordis-catalog freshness, markdown wrap/link, and type-equiv verification pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps pnpm run verify-module-graph # fail if docs/module-graph.md is stale -pnpm run build # build declarations and JS bundles -pnpm run hygiene # knip, publint, and workspace constraints +pnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files +pnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable +pnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check ``` When changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, cordis events/services catalog drift, and hard-wrapped markdown prose, but broader prose/API sync still needs review. diff --git a/docs/module-graph.md b/docs/module-graph.md index 9efe6e9669..93ad58725d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -45,6 +45,9 @@ graph TD agent-loop --> session-persistence agent-loop --> system-prompt agent-loop --> tools + subagent --> agent + subagent --> llm + subagent --> tools tool-bash --> agent tool-bash --> bash tool-bash --> llm @@ -57,6 +60,20 @@ graph TD agent-core --> system-prompt agent-core --> tool-bash agent-core --> tools + subagent-acp --> agent + subagent-acp --> llm + subagent-acp --> subagent + subagent-inprocess --> agent + subagent-inprocess --> llm + subagent-inprocess --> session + subagent-inprocess --> subagent + subagent-mock --> agent + subagent-mock --> llm + subagent-mock --> subagent + tool-subagent --> agent + tool-subagent --> llm + tool-subagent --> subagent + tool-subagent --> tools acp-agent --> acp acp-agent --> agent-core acp-agent --> session-persistence-jsonl @@ -65,6 +82,12 @@ graph TD stdio-agent --> session stdio-agent --> session-persistence-jsonl stdio-agent --> ui-stdio + subagent-fork --> agent + subagent-fork --> session + subagent-fork --> subagent + subagent-fork --> subagent-inprocess + subagent-spawn --> subagent + subagent-spawn --> subagent-inprocess ``` | Package | Depends on | @@ -87,7 +110,14 @@ graph TD | `ui-stdio` | `agent`, `llm`, `session` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | +| `subagent` | `agent`, `llm`, `tools` | | `tool-bash` | `agent`, `bash`, `llm`, `tools` | | `agent-core` | `agent`, `agent-loop`, `invariants`, `llm`, `session`, `system-prompt`, `tool-bash`, `tools` | +| `subagent-acp` | `agent`, `llm`, `subagent` | +| `subagent-inprocess` | `agent`, `llm`, `session`, `subagent` | +| `subagent-mock` | `agent`, `llm`, `subagent` | +| `tool-subagent` | `agent`, `llm`, `subagent`, `tools` | | `acp-agent` | `acp`, `agent-core`, `session-persistence-jsonl` | | `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl`, `ui-stdio` | +| `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` | +| `subagent-spawn` | `subagent`, `subagent-inprocess` | diff --git a/docs/rfc/README.md b/docs/rfc/README.md index dd34e66816..74af9e454b 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -82,6 +82,8 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Rich ACP bash rendering — the terminal card (`_meta`) and command classification](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | +| [Subagent capability seam](implemented/feature/2026-06-21-subagent-capability-seam.md) | 2026-06-21 | +| [ACP subagent backend (out-of-process delegation)](implemented/feature/2026-06-22-acp-subagent-backend.md) | 2026-06-22 | ### Simplification @@ -126,6 +128,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [tsdown for JS bundling instead of dumble](implemented/process/2026-06-11-tsdown-over-dumble.md) | 2026-06-11 | | [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | | [pnpm as the package manager instead of Yarn 4](implemented/process/2026-06-16-pnpm-over-yarn.md) | 2026-06-16 | +| [TSC-first build and one tsconfig](implemented/process/2026-06-17-ts-build-config.md) | 2026-06-17 | | [Markdown cross-link validity linting](implemented/process/2026-06-18-markdown-cross-link-lint.md) | 2026-06-18 | | [Core-data-structures catalog and the `ts type-equiv` drift gate](implemented/process/2026-06-20-core-data-structures-catalog.md) | 2026-06-20 | | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | @@ -139,6 +142,9 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [ACP snapshot tests — record-once / replay-deterministic](implemented/testing/2026-06-19-acp-snapshot-tests.md) | 2026-06-19 | | [Real-API e2e in CI against the external DeepSeek API](implemented/testing/2026-06-19-real-api-e2e-ci.md) | 2026-06-19 | | [Use `session.jsonl` as the only snapshot session-log artifact](implemented/testing/2026-06-20-remove-redundant-snapshot-log-goldens.md) | 2026-06-20 | +| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | +| [Persist the seed boundary so fork-child replay routes correctly](implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md) | 2026-06-22 | +| [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | ## Rejected diff --git a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md index 60e295e767..8b198ed9c6 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md +++ b/docs/rfc/implemented/architecture/2026-06-20-package-hierarchy.md @@ -51,7 +51,7 @@ packages/ The package list had been enumerated in five places. The uniform depth-2 layout lets most of them be derived instead: -- `tsconfig.base.json` and `tsconfig.typecheck.json` each map every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of 18 per-package entries. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the `paths` map via the TypeScript JSONC API rather than stripping comments by hand for exactly this reason.) +- `tsconfig.base.json` maps every package through a single `@deepseek-ai/dsh-*` `paths` wildcard listing one candidate per group, in place of per-package entries. Root `tsconfig.json` reuses that source map and carries the explicit project references that keep package/vendor typecheck boundaries intact. (One subtlety this introduced: a path candidate contains `/*/`, which a naive regex comment-stripper mistakes for a block comment — `scripts/doc-typecheck.ts` reads the JSONC config through TypeScript's parser rather than stripping comments by hand for exactly this reason.) - `scripts/publint-all.ts` derives its list by reading the hierarchy (`packages//`), resolving the `TODO(package-inventory)`. - `tsconfig.build.json`'s project `references` stay an explicit list — TypeScript project references have no wildcard form. Generating these from a manifest is left to a follow-up (see [discover package inventories](../../proposed/process/2026-06-20-discover-package-inventory.md)). 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 new file mode 100644 index 0000000000..f733daca7b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md @@ -0,0 +1,73 @@ +# RFC: Subagent capability seam + +Status: implemented + +> **Implementation status:** shipped across four PRs. PR1 landed this proposal + the `dsh-subagent` interface, the `dsh-subagent-mock` test backend, and the `dsh-tool-subagent` consumer; PR2 the two in-process backends (`dsh-subagent-spawn`, `dsh-subagent-fork`); PR2.5 the nested-agent snapshot infrastructure (see [Per-session snapshot replay for nested agents](../testing/2026-06-22-subagent-snapshot-replay.md)); PR3 the out-of-process `dsh-subagent-acp` backend (see [ACP subagent backend](2026-06-22-acp-subagent-backend.md)). The design below is amended to describe what actually landed. + +## Problem + +The harness has a long-deferred seam for **subagents** — an agent delegating work to another agent. The intent was sketched in the `Agent`/`AgentLoop` interfaces ([packages/core/agent/src/types.ts](../../../../packages/core/agent/src/types.ts), [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)): a creation option referencing a parent agent (fork = seed the child session with the parent's event log; spawn = fresh session), with the child returned as an `Agent` handle so steering and event subscription work uniformly. This RFC realizes that seam (see the implementation-status banner above for what has landed); the design below is the proposal it was argued from, when no service, vocabulary, or implementation yet existed. + +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); +- **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. + +## Why not the bash seam shape + +The bash seam ([capability seams](../../implemented/architecture/2026-06-13-capability-seams.md)) registers exactly one `BashExecutor` per context; loading a second throws. That is correct for bash (one machine, one way to run a command) but wrong here: coexistence is the requirement. So the subagent service is a **named-provider registry** — each implementation registers under a unique name and a caller picks one by name — mirroring the **LLM adapter registry** (`LlmService.registerAdapter`), not the single-service bash executor. The seam is still three-package (interface / implementation / consumer); only the "one vs. many implementations" axis differs. + +## Proposal + +### The three-package seam + +A new package group `packages/subagent/`: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-subagent` | interface: `SubagentService` (`ctx.subagents`), `SubagentProvider`, `SubagentRun`, the request/result/capability vocabulary, the `subagent/*` events | +| `@deepseek-ai/dsh-subagent-spawn` | implementation: a fresh in-process child via `ctx.agents.create` (PR2) | +| `@deepseek-ai/dsh-subagent-fork` | implementation: an in-process child seeded with a snapshot of the parent's log (PR2) | +| `@deepseek-ai/dsh-subagent-acp` | implementation: an ACP client driving a configured child process (PR3) | +| `@deepseek-ai/dsh-subagent-mock` | support: a scripted provider for testing the seam through the real load path (PR1) | +| `@deepseek-ai/dsh-tool-subagent` | consumer: the model-facing `subagent` tool over `ctx.subagents` (PR1) | + +### The primitive: `start → SubagentRun` + +A provider exposes `start(request) → SubagentRun`. The run carries a `result` promise (the terminal `SubagentResult`), `cancel()`, and `dispose()`. The transport-neutral verb is **`start`**; "spawn" is reserved for the in-process `dsh-subagent-spawn` backend's identity, not the service verb. The service's `start(name, request)` resolves the named provider, validates capabilities, delegates, and emits `subagent/start` / `subagent/end` around the run. + +### Two kinds of optional capability, discovered two ways + +- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) ride on a static `provider.capabilities` descriptor. The service checks every requested one BEFORE delegating and **rejects loud** (`SubagentError('UNSUPPORTED_CAPABILITY')`) if the provider lacks it — never accepted-then-ignored. They must be checked before a run exists, which is why they cannot be runtime methods. +- **Runtime features** (steering via `sendMessage`, follow-up via `resume`) are **optional methods** on `SubagentRun`. The method's presence IS the capability, and TypeScript narrowing is the discovery mechanism: a consumer cannot call an absent method without narrowing first, so there is no silent-degradation path and no separate flags object to keep in sync. + +### Fork vs. fresh are separate backends, not a flag + +Rather than a `context: 'fresh' | 'fork'` request field, the distinction is the provider's identity: `dsh-subagent-spawn` (fresh, isolated, own system prompt) and `dsh-subagent-fork` (seeded from the parent's log) are two registered providers. You pick behavior by picking a provider — consistent with the registry being the selection mechanism. + +### Child isolation and the parent log + +Each subagent runs in its **own `Session`** (own id, `parentSession` lineage), persisted independently. The parent's log records only the spawn `tool/call` and its `tool/result` (the child's final output) — the child's internal steps and tool calls stay in the child's own session, never injected into the parent log. This is the only design that is identical across transports: an ACP child's internal events physically cannot be injected into our parent log, so making in-process behave the same keeps the seam transport-agnostic. + +### Synchronous collect (first cut) + +The `dsh-tool-subagent` consumer awaits `run.result` and returns the child's final output as the tool result, blocking the parent's turn until the child finishes. It does so inside a `try/finally` that always `dispose()`s the run (no leaked idle child/session on any path), bridges `exec.signal` to `run.cancel()`, and maps a non-`completed` stop reason to an `isError` result rather than returning partial output as success. Steering (`sendMessage`) is part of the contract but **intentionally unused** this cut. + +### Provider selection is config, not model-facing + +`dsh-tool-subagent` binds to exactly one provider name (`Config.provider`); the model sees only `{ description, prompt }`. To expose more than one transport, load the tool plugin more than once, each bound to a different provider and a distinct `toolName` (the tool registry rejects a duplicate name). The *service* holds the multi-provider registry; the *tool* picks one — no provider/type parameter in the schema this cut. + +## Plan (three PRs, each converged with Codex separately) + +1. **PR1 — interface + tool + mock.** This RFC, `dsh-subagent` (service, registry, vocabulary, `subagent/*` events), `dsh-subagent-mock` (scripted provider), `dsh-tool-subagent`. Wire the new `packages/subagent/` group into the tsconfigs, the build references, the package hierarchy docs, and the module graph. Tests: registry HMR-safety, duplicate-name rejection, start-time capability rejection, and at least one test driving the tool through the **real cordis Loader / export path** (a hand-built `ctx.plugin` mount bypasses `unwrapExports` and cannot catch a broken export shape — see [postmortem 0001](../../../postmortem/0001-acp-default-export-drops-inject.md)). +2. **PR2 — in-process backends.** `dsh-subagent-spawn` and `dsh-subagent-fork` over `ctx.agents.create` + `AgentHandle.dispose`. The fork backend must seed only a **balanced, completed-turn prefix** of the parent log: at tool-execute time the parent's turn is open (it holds the `assistant/message` and the dangling spawn `tool/call` with no `tool/result`), and seeding that raw prefix gives the child an unbalanced turn the [invariants](../../../../packages/support/invariants/src/index.ts) freeze-check rejects. Depth tracking (parent depth + 1, refused past `maxDepth`) and its exact storage are settled in PR2. +3. **PR3 — ACP backend.** `dsh-subagent-acp` as an ACP client over a configured spawn command (stdio); point it at our own `acp-agent` example to "talk to our own process". Minimal client stub: advertise no optional client capabilities, auto-resolve `session/request_permission` via a configured default, consume `session/update` without surfacing it this cut. Decide the `@agentclientprotocol/sdk` version (recommended: bump to 0.28.x for the fluent client API; the bump is shared with the existing `dsh-acp` bridge, so re-run its snapshot + e2e). + +## Risks and deferrals + +- **Recursion.** Without a guard, an in-process child inherits the spawn tool and can spawn unboundedly. Depth-limit is an optional capability (the in-process backends enforce it; ACP advertises it off and rejects a `maxDepth` request); tool-filtering is likewise optional. Tool-filtering, when implemented, needs a `tools/execute` veto in the child context — schema filtering alone is insufficient because a model can hallucinate a denied tool name. +- **Blocking the parent turn.** Synchronous collect holds the parent's `runStep` open for the child's full duration. This is acceptable for the first cut; **background / poll / spill semantics are deferred to a future redesign that unifies long-running-tool handling across subagents AND bash** (a sub-agent and a long `bash` background task pose the same "the model started something slow, how does it collect later" problem, and should share one mechanism rather than each inventing its own). +- **Live progress.** This cut surfaces only lifecycle + final result; a per-chunk child→parent update stream is deferred with the background redesign. +- **ACP client surface.** Proxying `fs`/`terminal` from the ACP child back to the parent (a shared-workspace mode) is future work; the first cut advertises neither, so the child self-serves in its own process. +- **Snapshot coverage of nested agents.** The snapshot tier (`pnpm run test:snapshot`) replays a recorded session through `dsh-llm-replay`. It was built single-session: a single GLOBAL positional cursor (the Nth `llm/stream` call serves the Nth recorded entry) and a harness that harvested a single session log file. A subagent runs as a *second* agent with its own session log, so a parent→child scenario needed per-session-keyed replay plus harvest-all-logs and plural-session-id plumbing — self-contained infrastructure orthogonal to the backends, scheduled as a dedicated stacked follow-up rather than folded into the in-process-backends PR. That follow-up has **landed**: see [Per-session snapshot replay for nested agents](../../implemented/testing/2026-06-22-subagent-snapshot-replay.md). Replay now keys each call by its calling session (`GenerateOptions.sessionId`) and binds live sessions to recorded scripts by first-call order; the harness harvests every log; and two nested scenarios (`subagent-spawn`, `subagent-multi`) replay keyless in the default gate. In-process subagents remain covered by real-loop unit tests and a with-key e2e in addition to the snapshot tier. diff --git a/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md new file mode 100644 index 0000000000..6be0f6746b --- /dev/null +++ b/docs/rfc/implemented/feature/2026-06-22-acp-subagent-backend.md @@ -0,0 +1,47 @@ +# RFC: ACP subagent backend (out-of-process delegation) + +Status: implemented + +## Problem + +The subagent seam ([the seam RFC](2026-06-21-subagent-capability-seam.md)) was built so multiple backends coexist by name on `ctx.subagents`. The in-process backends (`-spawn`/`-fork`) run a child as a second `Agent` on the SAME cordis context — cheap, but the child shares the parent's process, model client, and tools. The seam's whole point was to also support an OUT-OF-PROCESS child reached over a protocol, proving the abstraction generalizes across a process boundary. This RFC adds the first such backend: an Agent Client Protocol (ACP) client. + +## Decision + +`@deepseek-ai/dsh-subagent-acp` registers a `SubagentProvider` that runs each child agent in a SPAWNED SUBPROCESS, driven over ACP as the *client*. It is the direction-inverted twin of the existing server-side bridge `@deepseek-ai/dsh-acp` (the ACP *agent*): the bridge ANSWERS `initialize`/`newSession`/`prompt`; this backend CALLS them and IMPLEMENTS the `Client` callbacks (`sessionUpdate`, `requestPermission`). Pointing the configured spawn command at the `acp-agent` example makes the harness talk to its own process. + +### Fresh process per run + +Each `start` spawns a new child, runs exactly one ACP session (`initialize` → `newSession` → `prompt`), and `dispose` kills the subprocess and awaits its exit. This is the simplest lifecycle and mirrors the in-process one-child-per-run shape. Persistent-process pooling (reuse a warm child across runs) is a performance optimization deferred to future work — it adds session-lifecycle and crash-recovery complexity the first cut does not need. + +### Minimal client stub + +The client advertises NO optional capabilities (no `fs`, no `terminal`): the child self-serves file/terminal access in its own process. `session/update` notifications are consumed — the backend accumulates `agent_message_chunk` text as the result output and ignores the rest (thoughts, tool-call cards) in this cut, which surfaces only the child's final answer. `session/request_permission` is auto-answered by a configured policy (`reject` declines every prompt, `allow` approves via the first allow-shaped option) — the first cut surfaces no prompt to a human. Proxying `fs`/`terminal` back to the parent (a shared-workspace mode) remains future work, as the seam RFC noted. + +### No start-time capabilities + +The provider's `capabilities` are all `false`. An out-of-process child cannot honor the parent's `maxDepth` (it has no access to `parent.options.subagentDepth`) or `toolFilter` (it owns its own tool registry), and the first cut does not implement `outputSchema`. The service rejects a request needing any of them before `start` runs. The backend injects only `subagents` (not `ctx.agents`) and ignores `request.parent`. + +### StopReason mapping + +ACP `StopReason` → harness `SubagentStopReason`: `end_turn`→`completed`, `max_tokens`→`max-tokens`, `refusal`→`refusal`, `cancelled`→`aborted`, `max_turn_requests`→`error` (no clean equivalent — the task did not finish), unknown→`error`. A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested); `result` never rejects on a child-level failure, per the seam contract. + +### SDK version: stayed on 0.25.1 + +The plan proposed bumping `@agentclientprotocol/sdk` 0.25.1 → 0.28.x for the new fluent `acp.client()` / `ActiveSession.nextUpdate()` API. Validating that against the code (the AGENTS.md "RFC is a proposal, not golden truth" discipline) reversed the decision: the backend only needs `ClientSideConnection` + `ndJsonStream` + `PROTOCOL_VERSION` + the `Client`/`Agent`/`StopReason` types, **all present and non-deprecated in 0.25.1**. The fluent API and `unstable_forkSession` that motivated the bump are never used here, so the "cleaner client code" benefit did not materialize. Worse, 0.28.x **deprecates both** `ClientSideConnection` AND `AgentSideConnection` (it wants all callers on the fluent builders), which turns the `no-deprecated` lint red across the entire existing ACP layer — 33 usages including the server bridge this PR has no business rewriting. That cross-cutting connection-API migration is its own PR, not baggage for "add an ACP subagent backend". So the bump was reverted and the backend is written against 0.25.1 (the plan's own fallback clause: "if the bump proves disruptive, fall back to `ClientSideConnection` (0.25.1), which is sufficient"). Migrating the whole ACP layer to the fluent API on a later 0.28.x bump is a worthwhile standalone follow-up. + +### Security: scrubbed child environment + +The child is a separate process, so it inherits an environment. Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded by default — the parent harness's own secrets must not leak into a spawned process implicitly (the same policy the bash executor applies). The child's OWN credentials (it needs a model key) are supplied EXPLICITLY via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. Child stderr is inherited to the parent's stderr (diagnostics surface naturally); a spawn-level `error` event (e.g. ENOENT for a bad command) is captured and raced against the ACP drive, so a bad command settles `error` instead of crashing the parent with an unhandled error. + +## Testing + +Designed at every tier the backend touches, per the AGENTS.md "design test infrastructure up front" rule: + +- **Keyless unit/integration** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio. Covers: the prompt round-trip + output accumulation; every StopReason mapping; cancellation via `run.cancel()` and via the request signal; the already-aborted-before-start case; the cancel-races-ahead-of-newSession case; a torn-pipe-after-cancel (child crashes on cancel) settling `aborted`; permission auto-answer under both policies (including the allow-policy-no-allow-option fallback); a non-message update consumed but not accumulated; a nonexistent-command spawn failure settling `error`; HMR provider cleanup; and the namespace export shape. 100% per-file coverage. +- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt (PONG) and does real file work (writes `proof.txt`, verified on disk). Self-skips without `DEEPSEEK_API_KEY`. This is the "talk to our own process" smoke and the out-of-process analogue of the in-process spawn e2e. +- **Snapshot**: deferred as `TODO(acp-subagent-replay)`. An ACP child is a distinct replay shape — each child is its own PROCESS with its own single-agent replay (booted under `DSH_SNAPSHOT=replay` with its own sessions-root + fixture), unlike the in-process per-session keying that [PR2.5](../testing/2026-06-22-subagent-snapshot-replay.md) added. The keyless mock-server tests give deterministic coverage of the backend in the meantime; the snapshot follow-up would record the parent driving a real-but-replayed ACP child. + +## Future providers + +The same out-of-process spawn/prompt/stream/cancel shape generalizes to other transports named in the seam RFC — A2A, the Codex app-server, and the Claude Code Agent SDK — each a sibling provider registered by name. The ACP backend is the proof that the seam supports the boundary; those are mechanically similar. diff --git a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md index 10de466ea3..69fca053f3 100644 --- a/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md +++ b/docs/rfc/implemented/process/2026-06-11-doc-sync-enforcement.md @@ -12,10 +12,10 @@ AGENTS.md promises that docs and code stay strictly in sync, but the promise was Two gates, mirroring the existing `scripts/` style (tsx ESM, one job each): -1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project, and compiles with `tsc --noEmit`. The temp tsconfig copies only resolution-relevant options and the workspace `paths` map from `tsconfig.typecheck.json` (vendor → built `lib`, harness → `src`) — resolving vendor to `lib` is essential, or tsc type-checks raw vendor source and floods the run. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. +1. **`doc-typecheck`** extracts every fenced ` ```ts ` block from `README.md`, `docs/**`, and `packages/*/README.md`, writes them to a temp project extending the root `tsconfig.json`, and compiles it with `tsc -b`. The temp project reuses the source `paths` map and the root project references, so documentation examples see source while vendored code remains checked under its own tsconfig settings. A block that is a deliberate sketch opts out with an explicit ` ```ts ignore-check ` info string; the script reports the opt-out ratio and fails if it exceeds half, so the escape hatch can't quietly become the norm. 2. **`verify-event-taxonomy`** extracts the event names from the `interface Events` blocks across `packages/*/src` and from the taxonomy table in `docs/architecture.md`, and asserts the two sets match exactly. Verify, don't generate: the table keeps its hand-written Mode/Purpose columns; only the set of names is checked. (Landing this surfaced three events the table had been missing — `tools/change`, `llm/adapter-change`, `system-prompt/change`.) **Superseded** by [the generated cordis catalog](2026-06-20-generated-cordis-catalog.md): this gate and its `architecture.md` table are retired in favor of a fully-generated `docs/cordis-catalog/events-and-services.md` and its `verify-cordis-catalog` freshness gate. The other gates here (`doc-typecheck`, and the `verify-md-wrap` amendment below) are unaffected. -Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck` (which emits the vendor `lib/` that doc-typecheck resolves against). API-extractor golden reports ([the deferred API-extractor-reports proposal](../../proposed/process/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. +Both run via a shared `doc-sync` package.json script that the lefthook pre-push hook and CI both invoke ([mechanical quality gates](2026-06-11-quality-gates.md): hooks and CI call the same scripts, so the gate fires locally before a push — not only after it). They run after `pnpm run typecheck`, which validates the package/vendor build graph that doc-typecheck references. API-extractor golden reports ([the deferred API-extractor-reports proposal](../../proposed/process/2026-06-11-api-extractor-reports.md)) were deliberately **deferred** — low value for an internal monorepo where reviewers already see the source diff, and a heavy, finicky dependency. **Amendment (2026-06-17):** a third gate, **`verify-md-wrap`**, was later folded into `doc-sync`. It parses each in-scope Markdown file (`README.md`, `docs/**`, `packages/*/README.md`, plus `AGENTS.md` / `packages/AGENTS.md`) with `mdast-util-from-markdown` + GFM and fails on any `paragraph` node spanning more than one source line, enforcing the AGENTS.md "Markdown is not hard-wrapped" convention. Same verify-don't-generate principle: it reports hard-wraps and never rewrites, so it adds no formatting churn. `doc-sync` is now three gates. diff --git a/docs/rfc/implemented/process/2026-06-11-quality-gates.md b/docs/rfc/implemented/process/2026-06-11-quality-gates.md index 0e3a3441ac..277a56fa2b 100644 --- a/docs/rfc/implemented/process/2026-06-11-quality-gates.md +++ b/docs/rfc/implemented/process/2026-06-11-quality-gates.md @@ -12,10 +12,10 @@ This codebase is developed primarily by coding agents. Agents follow enforced ga Every AGENTS.md promise gets a command that exits non-zero, wired into git hooks and CI both calling the same package.json scripts: -- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); tests and examples typecheck in CI via `tsconfig.typecheck.json` (vendored packages resolve as built declarations). +- Max-strict TypeScript (`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, …); examples, tests, and scripts typecheck in CI via the root no-emit `tsconfig.json` while package/vendor code stays behind its own project-reference boundary. - ESLint strict-type-checked + @stylistic (the house style, enforced); vendored code excluded. - Per-file 100% coverage on `packages/*/src` (v8); unreachable defensive guards carry `/* v8 ignore */ ` with stated reasons instead of deletion. -- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM). +- knip (dead code/deps), publint (package correctness), workspace constraints (workspace rules: private, cordis peer+dev, uniform version, ESM), and a NodeNext consumer typecheck for built package declarations. - lefthook pre-commit (lint staged, typecheck, vendor-manifest guard) and pre-push (tests, hygiene); CI runs the full matrix on node 24/26 plus a demo smoke test driving the echo-agent end to end. ## Consequences diff --git a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md index 283b236397..dc028d7e9b 100644 --- a/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md +++ b/docs/rfc/implemented/process/2026-06-11-tsdown-over-dumble.md @@ -15,12 +15,12 @@ Build output currently matters only for `pnpm run build` + publint (nothing publ Replace dumble with **tsdown** (rolldown-based, ~2.5M downloads/week, VoidZero-backed, actively released): - Root `tsdown.config.ts` with `workspace: ['vendor/*', 'packages/*']` (explicit globs, not `workspace: true`, which would also pick up `examples/*` — they have package.json files but are not pnpm workspaces). -- Shared shape: entry `src/index.ts`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ holds tsc's .d.ts output). +- Shared shape: entry `lib/types/index.js`, `outDir: 'lib'`, ESM, `platform: node`, `target: es2024`, `fixedExtension: false` (keeps `.js` for `"type": "module"` packages), `dts: false` (tsc -b owns declarations), `clean: false` (lib/ also holds TSC's `lib/types` intermediate tree). The entry was originally `src/index.ts`; the [TSC-first build RFC](2026-06-17-ts-build-config.md) later moved tsdown to bundling TSC-emitted JS so TypeScript transform behavior comes from one compiler. - Two per-package overrides in vendor/ (ours, like the regenerated tsconfigs; logged in vendor/README.md): schemastery (dual `.mjs`/`.cjs` via `outExtensions`), logger-console (two single-entry passes so the shared base class is inlined into each entry instead of a hash-named chunk, matching upstream's published shape). -- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b && tsdown`. +- `scripts/build.ts` deleted; `pnpm run build` = `tsc -b tsconfig.build.json && tsdown`. Alternatives considered: **direct esbuild script** (most established engine, zero wrapper risk, but hand-maintains the per-package spec table tsdown's workspace mode gives us); **pkgroll** (closest drop-in philosophically, but 78k dl/wk and Rollup-based — strictly weaker maintenance story than tsdown); **keep dumble** (perfect upstream alignment, unacceptable bus factor). ## Consequences -Output file lists are byte-for-byte-list identical to dumble's (verified by snapshot diff at migration time); externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new RFC. +Runtime bundle outputs still follow the dumble-era public entry shape (`lib/index.js`, plus package-specific variants such as `schemastery`'s `lib/index.mjs`/`lib/index.cjs` and `logger-console`'s `lib/browser.js`); declarations now live under `lib/types` per the [TSC-first build RFC](2026-06-17-ts-build-config.md). Externals still come from each package's dependencies/peerDependencies. We give up dumble's exports-field inference — new packages with non-default shapes need a per-package `tsdown.config.ts` instead of just package.json fields. Future option: tsdown could also absorb declaration bundling (isolatedDeclarations) if `tsc -b` ever becomes the bottleneck; that would be a new RFC. diff --git a/docs/rfc/implemented/process/2026-06-17-ts-build-config.md b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md new file mode 100644 index 0000000000..a45962c70e --- /dev/null +++ b/docs/rfc/implemented/process/2026-06-17-ts-build-config.md @@ -0,0 +1,73 @@ +# RFC: TSC-first build and one tsconfig + +Status: implemented (accepted 2026-06-20) + + + +## Context + +The current TypeScript build and typecheck setup had these issues: + +- `build` used `tsc` to transform `.ts` to `.d.ts` files for packages under `packages//` and `vendor/*`, and then used `tsdown` to transform `.ts` to bundled `.js` files. This made two tools do TypeScript transform. +- `typecheck` tended to validate packages, vendor source, examples, tests, and scripts through one root typecheck config. + +The goal is to make build and typecheck use matching tsconfig boundaries and TypeScript resolution/transform behavior. Build should generate `.js`, `.d.ts`, `.js.map`, and `.d.ts.map` through one compiler and config, so publish output and type validation stay consistent. + +Validation found several concrete technical issues and possible routes: + +- `tsdown` uses `oxc` to transform TypeScript, which is not the same behavior as `tsc`. + - Bundled `.d.ts` emitted by `tsdown` conflicts with Cordis' internal relative module augmentation shape. + - The tsc output is affected by `allowImportingTsExtensions`, so we need to ensure that generated `.js` files do not import `.ts` files and generated `.d.ts` files keep explicit relative specifiers that NodeNext/Node16 accepts. Therefore, in-package relative imports use explicit `.ts` specifiers in TypeScript source and `rewriteRelativeImportExtensions` rewrites those specifiers to `.js` in emitted JS. + - Bundled `.js` emitted by `tsdown` is not the same behavior as per-file `.js` emitted by `tsc -b`, such as decorator transform behavior. +- `vendor/*/src`, examples, tests, and scripts cannot all be plain-included in one root strict program. + - Directly typechecking `vendor/*/src` under the root strict config triggers many type errors outside this project's ownership. + - Package dependencies under `packages/*/*` on `vendor` are resolved to the `vendor/*/lib` for different tsconfig strictness. + + +## Decision + +In-package relative imports use explicit `.ts` specifiers. + +`pnpm run build` is a two-stage build: + +- Stage 1: `tsc -b tsconfig.build.json` emits per-module `.js`, declarations `.d.ts`, JS sourcemaps `.js.map`, and declaration sourcemaps `.d.ts.map` into each package's `lib/types`. This is the authoritative TypeScript compilation result. For publish we keep `.d.ts` / `.d.ts.map` and ignore `.js` / `.js.map`. + - The build project uses the project-reference graph that `tsc -b` compiles. For example, root `tsconfig.build.json` references package and vendor tsconfigs. It validates and emits package/vendor build results. +- Stage 2: a bundler reads the emitted JS under `lib/types` and writes the bundled runtime entry as `lib/index.js` or `lib/index.mjs` (follow current behavior). This stage is bundling only. It must not read TypeScript source or emit declarations. + +`tsdown` is no longer the owner of TypeScript compilation or declaration output. + +`pnpm run typecheck` runs build mode over the root `tsconfig.json`. +- The root `tsconfig.json` is the single development/typecheck project. It typechecks examples, tests, and scripts with `noEmit`, and validates package/vendor source through references. +- Referenced package/vendor projects keep the same emit behavior as build, so typecheck can refresh their `lib/types` outputs instead of using a separate no-emit graph. Project-specific strictness changes live in the owning `packages/*/*/tsconfig.json` or `vendor/*/tsconfig.json`. +- The root no-emit project disables `rewriteRelativeImportExtensions`; it emits nothing and includes tests that import helpers across project-reference boundaries. Package/vendor emit projects keep the rewrite enabled. + +The command orchestration shape is: + +```sh +pnpm run build: +tsc -b tsconfig.build.json +tsdown + +pnpm run verify-node-next-types: +tsx scripts/verify-node-next-types.ts + +pnpm run typecheck: +tsc -b tsconfig.json +``` + +`pnpm run demo:*` still runs `src` directly through tsx and root paths, without a compile step. + +## Consequences + +Build responsibilities are clearer: + +- Each module under `packages//` and `vendor/*` has one local tsconfig for build, typecheck, and tools that run source directly, such as `tsx` and `vitest`. +- The `build` command uses `tsconfig.build.json`. `tsc -b` owns the publishable per-module `.js` and `.d.ts` output, and the bundler owns only `lib/index.*`. + - `lib/types/*.d.ts` and `.d.ts.map` are the publish declaration output. + - `lib/types/*.d.ts` uses explicit `.ts` relative specifiers, which TypeScript's NodeNext/Node16 resolver maps to sibling `.d.ts` files. + - `lib/types/*.js` is only a bundler input and must not be used as a runtime entry or public import target. + - `lib/index.*` is the publish runtime output and is generated by the bundler, currently `tsdown`. +- `pnpm run verify-node-next-types` scans built declarations for relative specifiers without file extensions, then typechecks a temporary external ESM consumer with `moduleResolution: "NodeNext"` against the built `types`/`exports` surface, so declaration specifier regressions fail before publish. +- The `typecheck` command uses `tsconfig.json`. Examples, tests, and scripts are checked by the root no-emit project, while packages and vendor modules keep the same emit behavior as `build`. Package and vendor source stays behind project-reference boundaries. + +The Cordis vendor copy now has one more type-structure divergence from upstream. During upstream sync, that divergence must be reapplied or explicitly retired. diff --git a/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md new file mode 100644 index 0000000000..a45e62639b --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-child-replay-seed-boundary.md @@ -0,0 +1,47 @@ +# RFC: Persist the seed boundary so fork-child replay routes correctly + +Status: implemented + +## Problem + +The [per-session snapshot replay RFC](2026-06-22-subagent-snapshot-replay.md) made the snapshot tier express a nested-agent shape: a parent plus one recorded log per in-process subagent, each replayed as its own script keyed by calling session. It noted (§ Scope, final bullet) that a fork snapshot was "a trivial future addition, not a gap in the keying." That was wrong about a fork child specifically — not the keying, but the *script derivation*. + +A subagent script is derived from a recorded session log by [`deriveReplayScript`](../../../../packages/support/llm-replay): it groups the log's `assistant/chunk` events by `(turn, step)` into one replay entry per `stream()` call. This is correct for a **spawn** child, whose log contains only its own model calls. + +A **fork** child is different. The fork backend seeds the child session with a *balanced completed-turn prefix of the parent's log* ([`dsh-subagent-inprocess`](../../../../packages/subagent/subagent-inprocess)), and that seed becomes the child session's persisted `log` (`Session`'s constructor copies the seed into `this.log`). So a fork child's `.jsonl` begins with the **parent's** events — including the parent's `assistant/chunk` events — and only then carries the child's own turn. + +Deriving the child script from the whole fork-child log therefore replays the **parent's** recorded responses as the **child's** model calls: the live fork child's first `stream()` would receive the parent's first recorded chunk sequence instead of its own. The recorded scenarios are all spawn today, so this never fired — but a fork snapshot would have mis-routed silently, exactly the class of bug the snapshot tier exists to catch. + +## Decision + +Record where a session's **inherited** prefix ends, persist it, and have the replay harness derive a child's script from its **own** events only. + +### 1. `seedLength` on the session header + +`SessionHeader` gains an optional `seedLength: number` — how many leading events were inherited via a seed rather than produced by this session. The fork backend stamps it (= the seeded-prefix length) when it creates the child; a fresh spawn leaves it absent (≡ 0). It is threaded through `CreateSessionOptions.meta` (and `CreateAgentOptions.meta`), set in `SessionStore.prepare`. + +`seedLength` is **explicit**, never inferred from `seed.length`. A reconstruction (resume/load) seeds the session with its WHOLE stored log, so `seed.length` there is the full length, not the original boundary — the resume path passes the persisted `seedLength` back from the loaded header instead. (Same shape as `createdAt`, which is also explicitly preserved on reconstruction rather than re-defaulted to now.) + +### 2. Both persistence backends round-trip it + +- **JSONL**: a `seedLength` field on the header line (`toHeaderLine`/`fromHeaderLine`). +- **SQLite**: a `seed_length` column on the `sessions` table. + +The SQLite change is a breaking table-layout change, so `SCHEMA_VERSION` bumps **2 → 3**. Per the repo's pre-release stance (§ "Pre-release stance" in AGENTS.md) the backend **rejects** a non-current `user_version` on open rather than migrating it — there is no persisted user data to preserve, so no migration code is written (the existing reject-not-migrate path at `openDatabase` already enforces this; v1 and now v2 are both rejected). + +### 3. Replay derives a child script after the boundary + +`dsh-llm-replay`'s `parseSessionHeader` now also reads `seedLength` (absent ⇒ 0), and `loadSessionScripts` derives a child's entries from `parseSessionLog(text).slice(seedLength)` — the events at or after the boundary, i.e. the child's own model calls. For a spawn child `seedLength` is 0 and this is a no-op, so spawn scenarios are byte-for-byte unchanged. + +This closes the routing correctness gap, and two recorded fork scenarios exercise it end to end — see [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md). + +## Alternatives considered + +- **Derive the boundary heuristically in `llm-replay`** (the seeded prefix is contiguous parent events ending at the last `turn/end` before the child's first `user/message`). Rejected: a brittle heuristic in the test harness that re-derives a fact the producer already knows. Persisting the boundary at its source (the fork backend) is the "explicit > implicit at package seams" rule applied across the persistence boundary — the reader of a child fixture never has to reconstruct where the inheritance ended. +- **Pin the format version instead of bumping** (the `SESSION_FORMAT_VERSION = 0` "unstable" stance the event log uses). Rejected for the SQLite *table* layout: `SCHEMA_VERSION` is the monotonic bump-and-reject knob (a small enumerable set of revisions worth telling apart), distinct from the event-vocabulary `version`. Adding a column is precisely the breaking table change it versions, so it bumps. + +## Consequences + +- A new persisted header field across core + both backends; the core-data-structures catalog (`persistence.md`) is updated in the same change (its `SessionHeader` / `CreateSessionOptions` `type-equiv` blocks). +- Existing SQLite databases at schema v2 are rejected on open (no user data pre-release). +- Spawn replay is unchanged (`seedLength` 0). Fork replay now routes a child to its own script; covered by a regression in `llm-replay`'s tests (a child fixture whose seeded prefix carries a parent chunk — the derived child script must exclude it, proven red without the slice) and a persistence round-trip test (both backends, via the shared coordinator contract). 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 new file mode 100644 index 0000000000..2ad44e2040 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-fork-snapshot-scenarios.md @@ -0,0 +1,27 @@ +# RFC: Record fork and mixed spawn+fork snapshot scenarios + +Status: implemented + +## Problem + +The [seed-boundary RFC](2026-06-22-fork-child-replay-seed-boundary.md) made fork-child replay route correctly: `dsh-llm-replay` derives a child's script from the events at or after its persisted `seedLength` boundary, so a fork child's inherited parent prefix is not replayed as the child's own model calls. But it shipped with **no recorded fork scenario** — the slice was exercised only by `llm-replay`'s unit tests (a synthetic child fixture) and a persistence round-trip test. The full-transcript snapshot tier, the one net that boots the real `acp-agent` and replays an end-to-end nested transcript, had only spawn children (`subagent-spawn`, `subagent-multi`). A fork-routing regression that left the unit tests green would still have escaped the tier built to catch transcript regressions. + +The snapshot infrastructure to express a fork scenario was already in place — both in-process backends are wired into `cordis.yml` / `cordis.snapshot.yml` as two model-facing tools (`subagent` → spawn, `subagent_fork` → fork), the harness harvests every child log, and replay forwards per-child fixtures keyed by `seedLength`. What was missing was a *recorded scenario* that drives a fork child through it. + +## Decision + +Record two scenarios against the real API, both replayed keyless in the default gate: + +- **`subagent-fork`** — the parent completes a turn that establishes a fact, then delegates one subtask via `subagent_fork`. The fork child inherits the conversation (its log carries a non-zero `seedLength`), so it can answer from the parent's context. This is the focused regression: the child fixture's `seedLength` is the boundary the replay slice depends on, recorded from a real fork rather than hand-synthesized. +- **`subagent-mixed`** — the parent completes a turn, then delegates once via `subagent` (a fresh spawn child, `seedLength` 0) and once via `subagent_fork` (a fork child, non-zero `seedLength`) in one transcript. This is the mixed spawn+fork scenario the seed-boundary and per-session-replay RFCs both named as a future addition: one transcript exercises both transports and both branches of the slice (`seedLength` 0 = no-op, `seedLength > 0` = trim the inherited prefix), with the two children ordered spawn-then-fork by `createdAt`. + +### 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. + +## Consequences + +- The fork-routing slice is now guarded at the full-transcript tier, not just by unit tests. Removing the `slice(seedLength)` (replaying the whole child log) turns **both** new scenarios red — the fork child receives the parent's recorded chunks instead of its own — proving the guard bites (verified red→green when the scenarios landed). +- `subagent-mixed` is the first snapshot scenario to drive two *different* subagent backends in one transcript, exercising the per-session replay keying across a spawn and a fork child simultaneously. +- Out-of-process (ACP) subagent replay remains a different shape (each child is its own process with its own replay) and is still tracked as `TODO(acp-subagent-replay)` — these scenarios are in-process only. +- Re-recording (`pnpm run test:snapshot:record`) regenerates all four fork/spawn fixtures from the live API; the two new scenarios self-skip without a key like every recorded scenario. diff --git a/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md new file mode 100644 index 0000000000..e72175e544 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-06-22-subagent-snapshot-replay.md @@ -0,0 +1,54 @@ +# RFC: Per-session snapshot replay for nested agents + +Status: implemented + +## Problem + +The snapshot tier (`pnpm run test:snapshot`) boots the real `acp-agent` subprocess, replays a recorded session through [`dsh-llm-replay`](../../../../packages/support/llm-replay), and diffs the normalized stdout transcript + re-persisted session log against committed goldens. It is the only tier that exercises the full editor-facing transcript end to end. + +It was built for ONE session per process, and that assumption is wired into two places: + +- **`dsh-llm-replay` keyed nothing.** It served the Nth `llm/stream` call the Nth recorded entry from a single global cursor. With a parent agent AND an in-process subagent both streaming on one context, the calls interleave and the single cursor hands the child the parent's script (and vice versa). +- **The harness harvested one log.** `findSessionLog` walked the sessions root and returned the FIRST `.jsonl` it found. A subagent runs as a second `Session` with its own log in the same cwd bucket, so the child's transcript was silently dropped. + +This was the `TODO(subagent-snapshots)` deferral recorded in the [subagent seam RFC](../../implemented/feature/2026-06-21-subagent-capability-seam.md): the in-process backends (PR2) shipped with unit + e2e coverage, but the full-transcript snapshot tier could not express a nested-agent shape until this infrastructure landed. This RFC is that stacked follow-up. + +## Decision + +Replay is keyed **per calling session**, and the harness harvests **every** session log. + +### 1. The calling session id rides on the model request + +`GenerateOptions` gains an optional `sessionId`, stamped by the agent loop from `agent.session.id` at request-assembly time (where the session is already in scope). Adapters ignore it; it exists so an `llm/stream` listener can route a call by WHICH session issued it. It is typed `Branded<'SessionId'>` (from `dsh-brand`) rather than importing `SessionId` from `dsh-session` — that package imports `Message` from `dsh-llm`, so importing its id back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a real id assigns with no cast. (A future dedicated ids package could own the brand and dissolve the note; tracked separately — it touches every id import and does not belong in this testing PR.) + +### 2. Replay binds live sessions to recorded scripts by first-call order + +A nested scenario records more than one log: the parent (`session.jsonl`) plus one per subagent child (`session.1.jsonl`, …). `dsh-llm-replay` loads them all, derives one script per recorded session, and orders the scripts by header `createdAt` (the parent is created before its children). + +Live session ids are freshly random every run and never equal the recorded ones, so a live session cannot bind to a script by id equality. Instead it binds by **first-call order**: the first live session to make any model call claims the first ordered script (the parent — earliest `createdAt`, and necessarily the first to stream, because it must run a turn before it can delegate), the next new live session claims the next script, and so on. Each session then advances its own cursor independently. + +This keys by WHO calls, not by global call order — so it stays correct even if subagents ever run concurrently or in the background (a global cursor would interleave them). A call carrying no `sessionId` (a direct unit-test `stream()`) is treated as one anonymous session bound to the primary script, so the single-session path is byte-for-byte the old behavior. More distinct live sessions than recorded scripts is a fail-loud error (an unrecorded subagent appeared), never a silent mis-route. + +The ordering key is the session header `createdAt`. In the current synchronous cut this is sound because sibling children are created **strictly sequentially** — the subagent tool awaits one child's result and disposes it before the parent's next tool call starts the next child — so their `createdAt` values are strictly ordered and match first-call order exactly. A same-millisecond sibling tie is therefore unreachable; the `recordedId` tiebreak only keeps such a degenerate collision deterministic, it does not recover first-call order. A future cut that runs siblings concurrently/backgrounded WOULD be able to create two children in the same millisecond, and must then thread a real first-call ordinal (the order live sessions first stream) rather than leaning on `createdAt` — flagged with `XXX(concurrent-subagents)` at the sort site. + +The alternative considered and rejected was a **call-ordered merge of the parent and child logs** into one global script (sound only because in-process subagent execution is strictly nested — the parent blocks on the child). It is simpler for today's synchronous cut but bakes in the parent-blocks-on-child invariant that a future backgrounded/concurrent subagent would break; per-session keying does not. + +### 3. The harness harvests every log, primary-first + +`harvestSessionLogs` collects every `.jsonl` across every cwd bucket under the sessions root (the JSONL backend puts a parent and its same-cwd child in the same bucket), parses each header, and orders them primary-first: the top-level session (no `parentSession`) leads, then each child by ascending `createdAt`. `RunResult.sessionLogs` is the plural result; the spec writes each back to its fixture on record (`session.jsonl` + `session..jsonl`) and diffs each harvested log against its fixture on replay. The normalizer already accepted plural session ids and collapses any stray UUID, so no normalizer change was needed. + +### 4. Scenarios + +Two nested scenarios were added and recorded against the real API: + +- **`subagent-spawn`** — the parent delegates one subtask via the `subagent` tool to a fresh spawn child (2 sessions). +- **`subagent-multi`** — the parent delegates two subtasks, each to its own spawn child (3 sessions), stressing the per-session keying with three concurrent scripts and the `createdAt` ordering of two children under one parent. + +Both replay keyless in the default gate. + +## Consequences + +- The `TODO(subagent-snapshots)` deferral is resolved: nested-agent transcripts are now a first-class snapshot shape. +- `GenerateOptions.sessionId` is a small, honest core-seam addition useful beyond replay (telemetry, request routing). +- The `subagent` tool is bound to a single provider, so both children in `subagent-multi` are spawn (fresh). The keying routes by session, not by backend, so it is already correct for fork. The script *derivation* was not: a fork child's log begins with the seeded parent prefix (the parent's `assistant/chunk` events), so deriving its script from the whole log would replay the parent's responses as the child's. That correctness gap is closed by persisting a seed boundary — see [Persist the seed boundary so fork-child replay routes correctly](2026-06-22-fork-child-replay-seed-boundary.md) — and recorded fork + mixed spawn+fork scenarios now exercise both transports through one transcript (see [Record fork and mixed spawn+fork snapshot scenarios](2026-06-22-fork-snapshot-scenarios.md)). +- Out-of-process (ACP) subagents are a different replay shape entirely (each child is its own PROCESS with its own replay), tracked as `TODO(acp-subagent-replay)` in the PR3 plan. 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 19bde62d01..cc0db091dc 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 @@ -46,7 +46,7 @@ The genuine risks of collapsing the two ids into one (the case AGAINST this prop - **It forecloses a one-agent-resumes-many-sessions / one-session-driven-by-many-agents future.** Today the separate ids leave room for an agent (a stable actor) to detach from one session and attach to another, or for a handoff where a new agent process adopts an existing session under a new actor handle. Unifying makes "agent" and "session" the same lifetime, so any such future needs a NEW seam (e.g. an explicit `actorId` distinct from the session) — re-introducing the very separation we removed. We judge this generality currently unused, but it is a door this change closes. -- **Sub-agents / fork / spawn (an explicitly deferred seam) may WANT a stable actor id across forked sessions.** `AgentLoop.create`'s `TODO(sub-agents)` envisions a child agent seeded from a parent's event log. If the design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. +- **Subagents / fork / spawn may WANT a stable actor id across forked sessions.** The [subagent seam](../../implemented/feature/2026-06-21-subagent-capability-seam.md) runs a child agent seeded from a parent's event log (fork). If a future design wants "the same agent identity across a fork" (parent and child share an actor but have distinct session logs), a unified id blocks it. The implementing PR must check the intended fork/spawn model BEFORE unifying, or accept that fork always mints a fresh combined id. (As shipped, each subagent child mints its own distinct agent id — `parentSession` records lineage — so the seam does not currently rely on a shared actor id, but unifying would foreclose adding one.) - **The config-driven resume-or-create policy becomes load-bearing, not cosmetic.** Today the per-run-uuid session id quietly sidesteps the "a fixed id collides with its own on-disk log on the second run" problem. Once the id is unified and stable, a config agent restarting MUST decide resume-vs-fresh deliberately — there is no longer a throwaway session id to hide behind. Getting this wrong reintroduces the create-collision the uuid was avoiding (a durable backend refuses to re-create an id whose log exists). This is the one real design decision the implementing PR owns, and it is easy to get subtly wrong. diff --git a/eslint.config.mjs b/eslint.config.mjs index d4763d8377..52236e5d64 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,7 +36,7 @@ export default tseslint.config( ], languageOptions: { parserOptions: { - project: ['./tsconfig.typecheck.json'], + project: ['./packages/*/*/tsconfig.json', './tsconfig.json'], tsconfigRootDir: import.meta.dirname, }, }, @@ -81,13 +81,13 @@ export default tseslint.config( // --- tests: same rules, minus the friction that fights test ergonomics -- { - files: ['packages/*/*/tests/**/*.ts'], + files: ['packages/*/*/tests/**/*.ts', 'examples/*/tests/**/*.ts'], extends: [ ...tseslint.configs.strictTypeChecked, ], languageOptions: { parserOptions: { - project: ['./tsconfig.typecheck.json'], + project: ['./tsconfig.json'], tsconfigRootDir: import.meta.dirname, }, }, diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index b57920668a..5f36a11efa 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -31,9 +31,42 @@ systemPrompt: | You are a coding assistant driven over the Agent Client Protocol. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit with - sed or a rewrite. Each bash call runs in a fresh shell — pass workdir - instead of cd. Check the [exit code: N] marker; verify your work. Keep - answers brief and factual. + Your tools are bash (plus bash_output/bash_kill for background tasks) + and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd. Check the [exit code: N] marker; + verify your work. Keep answers brief and factual. + + Use the subagent tool to delegate a focused, self-contained subtask to + a fresh child agent (it works in its own context and returns only its + final result) — give it a complete, standalone instruction. + +# The subagent seam + both in-process backends + two model-facing tools — +# identical to cordis.yml's wiring (only the LLM backend differs above): spawn +# and fork are each reachable via a dsh-tool-subagent bound to it with a distinct +# toolName (subagent → spawn, subagent_fork → fork). +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork diff --git a/examples/acp-agent/cordis.yml b/examples/acp-agent/cordis.yml index e456e8ff05..a00d0e6036 100644 --- a/examples/acp-agent/cordis.yml +++ b/examples/acp-agent/cordis.yml @@ -40,9 +40,44 @@ systemPrompt: | You are a coding assistant driven over the Agent Client Protocol. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit with - sed or a rewrite. Each bash call runs in a fresh shell — pass workdir - instead of cd. Check the [exit code: N] marker; verify your work. Keep - answers brief and factual. + Your tools are bash (plus bash_output/bash_kill for background tasks) + and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd. Check the [exit code: N] marker; + verify your work. Keep answers brief and factual. + + Use the subagent tool to delegate a focused, self-contained subtask to + a fresh child agent (it works in its own context and returns only its + final result) — give it a complete, standalone instruction. + +# The subagent seam + both in-process backends + two model-facing tools, as leaf +# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh +# child) and fork (a child seeded with the parent's completed-turn prefix) are +# both reachable by the model: dsh-tool-subagent is loaded once per backend with +# a distinct toolName (subagent → spawn, subagent_fork → fork), so a multi-child +# scenario can exercise both transports. +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index be6aabada0..42e9107c04 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -3,7 +3,7 @@ import { existsSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' import { describe, expect, it } from 'vitest' -import { type InputScript, runScenario } from './snapshot-harness.ts' +import { type HarvestedLog, type InputScript, runScenario } from './snapshot-harness.ts' import { type NormalizeContext, normalizeSessionLog, normalizeStdout } from './snapshot-normalize.ts' /** @@ -37,6 +37,14 @@ interface Scenario { * coaxed into deterministically) are NEVER re-recorded. */ recorded: 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 } const SCENARIOS: Scenario[] = [ @@ -48,8 +56,17 @@ const SCENARIOS: Scenario[] = [ { name: 'multi-turn', hasModelTurn: true, recorded: true }, { name: 'error-finish', hasModelTurn: true, recorded: false }, { name: 'cancel', hasModelTurn: true, recorded: false }, + { 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 }, ] +/** The sibling child-fixture paths for a scenario (`session.1.jsonl` …). */ +function childFixturePaths(dir: string, childSessions: number): string[] { + return Array.from({ length: childSessions }, (_, i) => join(dir, `session.${i + 1}.jsonl`)) +} + /** * Derive the {@link NormalizeContext} for a `session.jsonl` fixture from its own * header line (`{ type: 'session', id, cwd }`). A committed fixture carries the @@ -81,42 +98,59 @@ for (const scenario of SCENARIOS) { const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as InputScript const overrideFile = join(dir, 'replay.override.json') const workspaceDir = join(dir, 'workspace') + const childSessions = scenario.childSessions ?? 0 const result = await runScenario(input, { mode: RECORDING ? 'record' : 'replay', fixtureFile: join(dir, 'session.jsonl'), ...existsSync(overrideFile) ? { overrideFile } : {}, + // In REPLAY, forward the recorded child fixtures so each subagent session + // replays from its own script. In RECORD they are harvested, not read. + ...!RECORDING && childSessions > 0 ? { childFiles: childFixturePaths(dir, childSessions) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, }) + // Scrub every volatile id the run produced: the ACP server-issued session + // id plus every harvested log's recorded id (a subagent child id never + // surfaces over ACP, but it appears in the child's own log header). The + // normalizer's UUID catch-all covers any we don't enumerate. const ctx: NormalizeContext = { - sessionIds: result.sessionId !== undefined ? [result.sessionId] : [], + sessionIds: [ + ...result.sessionId !== undefined ? [result.sessionId] : [], + ...result.sessionLogs.map(l => l.id), + ], cwd: result.cwd, } - // RECORD mode (recorded scenarios only): persist the freshly-harvested log - // back to the scenario's session.jsonl fixture. `--update` refreshes the - // Vitest goldens but NOT this fixture, so write it here. + // RECORD mode (recorded model scenarios only): persist the freshly-harvested + // logs back to their fixtures — the primary to session.jsonl, each child to + // session..jsonl in harvest order. `--update` refreshes the Vitest + // goldens but NOT these fixtures, so write them here. if (RECORDING && scenario.recorded && scenario.hasModelTurn) { - expect(result.sessionLog, 'record produced no session log to harvest').toBeDefined() - await writeFile(join(dir, 'session.jsonl'), result.sessionLog as string) + expect(result.sessionLogs.length, 'record produced no session log to harvest').toBeGreaterThan(0) + expect(result.sessionLogs.length, `expected ${childSessions + 1} session logs (parent + children)`) + .toBe(childSessions + 1) + await writeFile(join(dir, 'session.jsonl'), (result.sessionLogs[0] as HarvestedLog).content) + for (let i = 1; i < result.sessionLogs.length; i++) { + await writeFile(join(dir, `session.${i}.jsonl`), (result.sessionLogs[i] as HarvestedLog).content) + } } await expect(normalizeStdout(result.rawStdout, ctx)) .toMatchFileSnapshot(join(dir, 'stdout.golden.jsonl')) if (scenario.hasModelTurn) { - expect(result.sessionLog, 'a model scenario must persist a session log').toBeDefined() - // Compare the replay run's persisted log against the `session.jsonl` - // fixture — there is no separate session golden. Both sides pass through - // normalizeSessionLog so the comparison is on normalized form: the - // fixture is raw-harvested (its own real session id / cwd / timestamps), - // the replay output has fresh ones, and each is scrubbed against ITS OWN - // volatile values. The fixture's are read from its header line (a - // committed file cannot share the live run's ctx), so the stale recorded - // cwd/id are scrubbed too, not left to leak past the run's `ctx`. - const fixture = await readFile(join(dir, 'session.jsonl'), 'utf8') - expect(normalizeSessionLog(result.sessionLog as string, ctx)) - .toEqual(normalizeSessionLog(fixture, fixtureContext(fixture))) + // The harvested logs (primary-first) must match their committed fixtures + // 1:1. Each side passes through normalizeSessionLog, scrubbed against ITS + // OWN volatile values — the live run's via `ctx`, the committed fixture's + // via its own header (a committed file cannot share the live run's ids). + expect(result.sessionLogs.length, 'a model scenario must persist a session log').toBe(childSessions + 1) + const fixtureFiles = ['session.jsonl', ...Array.from({ length: childSessions }, (_, i) => `session.${i + 1}.jsonl`)] + for (let i = 0; i < fixtureFiles.length; i++) { + const harvested = (result.sessionLogs[i] as HarvestedLog).content + const fixture = await readFile(join(dir, fixtureFiles[i] as string), 'utf8') + expect(normalizeSessionLog(harvested, ctx), `${fixtureFiles[i]} mismatch`) + .toEqual(normalizeSessionLog(fixture, fixtureContext(fixture))) + } } }) }) @@ -144,7 +178,7 @@ describe('snapshot fixtures', () => { // doubles as the expected-log artifact the run is diffed against. An authored // (non-`recorded`) model scenario additionally ships a `replay.override.json` // sidecar for the throw/hang cases a derived script cannot express. - for (const { name, hasModelTurn, recorded } of SCENARIOS) { + for (const { name, hasModelTurn, recorded, childSessions } of SCENARIOS) { const dir = join(SNAPSHOTS_DIR, 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) @@ -152,6 +186,11 @@ describe('snapshot fixtures', () => { if (hasModelTurn && !recorded) { expect(existsSync(join(dir, 'replay.override.json')), `${name}/replay.override.json`).toBe(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) + } } }) }) diff --git a/examples/acp-agent/tests/snapshot-harness.ts b/examples/acp-agent/tests/snapshot-harness.ts index 7545768b1d..8285b870bf 100644 --- a/examples/acp-agent/tests/snapshot-harness.ts +++ b/examples/acp-agent/tests/snapshot-harness.ts @@ -17,7 +17,7 @@ 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 } from 'node:path' +import { join, delimiter } from 'node:path' import { fileURLToPath } from 'node:url' import { Readable, Writable } from 'node:stream' import { @@ -71,7 +71,19 @@ export interface InputScript { steps: InputStep[] } -/** The result of running a scenario: raw stdout + the harvested session log. */ +/** One harvested session log plus the identifying facts off its header line. */ +export interface HarvestedLog { + /** The recorded session id (header `id`). */ + id: string + /** Session creation time (header `createdAt`) — the child-ordering key. */ + createdAt: number + /** The parent session id, if this log is a subagent child (header `parentSession`). */ + parentSession?: string + /** The full `.jsonl` file content. */ + content: string +} + +/** The result of running a scenario: raw stdout + the harvested session log(s). */ export interface RunResult { /** Raw stdout bytes (decoded utf8), every newline-delimited JSON-RPC frame. */ rawStdout: string @@ -81,8 +93,13 @@ export interface RunResult { sessionId?: string /** The temp cwd the session ran in (the bash workspace). */ cwd: string - /** The persisted session log's content, if one was produced. */ - sessionLog?: string + /** + * Every persisted session log harvested after the run, ordered primary-first: + * the top-level (parent) session — the one with no `parentSession` — then each + * subagent child by ascending `createdAt`. A single-session scenario harvests + * exactly one; a nested-agent scenario harvests the parent plus one per child. + */ + sessionLogs: HarvestedLog[] } interface RunOptions { @@ -92,6 +109,14 @@ interface RunOptions { fixtureFile: string /** Optional sidecar override path (replay). */ overrideFile?: string + /** + * Recorded SUBAGENT child-session fixture paths (replay). A nested-agent + * scenario ships one per child (`session.1.jsonl`, …); the harness forwards + * them to `dsh-llm-replay` via `$DSH_SNAPSHOT_CHILD_FILES` so each child + * session replays from its own recorded script. Empty for single-session + * scenarios. Ignored in record mode (children are harvested, not replayed). + */ + childFiles?: string[] /** * Optional `/workspace/` directory whose contents are copied into * the temp cwd BEFORE the run — the standard way to seed files the agent @@ -114,7 +139,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // never leaks them (the "e2e tests own their resources" rule). let child: ChildProcessWithoutNullStreams | undefined let sessionId: string | undefined - let sessionLog: string | undefined + let sessionLogs: HarvestedLog[] = [] const rawBuffers: Buffer[] = [] const stderrChunks: string[] = [] try { @@ -131,6 +156,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise DSH_SNAPSHOT_FILE: opts.fixtureFile, DSH_SNAPSHOT_SESSIONS_ROOT: sessionsRoot, ...opts.overrideFile !== undefined ? { DSH_SNAPSHOT_OVERRIDE: opts.overrideFile } : {}, + ...opts.childFiles !== undefined && opts.childFiles.length > 0 + ? { DSH_SNAPSHOT_CHILD_FILES: opts.childFiles.join(delimiter) } + : {}, } child = spawn( @@ -189,9 +217,9 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise // persistence) and exits. Then await exit so the harvested log is complete. child.stdin.end() await waitForExit(child) - // Harvest the persisted log (if any) while the temp dirs still exist. - const sessionLogPath = await findSessionLog(sessionsRoot) - if (sessionLogPath !== undefined) sessionLog = await readFile(sessionLogPath, 'utf8') + // 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 @@ -209,7 +237,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise stderr: stderrChunks.join(''), cwd, ...sessionId !== undefined ? { sessionId } : {}, - ...sessionLog !== undefined ? { sessionLog } : {}, + sessionLogs, } } @@ -300,14 +328,25 @@ function waitForExit(child: ChildProcessWithoutNullStreams): Promise { return new Promise(resolve => child.once('exit', () => { resolve() })) } -/** Find the single produced `.jsonl` session log under a sessions root, if any. */ -async function findSessionLog(root: string): Promise { +/** + * Harvest EVERY persisted `.jsonl` session log under a sessions root, parse each + * header line, and return them ordered primary-first: the top-level session (no + * `parentSession`) leads, then each subagent child by ascending `createdAt`. + * + * The JSONL backend lays sessions out as `//.jsonl` + * (one bucket per cwd), so a parent and its same-cwd in-process child land in + * the SAME bucket — collecting all files across all buckets catches both (the + * old first-match short-circuit silently dropped the child). Returns `[]` if no + * log was produced (a no-session scenario). + */ +async function harvestSessionLogs(root: string): Promise { let cwdDirs: string[] try { cwdDirs = await readdir(root) } catch { - return undefined + return [] } + const logs: HarvestedLog[] = [] for (const dir of cwdDirs) { const sub = join(root, dir) let files: string[] @@ -316,8 +355,31 @@ async function findSessionLog(root: string): Promise { } catch { continue } - const jsonl = files.find(f => f.endsWith('.jsonl')) - if (jsonl !== undefined) return join(sub, jsonl) + for (const f of files) { + if (!f.endsWith('.jsonl')) continue + const content = await readFile(join(sub, f), 'utf8') + const firstLine = content.split('\n').find(line => line.trim().length > 0) ?? '{}' + const header = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; parentSession?: unknown } + logs.push({ + id: typeof header.id === 'string' ? header.id : '', + createdAt: typeof header.createdAt === 'number' ? header.createdAt : 0, + ...typeof header.parentSession === 'string' ? { parentSession: header.parentSession } : {}, + content, + }) + } } - return undefined + // Primary (no parentSession) first, then children by ascending createdAt. A + // scenario has exactly one top-level session. In the synchronous cut sibling + // children are created strictly sequentially, so their createdAt values are + // strictly ordered; the recordedId tiebreak only keeps a degenerate + // same-millisecond collision (unreachable here) deterministic. This harvest + // order must match the replay load order in dsh-llm-replay's loadSessionScripts + // so session..jsonl maps to the same child on record and replay — replay + // re-sorts childFiles by the same key, so the two stay consistent. + logs.sort((a, b) => { + const ap = a.parentSession === undefined ? 0 : 1 + const bp = b.parentSession === undefined ? 0 : 1 + return ap - bp || a.createdAt - b.createdAt || a.id.localeCompare(b.id) + }) + return logs } diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/input.json b/examples/acp-agent/tests/snapshots/subagent-fork/input.json new file mode 100644 index 0000000000..366a97e3b5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools." }, + { "op": "prompt", "text": "Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl new file mode 100644 index 0000000000..8bc3d394ad --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -0,0 +1,89 @@ +{"type":"session","version":0,"id":"906f1eac-a457-4eb9-828b-1ba537552524","createdAt":1782133845692,"cwd":"/tmp/acp-snap-cwd-Ml0DrO","parentSession":"f2358dc0-75f8-4649-8440-ab94b8e10dc3","seedLength":38} +{"type":"turn/start","seq":0,"time":1782133842298,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133842298,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782133842299,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133843792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133843793,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133843861,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133843888,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":10,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1782133843913,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":12,"time":1782133843940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":13,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":14,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" respond"}}} +{"type":"assistant/chunk","seq":16,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":18,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":20,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":21,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":22,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":23,"time":1782133843990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":25,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":26,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":27,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":28,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1782133844039,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":31,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."}}}} +{"type":"assistant/chunk","seq":32,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":33,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":34,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1782133844042,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1782133844042,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1782133844042,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":38,"time":1782133845693,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":39,"time":1782133845693,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":40,"time":1782133845693,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":41,"time":1782133846927,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":42,"time":1782133846927,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":43,"time":1782133847020,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":44,"time":1782133847044,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":45,"time":1782133847069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":46,"time":1782133847069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":47,"time":1782133847069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":48,"time":1782133847094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":49,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":50,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":51,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":52,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":53,"time":1782133847095,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":54,"time":1782133847120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":55,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":56,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":57,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":58,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":59,"time":1782133847121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":60,"time":1782133847146,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" then"}}} +{"type":"assistant/chunk","seq":61,"time":1782133847146,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" later"}}} +{"type":"assistant/chunk","seq":62,"time":1782133847171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":63,"time":1782133847171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":64,"time":1782133847171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":65,"time":1782133847197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" was"}}} +{"type":"assistant/chunk","seq":66,"time":1782133847197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":67,"time":1782133847197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":68,"time":1782133847222,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":69,"time":1782133847223,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":70,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":71,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":72,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":73,"time":1782133847248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":74,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":75,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":76,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":77,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"M"}}} +{"type":"assistant/chunk","seq":78,"time":1782133847274,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ARM"}}} +{"type":"assistant/chunk","seq":79,"time":1782133847301,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":80,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ADE"}}} +{"type":"assistant/chunk","seq":81,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and then later asked what it was. I should reply with exactly that one word."}}}} +{"type":"assistant/chunk","seq":82,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":83,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1210,"outputTokens":39,"cacheReadTokens":0,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":84,"time":1782133847302,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":85,"time":1782133847303,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and then later asked what it was. I should reply with exactly that one word."},{"type":"text","text":"MARMALADE"}],"usage":{"inputTokens":1210,"outputTokens":39,"cacheReadTokens":0,"reasoningTokens":34}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":86,"time":1782133847303,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":87,"time":1782133847303,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl new file mode 100644 index 0000000000..358c71f6b0 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl @@ -0,0 +1,190 @@ +{"type":"session","version":0,"id":"f2358dc0-75f8-4649-8440-ab94b8e10dc3","createdAt":1782133842294,"cwd":"/tmp/acp-snap-cwd-Ml0DrO"} +{"type":"turn/start","seq":0,"time":1782133842298,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133842298,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is MARMALADE. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782133842299,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133843792,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133843793,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133843861,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133843888,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":10,"time":1782133843889,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":11,"time":1782133843913,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":12,"time":1782133843940,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":13,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":14,"time":1782133843941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":15,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" respond"}}} +{"type":"assistant/chunk","seq":16,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":17,"time":1782133843963,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":18,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":19,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":20,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":21,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":22,"time":1782133843989,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":23,"time":1782133843990,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":24,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":25,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":26,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":27,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":28,"time":1782133844017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":29,"time":1782133844039,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":30,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":31,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."}}}} +{"type":"assistant/chunk","seq":32,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":33,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}}}} +{"type":"assistant/chunk","seq":34,"time":1782133844040,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1782133844042,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to remember a codeword and respond with just \"OK\". I need to not use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":1334,"outputTokens":27,"cacheReadTokens":0,"reasoningTokens":25}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1782133844042,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1782133844042,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":38,"time":1782133844049,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":39,"time":1782133844049,"data":{"content":[{"type":"text","text":"Use the subagent_fork tool exactly once to delegate this subtask to a forked child agent: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' The forked child inherits this conversation, so it can answer. After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":40,"time":1782133844049,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":41,"time":1782133844782,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":42,"time":1782133844782,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":43,"time":1782133845002,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":44,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":45,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":46,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":47,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":48,"time":1782133845004,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":49,"time":1782133845029,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":50,"time":1782133845029,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":51,"time":1782133845030,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":52,"time":1782133845030,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":53,"time":1782133845030,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" ask"}}} +{"type":"assistant/chunk","seq":54,"time":1782133845054,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":55,"time":1782133845054,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" question"}}} +{"type":"assistant/chunk","seq":56,"time":1782133845054,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":57,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":58,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":59,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":60,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":61,"time":1782133845084,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":62,"time":1782133845104,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":63,"time":1782133845104,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":64,"time":1782133845104,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":65,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ked"}}} +{"type":"assistant/chunk","seq":66,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":67,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":68,"time":1782133845129,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":69,"time":1782133845130,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":70,"time":1782133845154,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":71,"time":1782133845154,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":72,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" so"}}} +{"type":"assistant/chunk","seq":73,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":74,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":75,"time":1782133845155,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" be"}}} +{"type":"assistant/chunk","seq":76,"time":1782133845180,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" able"}}} +{"type":"assistant/chunk","seq":77,"time":1782133845180,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":78,"time":1782133845180,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" see"}}} +{"type":"assistant/chunk","seq":79,"time":1782133845204,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":80,"time":1782133845205,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":81,"time":1782133845230,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mention"}}} +{"type":"assistant/chunk","seq":82,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" of"}}} +{"type":"assistant/chunk","seq":83,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":84,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":85,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":86,"time":1782133845257,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":87,"time":1782133845281,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":88,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":89,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":90,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":91,"time":1782133845282,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":92,"time":1782133845308,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":93,"time":1782133845308,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":94,"time":1782133845384,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":95,"time":1782133845385,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":96,"time":1782133845411,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":97,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":98,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":99,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":100,"time":1782133845412,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":101,"time":1782133845433,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1782133845434,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"Ret"}}} +{"type":"assistant/chunk","seq":103,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"rieve"}}} +{"type":"assistant/chunk","seq":104,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":105,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":106,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":107,"time":1782133845460,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":108,"time":1782133845484,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":109,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":110,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":112,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":113,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":114,"time":1782133845511,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":115,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":116,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":117,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":118,"time":1782133845536,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":119,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":120,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":121,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":122,"time":1782133845562,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":123,"time":1782133845563,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":124,"time":1782133845563,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":125,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":126,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":127,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":128,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":129,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":130,"time":1782133845587,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":131,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":132,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":133,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":134,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":135,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":136,"time":1782133845622,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":137,"time":1782133845637,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":138,"time":1782133845637,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":139,"time":1782133845637,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1782133845662,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":141,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use subagent_fork to ask a question about the project codeword. The forked child inherits this conversation, so it should be able to see the earlier mention of \"MARMALADE\". Let me do that."}}}} +{"type":"assistant/chunk","seq":142,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","arguments":"{\"description\": \"Retrieve project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":143,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":143,"outputTokens":141,"cacheReadTokens":1280,"reasoningTokens":52}}}} +{"type":"assistant/chunk","seq":144,"time":1782133845691,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":145,"time":1782133845691,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use subagent_fork to ask a question about the project codeword. The forked child inherits this conversation, so it should be able to see the earlier mention of \"MARMALADE\". Let me do that."},{"type":"tool-call","id":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","arguments":"{\"description\": \"Retrieve project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"usage":{"inputTokens":143,"outputTokens":141,"cacheReadTokens":1280,"reasoningTokens":52}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":146,"time":1782133845691,"data":{"turn":2,"step":1,"callId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","name":"subagent_fork","arguments":"{\"description\": \"Retrieve project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":147,"time":1782133847305,"data":{"turn":2,"step":1,"callId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","content":[{"type":"text","text":"MARMALADE"}],"isError":false},"sourceEventSeqs":[146],"surfaceOp":"append"} +{"type":"step/end","seq":148,"time":1782133847305,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":149,"time":1782133847305,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":150,"time":1782133847941,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":151,"time":1782133847941,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":152,"time":1782133848080,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":153,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":154,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":155,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":156,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"M"}}} +{"type":"assistant/chunk","seq":157,"time":1782133848105,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ARM"}}} +{"type":"assistant/chunk","seq":158,"time":1782133848131,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":159,"time":1782133848131,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ADE"}}} +{"type":"assistant/chunk","seq":160,"time":1782133848131,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\","}}} +{"type":"assistant/chunk","seq":161,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" which"}}} +{"type":"assistant/chunk","seq":162,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":163,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" correct"}}} +{"type":"assistant/chunk","seq":164,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":165,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":166,"time":1782133848155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":167,"time":1782133848179,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":168,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":169,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":170,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":171,"time":1782133848180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":172,"time":1782133848205,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":173,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":174,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":175,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":176,"time":1782133848206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":177,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":178,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":179,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":180,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":181,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":182,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"MARMALADE\", which is correct. Now I need to reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":183,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":184,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":44,"outputTokens":31,"cacheReadTokens":1536,"reasoningTokens":26}}}} +{"type":"assistant/chunk","seq":185,"time":1782133848232,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":186,"time":1782133848233,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"MARMALADE\", which is correct. Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":44,"outputTokens":31,"cacheReadTokens":1536,"reasoningTokens":26}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":187,"time":1782133848233,"data":{"turn":2,"step":2}} +{"type":"turn/end","seq":188,"time":1782133848233,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl new file mode 100644 index 0000000000..f14353a7ea --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-fork/stdout.golden.jsonl @@ -0,0 +1,114 @@ +{"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}}"}} +{"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"}}}} +{"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":" remember"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"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":" respond"}}}} +{"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":" just"}}}} +{"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":"OK"}}}} +{"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":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" need"}}}} +{"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":" not"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"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":"OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"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"}}}} +{"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":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"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":" ask"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" question"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"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":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"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":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"its"}}}} +{"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":" conversation"}}}} +{"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":" so"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"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":" be"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" able"}}}} +{"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":" see"}}}} +{"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":" earlier"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mention"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" of"}}}} +{"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":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"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":" do"}}}} +{"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":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Retrieve project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_gnZDhmbVLP9hQ0PT42QW7314","status":"completed","content":[{"type":"content","content":{"type":"text","text":"MARMALADE"}}]}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":"M"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ARM"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ADE"}}}} +{"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":" which"}}}} +{"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":" correct"}}}} +{"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":" Now"}}}} +{"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":" need"}}}} +{"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":" reply"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/input.json b/examples/acp-agent/tests/snapshots/subagent-mixed/input.json new file mode 100644 index 0000000000..38cad9c585 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/input.json @@ -0,0 +1,8 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools." }, + { "op": "prompt", "text": "Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl new file mode 100644 index 0000000000..75cbc97f39 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"be77a907-c832-4870-8e71-beb6e57d3726","createdAt":1782133872837,"cwd":"/tmp/acp-snap-cwd-J8rqO2","parentSession":"6d80d699-1744-467a-80a3-e3c73110adda"} +{"type":"turn/start","seq":0,"time":1782133872838,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133872838,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782133872838,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133874026,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133874026,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133874140,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782133874166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782133874167,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":16,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":17,"time":1782133874190,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":18,"time":1782133874215,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1782133874216,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":25,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":26,"time":1782133874241,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":27,"time":1782133874241,"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 \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":28,"time":1782133874242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":29,"time":1782133874242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":778,"outputTokens":23,"cacheReadTokens":384,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":30,"time":1782133874242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1782133874242,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"usage":{"inputTokens":778,"outputTokens":23,"cacheReadTokens":384,"reasoningTokens":19}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1782133874242,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1782133874242,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl new file mode 100644 index 0000000000..d888f79a7d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -0,0 +1,97 @@ +{"type":"session","version":0,"id":"3659a741-05d5-4382-93e5-977b8d563ab3","createdAt":1782133875844,"cwd":"/tmp/acp-snap-cwd-J8rqO2","parentSession":"6d80d699-1744-467a-80a3-e3c73110adda","seedLength":44} +{"type":"turn/start","seq":0,"time":1782133869872,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133869873,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782133869873,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133870753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":7,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":8,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1782133870802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1782133870827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":17,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":18,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":19,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":22,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":24,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":25,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":27,"time":1782133870904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":28,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":29,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":30,"time":1782133870954,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":31,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":32,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":33,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":34,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":35,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":36,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":37,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."}}}} +{"type":"assistant/chunk","seq":38,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":39,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":40,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":41,"time":1782133870983,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":42,"time":1782133870983,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":43,"time":1782133870983,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":44,"time":1782133875845,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":45,"time":1782133875845,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":46,"time":1782133875845,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":47,"time":1782133876624,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":48,"time":1782133876624,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":49,"time":1782133876870,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":50,"time":1782133876896,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":51,"time":1782133876922,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":52,"time":1782133876923,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":53,"time":1782133876923,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":54,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":55,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":56,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":57,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":58,"time":1782133876946,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":59,"time":1782133876947,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":60,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":61,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":62,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":63,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":64,"time":1782133876975,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":65,"time":1782133876995,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" later"}}} +{"type":"assistant/chunk","seq":66,"time":1782133876996,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":67,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":68,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" now"}}} +{"type":"assistant/chunk","seq":69,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" they"}}} +{"type":"assistant/chunk","seq":70,"time":1782133877021,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'re"}}} +{"type":"assistant/chunk","seq":71,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":72,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" what"}}} +{"type":"assistant/chunk","seq":73,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":74,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":75,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":76,"time":1782133877046,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":77,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":78,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":79,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1782133877074,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":81,"time":1782133877098,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":82,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":83,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":84,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":85,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":86,"time":1782133877099,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"SA"}}} +{"type":"assistant/chunk","seq":87,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"FF"}}} +{"type":"assistant/chunk","seq":88,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":1,"text":"RON"}}} +{"type":"assistant/chunk","seq":89,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"SAFFRON\" for later, and now they're asking what it is. I should reply with just that one word."}}}} +{"type":"assistant/chunk","seq":90,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":91,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":57,"outputTokens":41,"cacheReadTokens":1152,"reasoningTokens":37}}}} +{"type":"assistant/chunk","seq":92,"time":1782133877125,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":93,"time":1782133877126,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"SAFFRON\" for later, and now they're asking what it is. I should reply with just that one word."},{"type":"text","text":"SAFFRON"}],"usage":{"inputTokens":57,"outputTokens":41,"cacheReadTokens":1152,"reasoningTokens":37}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":94,"time":1782133877126,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":95,"time":1782133877126,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl new file mode 100644 index 0000000000..685bb0be68 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -0,0 +1,346 @@ +{"type":"session","version":0,"id":"6d80d699-1744-467a-80a3-e3c73110adda","createdAt":1782133869868,"cwd":"/tmp/acp-snap-cwd-J8rqO2"} +{"type":"turn/start","seq":0,"time":1782133869872,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782133869873,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782133869873,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782133870583,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782133870753,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":7,"time":1782133870777,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asking"}}} +{"type":"assistant/chunk","seq":8,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":9,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":10,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" remember"}}} +{"type":"assistant/chunk","seq":11,"time":1782133870778,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":12,"time":1782133870802,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":13,"time":1782133870827,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":14,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":15,"time":1782133870828,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":16,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":17,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":18,"time":1782133870853,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":19,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":20,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":21,"time":1782133870854,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":22,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":23,"time":1782133870878,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" just"}}} +{"type":"assistant/chunk","seq":24,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":25,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"OK"}}} +{"type":"assistant/chunk","seq":26,"time":1782133870879,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":27,"time":1782133870904,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" They"}}} +{"type":"assistant/chunk","seq":28,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specified"}}} +{"type":"assistant/chunk","seq":29,"time":1782133870928,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" not"}}} +{"type":"assistant/chunk","seq":30,"time":1782133870954,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":31,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":32,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" any"}}} +{"type":"assistant/chunk","seq":33,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tools"}}} +{"type":"assistant/chunk","seq":34,"time":1782133870955,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":35,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":36,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"OK"}}} +{"type":"assistant/chunk","seq":37,"time":1782133870980,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."}}}} +{"type":"assistant/chunk","seq":38,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"OK"}}}} +{"type":"assistant/chunk","seq":39,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":40,"time":1782133870981,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":41,"time":1782133870983,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user is asking me to remember a codeword \"SAFFRON\" and reply with just \"OK\". They specified not to use any tools."},{"type":"text","text":"OK"}],"usage":{"inputTokens":53,"outputTokens":33,"cacheReadTokens":1280,"reasoningTokens":31}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":42,"time":1782133870983,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":43,"time":1782133870983,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":44,"time":1782133870991,"data":{"turn":2,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":45,"time":1782133870991,"data":{"content":[{"type":"text","text":"Do these two delegations, once at a time. First, use the subagent tool (fresh child) exactly once: 'Reply with exactly the word ALPHA and nothing else.' Then, after it returns, use the subagent_fork tool (forked child that inherits this conversation) exactly once: 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":46,"time":1782133870991,"data":{"turn":2,"step":1}} +{"type":"assistant/chunk","seq":47,"time":1782133871816,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":48,"time":1782133871816,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":49,"time":1782133871942,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":50,"time":1782133871966,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":51,"time":1782133871967,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":52,"time":1782133871967,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":53,"time":1782133871967,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" do"}}} +{"type":"assistant/chunk","seq":54,"time":1782133871994,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" two"}}} +{"type":"assistant/chunk","seq":55,"time":1782133871994,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" deleg"}}} +{"type":"assistant/chunk","seq":56,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ations"}}} +{"type":"assistant/chunk","seq":57,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sequentially"}}} +{"type":"assistant/chunk","seq":58,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":\n\n"}}} +{"type":"assistant/chunk","seq":59,"time":1782133872018,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"1"}}} +{"type":"assistant/chunk","seq":60,"time":1782133872019,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":61,"time":1782133872019,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":62,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":63,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":64,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":65,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":66,"time":1782133872043,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":67,"time":1782133872044,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":68,"time":1782133872068,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":69,"time":1782133872069,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":70,"time":1782133872093,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":71,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":72,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":73,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":74,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":75,"time":1782133872094,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":76,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":77,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" AL"}}} +{"type":"assistant/chunk","seq":78,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":79,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":80,"time":1782133872120,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":81,"time":1782133872121,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":82,"time":1782133872144,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":83,"time":1782133872171,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":84,"time":1782133872172,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"2"}}} +{"type":"assistant/chunk","seq":85,"time":1782133872172,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":86,"time":1782133872172,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":87,"time":1782133872197,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":88,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":89,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":90,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":91,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":92,"time":1782133872198,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":93,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":94,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":95,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":96,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":97,"time":1782133872224,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":98,"time":1782133872225,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":99,"time":1782133872247,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":100,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":101,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"What"}}} +{"type":"assistant/chunk","seq":102,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":103,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":104,"time":1782133872248,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":105,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":106,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":107,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":108,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" mentioned"}}} +{"type":"assistant/chunk","seq":109,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" earlier"}}} +{"type":"assistant/chunk","seq":110,"time":1782133872273,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" in"}}} +{"type":"assistant/chunk","seq":111,"time":1782133872297,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":112,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":113,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"?"}}} +{"type":"assistant/chunk","seq":114,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Reply"}}} +{"type":"assistant/chunk","seq":115,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":116,"time":1782133872298,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":117,"time":1782133872324,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":118,"time":1782133872324,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":119,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":120,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":121,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":122,"time":1782133872325,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":123,"time":1782133872348,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".'\n"}}} +{"type":"assistant/chunk","seq":124,"time":1782133872349,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"3"}}} +{"type":"assistant/chunk","seq":125,"time":1782133872349,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":126,"time":1782133872349,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":127,"time":1782133872374,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" both"}}} +{"type":"assistant/chunk","seq":128,"time":1782133872374,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" return"}}} +{"type":"assistant/chunk","seq":129,"time":1782133872374,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":130,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":131,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":132,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" '"}}} +{"type":"assistant/chunk","seq":133,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":134,"time":1782133872402,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":135,"time":1782133872403,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":136,"time":1782133872426,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":137,"time":1782133872426,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"'.\n\n"}}} +{"type":"assistant/chunk","seq":138,"time":1782133872427,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Let"}}} +{"type":"assistant/chunk","seq":139,"time":1782133872427,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":140,"time":1782133872427,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":141,"time":1782133872452,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":142,"time":1782133872453,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":143,"time":1782133872453,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":144,"time":1782133872453,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" one"}}} +{"type":"assistant/chunk","seq":145,"time":1782133872478,"data":{"turn":2,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":146,"time":1782133872528,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":147,"time":1782133872528,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":148,"time":1782133872553,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":149,"time":1782133872554,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":150,"time":1782133872554,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":151,"time":1782133872604,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":152,"time":1782133872605,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":153,"time":1782133872605,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1782133872605,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":155,"time":1782133872624,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":156,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":157,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":158,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":159,"time":1782133872625,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":160,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":161,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":162,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":163,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":164,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":165,"time":1782133872684,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":166,"time":1782133872708,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":167,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":168,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":169,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":170,"time":1782133872709,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":171,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":172,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":173,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":174,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":175,"time":1782133872734,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":176,"time":1782133872735,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":177,"time":1782133872766,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":178,"time":1782133872767,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":179,"time":1782133872767,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":180,"time":1782133872800,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":181,"time":1782133872835,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool with the prompt 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool with the prompt 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with the first one."}}}} +{"type":"assistant/chunk","seq":182,"time":1782133872836,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","arguments":"{\"description\": \"Reply with ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":183,"time":1782133872836,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":171,"outputTokens":173,"cacheReadTokens":1280,"reasoningTokens":98}}}} +{"type":"assistant/chunk","seq":184,"time":1782133872836,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":185,"time":1782133872836,"data":{"turn":2,"step":1,"content":[{"type":"reasoning","text":"The user wants me to do two delegations sequentially:\n\n1. First, use the subagent tool with the prompt 'Reply with exactly the word ALPHA and nothing else.'\n2. After it returns, use the subagent_fork tool with the prompt 'What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.'\n3. After both return, reply with 'PARENT_DONE'.\n\nLet me start with the first one."},{"type":"tool-call","id":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","arguments":"{\"description\": \"Reply with ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"usage":{"inputTokens":171,"outputTokens":173,"cacheReadTokens":1280,"reasoningTokens":98}},"sourceEventSeqs":[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,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],"surfaceOp":"append"} +{"type":"tool/call","seq":186,"time":1782133872836,"data":{"turn":2,"step":1,"callId":"call_00_PoCyXrE8CAYDDrnx19eO7333","name":"subagent","arguments":"{\"description\": \"Reply with ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":187,"time":1782133874245,"data":{"turn":2,"step":1,"callId":"call_00_PoCyXrE8CAYDDrnx19eO7333","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[186],"surfaceOp":"append"} +{"type":"step/end","seq":188,"time":1782133874246,"data":{"turn":2,"step":1}} +{"type":"step/start","seq":189,"time":1782133874246,"data":{"turn":2,"step":2}} +{"type":"assistant/chunk","seq":190,"time":1782133875024,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":191,"time":1782133875024,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":192,"time":1782133875129,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":193,"time":1782133875155,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":194,"time":1782133875156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":195,"time":1782133875156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":196,"time":1782133875156,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":197,"time":1782133875180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":198,"time":1782133875180,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":199,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":200,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":201,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":202,"time":1782133875181,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":203,"time":1782133875206,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":204,"time":1782133875207,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":205,"time":1782133875207,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":206,"time":1782133875207,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":207,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":208,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_f"}}} +{"type":"assistant/chunk","seq":209,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ork"}}} +{"type":"assistant/chunk","seq":210,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":211,"time":1782133875231,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":212,"time":1782133875232,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":213,"time":1782133875257,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" about"}}} +{"type":"assistant/chunk","seq":214,"time":1782133875258,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":215,"time":1782133875258,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" project"}}} +{"type":"assistant/chunk","seq":216,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":217,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":218,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":219,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":220,"time":1782133875282,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Since"}}} +{"type":"assistant/chunk","seq":221,"time":1782133875307,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":222,"time":1782133875308,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" for"}}} +{"type":"assistant/chunk","seq":223,"time":1782133875333,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ked"}}} +{"type":"assistant/chunk","seq":224,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" child"}}} +{"type":"assistant/chunk","seq":225,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" inher"}}} +{"type":"assistant/chunk","seq":226,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"its"}}} +{"type":"assistant/chunk","seq":227,"time":1782133875334,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" this"}}} +{"type":"assistant/chunk","seq":228,"time":1782133875357,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" conversation"}}} +{"type":"assistant/chunk","seq":229,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":230,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" it"}}} +{"type":"assistant/chunk","seq":231,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":232,"time":1782133875358,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" know"}}} +{"type":"assistant/chunk","seq":233,"time":1782133875385,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":234,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" cod"}}} +{"type":"assistant/chunk","seq":235,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ew"}}} +{"type":"assistant/chunk","seq":236,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ord"}}} +{"type":"assistant/chunk","seq":237,"time":1782133875409,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" is"}}} +{"type":"assistant/chunk","seq":238,"time":1782133875410,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" SA"}}} +{"type":"assistant/chunk","seq":239,"time":1782133875435,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":240,"time":1782133875435,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":241,"time":1782133875435,"data":{"turn":2,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":242,"time":1782133875510,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":243,"time":1782133875510,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":244,"time":1782133875534,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":245,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":246,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":247,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":248,"time":1782133875535,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":249,"time":1782133875569,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":250,"time":1782133875570,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"Recall"}}} +{"type":"assistant/chunk","seq":251,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":252,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":253,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":254,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":255,"time":1782133875590,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":256,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":257,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":258,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":259,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":260,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":261,"time":1782133875637,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":262,"time":1782133875661,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":263,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"What"}}} +{"type":"assistant/chunk","seq":264,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" is"}}} +{"type":"assistant/chunk","seq":265,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":266,"time":1782133875662,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" project"}}} +{"type":"assistant/chunk","seq":267,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" cod"}}} +{"type":"assistant/chunk","seq":268,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ew"}}} +{"type":"assistant/chunk","seq":269,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"ord"}}} +{"type":"assistant/chunk","seq":270,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" mentioned"}}} +{"type":"assistant/chunk","seq":271,"time":1782133875685,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" earlier"}}} +{"type":"assistant/chunk","seq":272,"time":1782133875686,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" in"}}} +{"type":"assistant/chunk","seq":273,"time":1782133875710,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" this"}}} +{"type":"assistant/chunk","seq":274,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" conversation"}}} +{"type":"assistant/chunk","seq":275,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"?"}}} +{"type":"assistant/chunk","seq":276,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" Reply"}}} +{"type":"assistant/chunk","seq":277,"time":1782133875711,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":278,"time":1782133875736,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":279,"time":1782133875737,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" that"}}} +{"type":"assistant/chunk","seq":280,"time":1782133875737,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" one"}}} +{"type":"assistant/chunk","seq":281,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":282,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":283,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":284,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":285,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":286,"time":1782133875762,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":287,"time":1782133875787,"data":{"turn":2,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":288,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork with the prompt about the project codeword. Since the forked child inherits this conversation, it should know the codeword is SAFFRON."}}}} +{"type":"assistant/chunk","seq":289,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":290,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":103,"outputTokens":139,"cacheReadTokens":1536,"reasoningTokens":51}}}} +{"type":"assistant/chunk","seq":291,"time":1782133875843,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":292,"time":1782133875843,"data":{"turn":2,"step":2,"content":[{"type":"reasoning","text":"The first subagent returned \"ALPHA\". Now I need to use subagent_fork with the prompt about the project codeword. Since the forked child inherits this conversation, it should know the codeword is SAFFRON."},{"type":"tool-call","id":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}],"usage":{"inputTokens":103,"outputTokens":139,"cacheReadTokens":1536,"reasoningTokens":51}},"sourceEventSeqs":[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,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240,241,242,243,244,245,246,247,248,249,250,251,252,253,254,255,256,257,258,259,260,261,262,263,264,265,266,267,268,269,270,271,272,273,274,275,276,277,278,279,280,281,282,283,284,285,286,287,288,289,290,291],"surfaceOp":"append"} +{"type":"tool/call","seq":293,"time":1782133875843,"data":{"turn":2,"step":2,"callId":"call_00_BW0xGt0pKCAONv8lM1rC1333","name":"subagent_fork","arguments":"{\"description\": \"Recall project codeword\", \"prompt\": \"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else.\"}"}} +{"type":"tool/result","seq":294,"time":1782133877128,"data":{"turn":2,"step":2,"callId":"call_00_BW0xGt0pKCAONv8lM1rC1333","content":[{"type":"text","text":"SAFFRON"}],"isError":false},"sourceEventSeqs":[293],"surfaceOp":"append"} +{"type":"step/end","seq":295,"time":1782133877128,"data":{"turn":2,"step":2}} +{"type":"step/start","seq":296,"time":1782133877128,"data":{"turn":2,"step":3}} +{"type":"assistant/chunk","seq":297,"time":1782133877923,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":298,"time":1782133877923,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":299,"time":1782133878022,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":300,"time":1782133878047,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":301,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":302,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":303,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":304,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" The"}}} +{"type":"assistant/chunk","seq":305,"time":1782133878048,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":306,"time":1782133878072,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":307,"time":1782133878072,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":308,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":309,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":310,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":311,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":312,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":313,"time":1782133878098,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":314,"time":1782133878123,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":315,"time":1782133878123,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":316,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":317,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"SA"}}} +{"type":"assistant/chunk","seq":318,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"FF"}}} +{"type":"assistant/chunk","seq":319,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"RON"}}} +{"type":"assistant/chunk","seq":320,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":321,"time":1782133878149,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":322,"time":1782133878174,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":323,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":324,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":325,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":326,"time":1782133878175,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":327,"time":1782133878199,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":328,"time":1782133878199,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":329,"time":1782133878199,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":330,"time":1782133878200,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":331,"time":1782133878200,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":332,"time":1782133878200,"data":{"turn":2,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":333,"time":1782133878225,"data":{"turn":2,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":334,"time":1782133878225,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":335,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":336,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":337,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":338,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned. The first returned \"ALPHA\" and the second returned \"SAFFRON\". Now I need to reply with \"PARENT_DONE\"."}}}} +{"type":"assistant/chunk","seq":339,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":340,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":129,"outputTokens":40,"cacheReadTokens":1664,"reasoningTokens":35}}}} +{"type":"assistant/chunk","seq":341,"time":1782133878226,"data":{"turn":2,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":342,"time":1782133878227,"data":{"turn":2,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned. The first returned \"ALPHA\" and the second returned \"SAFFRON\". Now I need to reply with \"PARENT_DONE\"."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":129,"outputTokens":40,"cacheReadTokens":1664,"reasoningTokens":35}},"sourceEventSeqs":[297,298,299,300,301,302,303,304,305,306,307,308,309,310,311,312,313,314,315,316,317,318,319,320,321,322,323,324,325,326,327,328,329,330,331,332,333,334,335,336,337,338,339,340,341],"surfaceOp":"append"} +{"type":"step/end","seq":343,"time":1782133878227,"data":{"turn":2,"step":3}} +{"type":"turn/end","seq":344,"time":1782133878227,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl new file mode 100644 index 0000000000..c0aad6ad3b --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/stdout.golden.jsonl @@ -0,0 +1,228 @@ +{"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}}"}} +{"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":" is"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" asking"}}}} +{"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":" remember"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"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":"SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"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":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"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":" just"}}}} +{"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":"OK"}}}} +{"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":" They"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specified"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" not"}}}} +{"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":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" any"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tools"}}}} +{"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":"OK"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +{"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"}}}} +{"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":" do"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" two"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" deleg"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ations"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sequentially"}}}} +{"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":"1"}}}} +{"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":" First"}}}} +{"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":" use"}}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"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":" 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":" prompt"}}}} +{"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":"Reply"}}}} +{"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":" exactly"}}}} +{"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":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"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":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"2"}}}} +{"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":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" it"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"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":" use"}}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"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":" 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":" prompt"}}}} +{"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":"What"}}}} +{"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":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" mentioned"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" earlier"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" in"}}}} +{"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":" conversation"}}}} +{"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":" Reply"}}}} +{"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":" exactly"}}}} +{"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":" one"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" word"}}}} +{"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":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":".'\n"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"3"}}}} +{"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":" After"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" return"}}}} +{"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":" reply"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":"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":" start"}}}} +{"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":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" one"}}}} +{"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_PoCyXrE8CAYDDrnx19eO7333","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_PoCyXrE8CAYDDrnx19eO7333","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"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":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"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":" Now"}}}} +{"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":" need"}}}} +{"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":" use"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_f"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ork"}}}} +{"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":" prompt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" about"}}}} +{"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":" project"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"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":" Since"}}}} +{"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":" for"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ked"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" child"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" inher"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"its"}}}} +{"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":" conversation"}}}} +{"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":" it"}}}} +{"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":" know"}}}} +{"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":" cod"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ew"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ord"}}}} +{"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":" SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"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_BW0xGt0pKCAONv8lM1rC1333","title":"subagent_fork","kind":"other","status":"in_progress","rawInput":{"description":"Recall project codeword","prompt":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_BW0xGt0pKCAONv8lM1rC1333","status":"completed","content":[{"type":"content","content":{"type":"text","text":"SAFFRON"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"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":" and"}}}} +{"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":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":"SA"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"FF"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"RON"}}}} +{"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":" Now"}}}} +{"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":" need"}}}} +{"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":" reply"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"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"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/input.json b/examples/acp-agent/tests/snapshots/subagent-multi/input.json new file mode 100644 index 0000000000..d497fd737a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl new file mode 100644 index 0000000000..558d69c495 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"dba897b9-c416-4b56-928c-75d12c3e6b32","createdAt":1782087750369,"cwd":"/tmp/acp-snap-cwd-v6PaeC","parentSession":"2a50c62e-1d77-4b0e-bfab-3a9285e3aa32"} +{"type":"turn/start","seq":0,"time":1782087750369,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087750369,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782087750370,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087750967,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087750967,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087751058,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":7,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782087751092,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":16,"time":1782087751117,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":17,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":18,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1782087751166,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"AL"}}} +{"type":"assistant/chunk","seq":25,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":26,"time":1782087751197,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"HA"}}} +{"type":"assistant/chunk","seq":27,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":28,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":29,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":68,"outputTokens":23,"cacheReadTokens":896,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":30,"time":1782087751198,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1782087751198,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"usage":{"inputTokens":68,"outputTokens":23,"cacheReadTokens":896,"reasoningTokens":19}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1782087751198,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1782087751198,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl new file mode 100644 index 0000000000..d1335a20ba --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -0,0 +1,33 @@ +{"type":"session","version":0,"id":"52bb4e53-1cf4-4680-b954-4ad941a9e986","createdAt":1782087752261,"cwd":"/tmp/acp-snap-cwd-v6PaeC","parentSession":"2a50c62e-1d77-4b0e-bfab-3a9285e3aa32"} +{"type":"turn/start","seq":0,"time":1782087752262,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087752262,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782087752262,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087752714,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087752714,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087752857,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087752875,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" asked"}}} +{"type":"assistant/chunk","seq":7,"time":1782087752909,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087752909,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087752910,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782087752910,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782087752910,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782087752941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782087752941,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":16,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":17,"time":1782087752942,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":18,"time":1782087752974,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":19,"time":1782087752975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":20,"time":1782087752975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":21,"time":1782087752975,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":22,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":24,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":25,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":26,"time":1782087753004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":27,"time":1782087753005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":67,"outputTokens":21,"cacheReadTokens":896,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":28,"time":1782087753005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":29,"time":1782087753005,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"usage":{"inputTokens":67,"outputTokens":21,"cacheReadTokens":896,"reasoningTokens":18}},"sourceEventSeqs":[3,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":1782087753005,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":31,"time":1782087753005,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl new file mode 100644 index 0000000000..1bbec1dbdc --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -0,0 +1,213 @@ +{"type":"session","version":0,"id":"2a50c62e-1d77-4b0e-bfab-3a9285e3aa32","createdAt":1782087748790,"cwd":"/tmp/acp-snap-cwd-v6PaeC"} +{"type":"turn/start","seq":0,"time":1782087748793,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087748794,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782087748794,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087749465,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087749465,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087749560,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087749588,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":10,"time":1782087749590,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1782087749617,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":12,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":13,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" twice"}}} +{"type":"assistant/chunk","seq":15,"time":1782087749618,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":16,"time":1782087749643,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":17,"time":1782087749644,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" at"}}} +{"type":"assistant/chunk","seq":18,"time":1782087749644,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" a"}}} +{"type":"assistant/chunk","seq":19,"time":1782087749672,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" time"}}} +{"type":"assistant/chunk","seq":20,"time":1782087749673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":21,"time":1782087749673,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" First"}}} +{"type":"assistant/chunk","seq":22,"time":1782087749702,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} +{"type":"assistant/chunk","seq":23,"time":1782087749703,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":24,"time":1782087749703,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":25,"time":1782087749731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":26,"time":1782087749731,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":27,"time":1782087749732,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":28,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":29,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":30,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":31,"time":1782087749758,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":32,"time":1782087749759,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" After"}}} +{"type":"assistant/chunk","seq":33,"time":1782087749786,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" that"}}} +{"type":"assistant/chunk","seq":34,"time":1782087749787,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":35,"time":1782087749814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":36,"time":1782087749814,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":37,"time":1782087749843,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" subt"}}} +{"type":"assistant/chunk","seq":38,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ask"}}} +{"type":"assistant/chunk","seq":39,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":":"}}} +{"type":"assistant/chunk","seq":40,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":41,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":42,"time":1782087749844,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":43,"time":1782087749872,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"B"}}} +{"type":"assistant/chunk","seq":44,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ETA"}}} +{"type":"assistant/chunk","seq":45,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":46,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":47,"time":1782087749873,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":48,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":49,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":50,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":51,"time":1782087749901,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":52,"time":1782087749902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":53,"time":1782087749902,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":54,"time":1782087749930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":55,"time":1782087749930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":56,"time":1782087749930,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Let"}}} +{"type":"assistant/chunk","seq":57,"time":1782087749931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":58,"time":1782087749931,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" start"}}} +{"type":"assistant/chunk","seq":59,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":60,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":61,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" first"}}} +{"type":"assistant/chunk","seq":62,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":63,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":64,"time":1782087749959,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":65,"time":1782087749986,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":66,"time":1782087750072,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":67,"time":1782087750072,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":68,"time":1782087750075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":69,"time":1782087750075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":70,"time":1782087750075,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":71,"time":1782087750102,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":72,"time":1782087750102,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":73,"time":1782087750103,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":74,"time":1782087750103,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"First"}}} +{"type":"assistant/chunk","seq":75,"time":1782087750129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" subt"}}} +{"type":"assistant/chunk","seq":76,"time":1782087750129,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":77,"time":1782087750130,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":78,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":79,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":80,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":81,"time":1782087750159,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":82,"time":1782087750189,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":83,"time":1782087750190,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":84,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":85,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":86,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":87,"time":1782087750216,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":88,"time":1782087750247,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":89,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":90,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":91,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":92,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":93,"time":1782087750248,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":94,"time":1782087750275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" AL"}}} +{"type":"assistant/chunk","seq":95,"time":1782087750275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"P"}}} +{"type":"assistant/chunk","seq":96,"time":1782087750275,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"HA"}}} +{"type":"assistant/chunk","seq":97,"time":1782087750276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":98,"time":1782087750276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":99,"time":1782087750276,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":100,"time":1782087750304,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":101,"time":1782087750305,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":102,"time":1782087750305,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":103,"time":1782087750365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool twice, once at a time. First subtask: reply with \"ALPHA\". After that returns, second subtask: reply with \"BETA\". Then I reply with \"PARENT_DONE\". Let me start with the first subagent call."}}}} +{"type":"assistant/chunk","seq":104,"time":1782087750366,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","arguments":"{\"description\": \"First subtask: ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":105,"time":1782087750366,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1189,"outputTokens":139,"cacheReadTokens":0,"reasoningTokens":62}}}} +{"type":"assistant/chunk","seq":106,"time":1782087750366,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":107,"time":1782087750368,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool twice, once at a time. First subtask: reply with \"ALPHA\". After that returns, second subtask: reply with \"BETA\". Then I reply with \"PARENT_DONE\". Let me start with the first subagent call."},{"type":"tool-call","id":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","arguments":"{\"description\": \"First subtask: ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"usage":{"inputTokens":1189,"outputTokens":139,"cacheReadTokens":0,"reasoningTokens":62}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"tool/call","seq":108,"time":1782087750368,"data":{"turn":1,"step":1,"callId":"call_00_T91HrbiohZjyqZ7biX4s4408","name":"subagent","arguments":"{\"description\": \"First subtask: ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":109,"time":1782087751204,"data":{"turn":1,"step":1,"callId":"call_00_T91HrbiohZjyqZ7biX4s4408","content":[{"type":"text","text":"ALPHA"}],"isError":false},"sourceEventSeqs":[108],"surfaceOp":"append"} +{"type":"step/end","seq":110,"time":1782087751204,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":111,"time":1782087751204,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":112,"time":1782087751674,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":113,"time":1782087751674,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"First"}}} +{"type":"assistant/chunk","seq":114,"time":1782087751762,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":115,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":116,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":117,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":118,"time":1782087751793,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"AL"}}} +{"type":"assistant/chunk","seq":119,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"P"}}} +{"type":"assistant/chunk","seq":120,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"HA"}}} +{"type":"assistant/chunk","seq":121,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\"."}}} +{"type":"assistant/chunk","seq":122,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":123,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":124,"time":1782087751819,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":125,"time":1782087751848,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":126,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" call"}}} +{"type":"assistant/chunk","seq":127,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":128,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" second"}}} +{"type":"assistant/chunk","seq":129,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":130,"time":1782087751849,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":131,"time":1782087751877,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":132,"time":1782087751966,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":133,"time":1782087751966,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":134,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":135,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":136,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":137,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":138,"time":1782087751995,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":139,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":140,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"Second"}}} +{"type":"assistant/chunk","seq":141,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" subt"}}} +{"type":"assistant/chunk","seq":142,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"ask"}}} +{"type":"assistant/chunk","seq":143,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":":"}}} +{"type":"assistant/chunk","seq":144,"time":1782087752025,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":145,"time":1782087752053,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":146,"time":1782087752053,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":147,"time":1782087752082,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":148,"time":1782087752083,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":149,"time":1782087752083,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":150,"time":1782087752083,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":151,"time":1782087752112,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":152,"time":1782087752113,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":153,"time":1782087752113,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":154,"time":1782087752113,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":155,"time":1782087752140,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":156,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":157,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":158,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":159,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" B"}}} +{"type":"assistant/chunk","seq":160,"time":1782087752141,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"ETA"}}} +{"type":"assistant/chunk","seq":161,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":162,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":163,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":164,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":165,"time":1782087752170,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":166,"time":1782087752199,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":167,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I need to call the second subagent."}}}} +{"type":"assistant/chunk","seq":168,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","arguments":"{\"description\": \"Second subtask: BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":169,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":63,"outputTokens":94,"cacheReadTokens":1280,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":170,"time":1782087752260,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":171,"time":1782087752261,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"First subagent returned \"ALPHA\". Now I need to call the second subagent."},{"type":"tool-call","id":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","arguments":"{\"description\": \"Second subtask: BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}],"usage":{"inputTokens":63,"outputTokens":94,"cacheReadTokens":1280,"reasoningTokens":19}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"tool/call","seq":172,"time":1782087752261,"data":{"turn":1,"step":2,"callId":"call_00_G13lPXMKGT1h6ms60n411588","name":"subagent","arguments":"{\"description\": \"Second subtask: BETA\", \"prompt\": \"Reply with exactly the word BETA and nothing else.\"}"}} +{"type":"tool/result","seq":173,"time":1782087753008,"data":{"turn":1,"step":2,"callId":"call_00_G13lPXMKGT1h6ms60n411588","content":[{"type":"text","text":"BETA"}],"isError":false},"sourceEventSeqs":[172],"surfaceOp":"append"} +{"type":"step/end","seq":174,"time":1782087753008,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":175,"time":1782087753008,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":176,"time":1782087753643,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":177,"time":1782087753643,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"Both"}}} +{"type":"assistant/chunk","seq":178,"time":1782087753776,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":179,"time":1782087753806,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"agents"}}} +{"type":"assistant/chunk","seq":180,"time":1782087753807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" have"}}} +{"type":"assistant/chunk","seq":181,"time":1782087753807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":182,"time":1782087753807,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":183,"time":1782087753850,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":184,"time":1782087753851,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":185,"time":1782087753851,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":186,"time":1782087753870,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":187,"time":1782087753870,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":188,"time":1782087753871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":189,"time":1782087753871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":190,"time":1782087753871,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":191,"time":1782087753901,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":192,"time":1782087753901,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":193,"time":1782087753902,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":194,"time":1782087753902,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":195,"time":1782087753902,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":196,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":197,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":198,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":199,"time":1782087753935,"data":{"turn":1,"step":3,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":200,"time":1782087753936,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":201,"time":1782087753936,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":202,"time":1782087753966,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":203,"time":1782087753966,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":204,"time":1782087753966,"data":{"turn":1,"step":3,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":205,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Both subagents have returned. Now I need to reply with exactly \"PARENT_DONE\" and nothing else."}}}} +{"type":"assistant/chunk","seq":206,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":207,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":43,"outputTokens":28,"cacheReadTokens":1408,"reasoningTokens":23}}}} +{"type":"assistant/chunk","seq":208,"time":1782087753967,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":209,"time":1782087753967,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"Both subagents have returned. Now I need to reply with exactly \"PARENT_DONE\" and nothing else."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":43,"outputTokens":28,"cacheReadTokens":1408,"reasoningTokens":23}},"sourceEventSeqs":[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],"surfaceOp":"append"} +{"type":"step/end","seq":210,"time":1782087753967,"data":{"turn":1,"step":3}} +{"type":"turn/end","seq":211,"time":1782087753967,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl new file mode 100644 index 0000000000..1cfce85dc3 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-multi/stdout.golden.jsonl @@ -0,0 +1,115 @@ +{"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}}"}} +{"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"}}}} +{"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":" use"}}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"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":" twice"}}}} +{"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":" once"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" at"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" a"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" time"}}}} +{"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":" First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"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":" reply"}}}} +{"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":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"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":" After"}}}} +{"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":" returns"}}}} +{"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":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" subt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ask"}}}} +{"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":" reply"}}}} +{"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":"B"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ETA"}}}} +{"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":" I"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" reply"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":" start"}}}} +{"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":" first"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"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":"."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_T91HrbiohZjyqZ7biX4s4408","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"First subtask: ALPHA","prompt":"Reply with exactly the word ALPHA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_T91HrbiohZjyqZ7biX4s4408","status":"completed","content":[{"type":"content","content":{"type":"text","text":"ALPHA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"First"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":"AL"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"P"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"HA"}}}} +{"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":" Now"}}}} +{"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":" need"}}}} +{"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":" 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":" second"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"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_G13lPXMKGT1h6ms60n411588","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Second subtask: BETA","prompt":"Reply with exactly the word BETA and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_G13lPXMKGT1h6ms60n411588","status":"completed","content":[{"type":"content","content":{"type":"text","text":"BETA"}}]}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Both"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agents"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" have"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":" Now"}}}} +{"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":" need"}}}} +{"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":" reply"}}}} +{"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":" exactly"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":" and"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"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":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/input.json b/examples/acp-agent/tests/snapshots/subagent-spawn/input.json new file mode 100644 index 0000000000..3cd6f5350d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl new file mode 100644 index 0000000000..210d14e773 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"4d76c4bd-1fca-418f-b2fe-b938d7398666","createdAt":1782087699201,"cwd":"/tmp/acp-snap-cwd-s06Syv","parentSession":"9b045576-92f1-48ca-b854-9ba160449992"} +{"type":"turn/start","seq":0,"time":1782087699202,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087699202,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782087699202,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087699839,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087699839,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087699947,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":10,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":11,"time":1782087699977,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":12,"time":1782087700016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":13,"time":1782087700016,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":14,"time":1782087700017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":15,"time":1782087700017,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":16,"time":1782087700034,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":17,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":18,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":19,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":20,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":21,"time":1782087700035,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":22,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":23,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":24,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"CH"}}} +{"type":"assistant/chunk","seq":25,"time":1782087700064,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ILD"}}} +{"type":"assistant/chunk","seq":26,"time":1782087700107,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"_OK"}}} +{"type":"assistant/chunk","seq":27,"time":1782087700108,"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 \"CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":28,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":29,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":964,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":30,"time":1782087700108,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1782087700108,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"CHILD_OK\" and nothing else."},{"type":"text","text":"CHILD_OK"}],"usage":{"inputTokens":964,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1782087700108,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1782087700108,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl new file mode 100644 index 0000000000..f66439dbe1 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -0,0 +1,142 @@ +{"type":"session","version":0,"id":"9b045576-92f1-48ca-b854-9ba160449992","createdAt":1782087697853,"cwd":"/tmp/acp-snap-cwd-s06Syv"} +{"type":"turn/start","seq":0,"time":1782087697856,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} +{"type":"user/message","seq":1,"time":1782087697856,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"}},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1782087697857,"data":{"turn":1,"step":1}} +{"type":"assistant/chunk","seq":3,"time":1782087698282,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":4,"time":1782087698283,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":5,"time":1782087698376,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}} +{"type":"assistant/chunk","seq":6,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}} +{"type":"assistant/chunk","seq":7,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}} +{"type":"assistant/chunk","seq":8,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":9,"time":1782087698406,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" use"}}} +{"type":"assistant/chunk","seq":10,"time":1782087698407,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":11,"time":1782087698435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":12,"time":1782087698435,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":13,"time":1782087698436,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":14,"time":1782087698436,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" once"}}} +{"type":"assistant/chunk","seq":15,"time":1782087698464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":16,"time":1782087698464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":17,"time":1782087698464,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" specific"}}} +{"type":"assistant/chunk","seq":18,"time":1782087698492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" prompt"}}} +{"type":"assistant/chunk","seq":19,"time":1782087698492,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":20,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"Reply"}}} +{"type":"assistant/chunk","seq":21,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":22,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}} +{"type":"assistant/chunk","seq":23,"time":1782087698521,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":24,"time":1782087698522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" word"}}} +{"type":"assistant/chunk","seq":25,"time":1782087698522,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" CH"}}} +{"type":"assistant/chunk","seq":26,"time":1782087698549,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":27,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":29,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" nothing"}}} +{"type":"assistant/chunk","seq":30,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" else"}}} +{"type":"assistant/chunk","seq":31,"time":1782087698550,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}} +{"type":"assistant/chunk","seq":32,"time":1782087698578,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" Then"}}} +{"type":"assistant/chunk","seq":33,"time":1782087698579,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" after"}}} +{"type":"assistant/chunk","seq":34,"time":1782087698607,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" the"}}} +{"type":"assistant/chunk","seq":35,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":36,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":37,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" returns"}}} +{"type":"assistant/chunk","seq":38,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":","}}} +{"type":"assistant/chunk","seq":39,"time":1782087698608,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":40,"time":1782087698636,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" should"}}} +{"type":"assistant/chunk","seq":41,"time":1782087698665,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":42,"time":1782087698666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":43,"time":1782087698666,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":44,"time":1782087698694,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":45,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":46,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":47,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":48,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":49,"time":1782087698695,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":50,"time":1782087698726,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":51,"time":1782087698727,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":52,"time":1782087698727,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" No"}}} +{"type":"assistant/chunk","seq":53,"time":1782087698756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" bash"}}} +{"type":"assistant/chunk","seq":54,"time":1782087698756,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" tool"}}} +{"type":"assistant/chunk","seq":55,"time":1782087698785,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" usage"}}} +{"type":"assistant/chunk","seq":56,"time":1782087698818,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":57,"time":1782087698875,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":58,"time":1782087698875,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":""}}} +{"type":"assistant/chunk","seq":59,"time":1782087698921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"{"}}} +{"type":"assistant/chunk","seq":60,"time":1782087698921,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":61,"time":1782087698922,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"description"}}} +{"type":"assistant/chunk","seq":62,"time":1782087698933,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":63,"time":1782087698933,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":64,"time":1782087698934,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":65,"time":1782087698934,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":66,"time":1782087698959,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":67,"time":1782087698960,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":68,"time":1782087698988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":69,"time":1782087698988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":70,"time":1782087698988,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":71,"time":1782087699021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":", "}}} +{"type":"assistant/chunk","seq":72,"time":1782087699021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":73,"time":1782087699021,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"prom"}}} +{"type":"assistant/chunk","seq":74,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"pt"}}} +{"type":"assistant/chunk","seq":75,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":76,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":": "}}} +{"type":"assistant/chunk","seq":77,"time":1782087699050,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":78,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"Reply"}}} +{"type":"assistant/chunk","seq":79,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" with"}}} +{"type":"assistant/chunk","seq":80,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" exactly"}}} +{"type":"assistant/chunk","seq":81,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" the"}}} +{"type":"assistant/chunk","seq":82,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" word"}}} +{"type":"assistant/chunk","seq":83,"time":1782087699079,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" CH"}}} +{"type":"assistant/chunk","seq":84,"time":1782087699107,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"ILD"}}} +{"type":"assistant/chunk","seq":85,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"_OK"}}} +{"type":"assistant/chunk","seq":86,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" and"}}} +{"type":"assistant/chunk","seq":87,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" nothing"}}} +{"type":"assistant/chunk","seq":88,"time":1782087699108,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":" else"}}} +{"type":"assistant/chunk","seq":89,"time":1782087699137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"."}}} +{"type":"assistant/chunk","seq":90,"time":1782087699137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"\""}}} +{"type":"assistant/chunk","seq":91,"time":1782087699137,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":1,"id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","argumentsDelta":"}"}}} +{"type":"assistant/chunk","seq":92,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to use the subagent tool once with the specific prompt \"Reply with exactly the word CHILD_OK and nothing else.\" Then after the subagent returns, I should reply with \"PARENT_DONE\" and stop. No bash tool usage."}}}} +{"type":"assistant/chunk","seq":93,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":94,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1159,"outputTokens":128,"cacheReadTokens":0,"reasoningTokens":53}}}} +{"type":"assistant/chunk","seq":95,"time":1782087699198,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":96,"time":1782087699200,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to use the subagent tool once with the specific prompt \"Reply with exactly the word CHILD_OK and nothing else.\" Then after the subagent returns, I should reply with \"PARENT_DONE\" and stop. No bash tool usage."},{"type":"tool-call","id":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}],"usage":{"inputTokens":1159,"outputTokens":128,"cacheReadTokens":0,"reasoningTokens":53}},"sourceEventSeqs":[3,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],"surfaceOp":"append"} +{"type":"tool/call","seq":97,"time":1782087699200,"data":{"turn":1,"step":1,"callId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":98,"time":1782087700114,"data":{"turn":1,"step":1,"callId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","content":[{"type":"text","text":"CHILD_OK"}],"isError":false},"sourceEventSeqs":[97],"surfaceOp":"append"} +{"type":"step/end","seq":99,"time":1782087700114,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":100,"time":1782087700114,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":101,"time":1782087700497,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"assistant/chunk","seq":102,"time":1782087700497,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}} +{"type":"assistant/chunk","seq":103,"time":1782087700630,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" sub"}}} +{"type":"assistant/chunk","seq":104,"time":1782087700660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"agent"}}} +{"type":"assistant/chunk","seq":105,"time":1782087700660,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" returned"}}} +{"type":"assistant/chunk","seq":106,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":107,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"CH"}}} +{"type":"assistant/chunk","seq":108,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ILD"}}} +{"type":"assistant/chunk","seq":109,"time":1782087700661,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_OK"}}} +{"type":"assistant/chunk","seq":110,"time":1782087700687,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":111,"time":1782087700716,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" as"}}} +{"type":"assistant/chunk","seq":112,"time":1782087700717,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" expected"}}} +{"type":"assistant/chunk","seq":113,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":114,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" Now"}}} +{"type":"assistant/chunk","seq":115,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" I"}}} +{"type":"assistant/chunk","seq":116,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" need"}}} +{"type":"assistant/chunk","seq":117,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}} +{"type":"assistant/chunk","seq":118,"time":1782087700746,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}} +{"type":"assistant/chunk","seq":119,"time":1782087700774,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}} +{"type":"assistant/chunk","seq":120,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}} +{"type":"assistant/chunk","seq":121,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"PAR"}}} +{"type":"assistant/chunk","seq":122,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ENT"}}} +{"type":"assistant/chunk","seq":123,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"_D"}}} +{"type":"assistant/chunk","seq":124,"time":1782087700775,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"ONE"}}} +{"type":"assistant/chunk","seq":125,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"\""}}} +{"type":"assistant/chunk","seq":126,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" and"}}} +{"type":"assistant/chunk","seq":127,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":" stop"}}} +{"type":"assistant/chunk","seq":128,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"reasoning-delta","index":0,"text":"."}}} +{"type":"assistant/chunk","seq":129,"time":1782087700804,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":130,"time":1782087700805,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"PAR"}}} +{"type":"assistant/chunk","seq":131,"time":1782087700833,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ENT"}}} +{"type":"assistant/chunk","seq":132,"time":1782087700833,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"_D"}}} +{"type":"assistant/chunk","seq":133,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":134,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."}}}} +{"type":"assistant/chunk","seq":135,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":136,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":22,"outputTokens":32,"cacheReadTokens":1280,"reasoningTokens":27}}}} +{"type":"assistant/chunk","seq":137,"time":1782087700834,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":138,"time":1782087700834,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The subagent returned \"CHILD_OK\" as expected. Now I need to reply with \"PARENT_DONE\" and stop."},{"type":"text","text":"PARENT_DONE"}],"usage":{"inputTokens":22,"outputTokens":32,"cacheReadTokens":1280,"reasoningTokens":27}},"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,130,131,132,133,134,135,136,137],"surfaceOp":"append"} +{"type":"step/end","seq":139,"time":1782087700835,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":140,"time":1782087700835,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl new file mode 100644 index 0000000000..3c78183324 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/stdout.golden.jsonl @@ -0,0 +1,89 @@ +{"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}}"}} +{"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"}}}} +{"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":" use"}}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"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":" the"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" specific"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" prompt"}}}} +{"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":"Reply"}}}} +{"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":" exactly"}}}} +{"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":" word"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"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":" nothing"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" else"}}}} +{"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":" after"}}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returns"}}}} +{"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":" 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":" reply"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":" 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":"agent_thought_chunk","content":{"type":"text","text":" No"}}}} +{"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":" usage"}}}} +{"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_3R6FQL0Zk3D122Jpbu0Q9888","title":"subagent","kind":"other","status":"in_progress","rawInput":{"description":"Reply with CHILD_OK","prompt":"Reply with exactly the word CHILD_OK and nothing else."}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_3R6FQL0Zk3D122Jpbu0Q9888","status":"completed","content":[{"type":"content","content":{"type":"text","text":"CHILD_OK"}}]}}} +{"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":" sub"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"agent"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" returned"}}}} +{"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":"CH"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ILD"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_OK"}}}} +{"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":" as"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" expected"}}}} +{"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":" Now"}}}} +{"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":" need"}}}} +{"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":" reply"}}}} +{"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":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ENT"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"_D"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"ONE"}}}} +{"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":" 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":"agent_message_chunk","content":{"type":"text","text":"PAR"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"ENT"}}}} +{"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":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/coding-agent/cordis.yml b/examples/coding-agent/cordis.yml index 497aa8896a..0347115cd5 100644 --- a/examples/coding-agent/cordis.yml +++ b/examples/coding-agent/cordis.yml @@ -44,16 +44,55 @@ # under ./.sessions); unset starts a fresh session each run. resumeSessionId: !!js process.env.RESUME_SESSION_ID persistenceRoot: './.sessions' - welcome: 'coding-agent ready. Give it a coding task (bash is its only tool).' + welcome: 'coding-agent ready. Give it a coding task (its tools are bash and subagent).' systemPrompt: | You are coding-agent, a CLI coding assistant. - Your only tools are bash (plus bash_output/bash_kill for background - tasks). Do ALL file operations through bash: read with cat/sed/head, - search with grep, write with heredocs (cat <<'EOF' > file), edit - with sed or a rewrite. Each bash call runs in a fresh shell — pass - workdir instead of cd, and never rely on shell state between calls. + Your tools are bash (plus bash_output/bash_kill for background + tasks) and subagent. Do ALL file operations through bash: read with + cat/sed/head, search with grep, write with heredocs (cat <<'EOF' > + file), edit with sed or a rewrite. Each bash call runs in a fresh + shell — pass workdir instead of cd, and never rely on shell state + between calls. + + Use the subagent tool to delegate a focused, self-contained subtask + to a fresh child agent (it works in its own context and returns only + its final result) — give it a complete, standalone instruction. Use + subagent_fork instead when the subtask needs THIS conversation's + context: the child inherits the log so far. Check the [exit code: N] marker on every command; investigate failures before moving on. Verify your work by running the code or tests. Keep answers brief and factual. + +# The subagent seam + BOTH in-process backends + two model-facing tools, as leaf +# entries after the app (which provides ctx.agents/ctx.tools). spawn (a fresh +# child) and fork (a child seeded with the parent's completed-turn prefix) are +# independent backends over the shared dsh-subagent-inprocess driver. Exposing +# both transports is pure config: load each backend, then load dsh-tool-subagent +# once per backend with a distinct toolName (the tool registry rejects a +# duplicate name) — no code change. +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork diff --git a/knip.json b/knip.json index b3ce7c1d4b..67d99a861d 100644 --- a/knip.json +++ b/knip.json @@ -36,6 +36,14 @@ "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"] + }, + "packages/subagent/subagent-acp": { + "entry": ["tests/**/*.spec.ts", "tests/**/*.e2e.ts", "tests/mock-acp-server.ts"], + "project": ["src/**/*.ts", "tests/**/*.ts"] } } } diff --git a/package.json b/package.json index 7a0b770d1e..2e83cdca61 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ ], "scripts": { "build": "tsc -b tsconfig.build.json && tsdown", - "typecheck": "tsc -b tsconfig.build.json && tsc -p tsconfig.typecheck.json", + "clean:build": "rm -rf .typecheck packages/*/*/lib vendor/*/lib *.tsbuildinfo", + "typecheck": "tsc -b tsconfig.json", "lint": "eslint .", "lint:fix": "eslint . --fix", "test": "vitest run", @@ -30,13 +31,14 @@ "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", - "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints", + "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:acp": "node --import tsx packages/ui/acp-agent/src/bin.ts examples/acp-agent/cordis.yml", diff --git a/packages/README.md b/packages/README.md index f39d7b70a1..c24e95838f 100644 --- a/packages/README.md +++ b/packages/README.md @@ -11,6 +11,7 @@ Packages are grouped by modular role at `packages///`. The group dir | [`core/`](core/README.md) | Product API spine: session, system-prompt, tools, agent, and the concrete loop | Product — stable surface | | [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable surface | | [`bash/`](bash/README.md) | Bash capability family: the executor seam, a local impl, and the model-facing tool | Product — stable surface | +| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry seam and the model-facing delegation tool | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | | [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | @@ -37,6 +38,12 @@ dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks) dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence (ACP JSON-RPC bridge) dsh-ui-stdio ← dsh-agent, dsh-llm, dsh-session (stdio readline UI plugin) dsh-llm-replay ← dsh-llm, dsh-session (record/replay adapter for keyless snapshot tests) +dsh-subagent ← dsh-agent, dsh-llm, dsh-tools (abstract subagent provider-registry seam) +dsh-subagent-mock ← dsh-subagent (scripted provider for tests) +dsh-subagent-spawn ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (in-process fresh child + shared run driver) +dsh-subagent-fork ← dsh-subagent-spawn, dsh-agent, dsh-session (in-process child seeded from parent log) +dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk (out-of-process child over ACP) +dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent (model-facing delegation tool) dsh-agent-core ← timer, dsh-llm, dsh-session, dsh-system-prompt, dsh-tools, dsh-agent, dsh-invariants, dsh-tool-bash, dsh-agent-loop (the providerless spine, as one bundle plugin) dsh-stdio-agent ← dsh-agent-core, dsh-ui-stdio, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + bin) dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) @@ -69,6 +76,12 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | | `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | +| `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | +| `subagent-spawn/` | `subagent` | In-process backend: a fresh child agent (+ the shared in-process run driver) | (registers on `ctx.subagents`) | +| `subagent-fork/` | `subagent` | In-process backend: a child agent seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | +| `subagent-acp/` | `subagent` | Out-of-process backend: a child agent in a spawned subprocess, driven over the Agent Client Protocol | (registers on `ctx.subagents`) | +| `subagent-mock/` | `support` | Scripted `SubagentProvider` for testing the seam through the real load path | (registers on `ctx.subagents`) | +| `tool-subagent/` | `subagent` | Model-facing `subagent` delegation tool over `ctx.subagents` | (registers on `ctx.tools`) | | `brand/` | `util` | Type-only `Branded` nominal-typing primitive (no runtime code, no harness deps) | (none — type-only) | Each package has its own `README.md` with purpose, service API, events, extension points, and deliberate non-goals (TODOs). @@ -79,5 +92,5 @@ Each package has its own `README.md` with purpose, service API, events, extensio - **Declaration merging for events and ctx**: services declare their events in `declare module 'cordis' { interface Events { ... } }` and their ctx key in `interface Context`. - **Waterfall semantics**: `ctx.waterfall` listeners receive `(...args, next)` and MUST call `next()` to delegate; returning without it short-circuits (the veto mechanism). - **Extensible unions**: `ContentBlockMap`, `MessageSourceMap`, `FinishReasonMap`, `TurnTriggerMap`, `TurnEndReasonMap`, and `SessionEventMap` use the merge-extensible-map pattern so plugins can add variants via declaration merging. -- **ESM everywhere**; imports use package names across package boundaries, `.ts` extensions within a package. +- **ESM everywhere**; imports use package names across package boundaries and explicit `.ts` relative specifiers within a package. - **Tests**: vitest, colocated under `packages///tests/*.spec.ts`. Every registry needs an HMR-safety test. Err on the side of more tests. diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index b786c1bc37..bc1dc7eb40 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash/bash-local/tsconfig.json b/packages/bash/bash-local/tsconfig.json index 51ae489658..ae31546543 100644 --- a/packages/bash/bash-local/tsconfig.json +++ b/packages/bash/bash-local/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 66c02408d1..f1bc43c2b4 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash/bash/tsconfig.json b/packages/bash/bash/tsconfig.json index 0e8e8c1878..342f636170 100644 --- a/packages/bash/bash/tsconfig.json +++ b/packages/bash/bash/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index aaf4fde4cc..f9092fabb6 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/bash/tool-bash/tsconfig.json b/packages/bash/tool-bash/tsconfig.json index 6cd94d1d9a..89b10bfea8 100644 --- a/packages/bash/tool-bash/tsconfig.json +++ b/packages/bash/tool-bash/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/agent-core/package.json b/packages/core/agent-core/package.json index d6e716835b..a70ee30e71 100644 --- a/packages/core/agent-core/package.json +++ b/packages/core/agent-core/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/agent-core/tsconfig.json b/packages/core/agent-core/tsconfig.json index 3cf1e3fb74..83bf06c586 100644 --- a/packages/core/agent-core/tsconfig.json +++ b/packages/core/agent-core/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index b9744eab2a..6e92adb6ab 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 90d641eeeb..ab95ea5aac 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -121,10 +121,6 @@ export class AgentLoop extends Service implements AgentFactory { * deliberate resume-or-create policy (resume the prior session if one exists, * else start fresh) or an explicit caller-chosen session id — revisit when the * UI/ACP path owns session selection. - * - * TODO(sub-agents): spawn/fork land here — accept a parent agent reference; - * fork seeds the new Session with the parent's event log, spawn starts - * fresh; the child is returned as a regular Agent handle. */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) @@ -140,16 +136,22 @@ export class AgentLoop extends Service implements AgentFactory { /** * Programmatic factory create ({@link AgentFactory}): an agent on a * caller-supplied `sessionId` (NOT `${id}-session`), with optional session - * metadata (validated `cwd`, lineage). The ACP bridge uses this so the - * client-generated session id becomes the live/persisted session id. Returns - * an {@link AgentHandle} the owner disposes to tear down exactly this agent. + * metadata (validated `cwd`, lineage) and an optional `seed` event prefix. The + * ACP bridge uses this so the client-generated session id becomes the + * live/persisted session id; the in-process FORK subagent backend passes a + * `seed` (a balanced completed-turn prefix of the parent's log) so the child + * starts with the parent's context. Returns an {@link AgentHandle} the owner + * disposes to tear down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a // duplicate id only AFTER the session enters the store, leaving an orphaned // live session (and lazy persistence state) that blocks reuse of that id. this.assertAgentIdFree(options.agentId) - const session = this.ctx.sessions.prepare(options.sessionId, { meta: options.meta ?? {} }) + const session = this.ctx.sessions.prepare(options.sessionId, { + ...options.seed !== undefined ? { seed: options.seed } : {}, + meta: options.meta ?? {}, + }) return this.startOwned(options.agentId, options.agentOptions ?? {}, session) } @@ -217,6 +219,9 @@ export class AgentLoop extends Service implements AgentFactory { createdAt: meta.createdAt, ...meta.cwd !== undefined ? { cwd: meta.cwd } : {}, ...meta.parentSession !== undefined ? { parentSession: meta.parentSession } : {}, + // Reconstruct the seed boundary from the persisted header, NOT from + // `events.length` (the resume seeds the WHOLE stored log). + ...meta.seedLength !== undefined ? { seedLength: meta.seedLength } : {}, }, }) return this.startOwned(options.agentId, options.agentOptions ?? {}, session) diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index dd3dc58257..ceef1bca8e 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -570,6 +570,7 @@ async function runStep( messages: session.deriveMessages(), ...system ? { system } : {}, ...assembly.tools.length > 0 ? { tools: assembly.tools } : {}, + sessionId: session.id, signal, } request = await ctx.waterfall('agent/request', agent, turn, step, request, () => Promise.resolve(request)) diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 6192396cab..074e84d78a 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -94,9 +94,9 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { await ctx2.fiber.dispose() }) - it('resume of a forked session preserves the parentSession lineage in the header', async () => { - // Lifecycle 1: persist a FORKED session (carries parentSession in its - // header) by creating it with a complete-turn seed — the write path + it('resume of a forked session preserves the parentSession lineage and seed boundary in the header', async () => { + // Lifecycle 1: persist a FORKED session (carries parentSession + seedLength + // in its header) by creating it with a complete-turn seed — the write path // materializes the fork (header + seed) on disk. const seed: SessionEvent[] = [ { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, @@ -104,12 +104,18 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { ] const adapter1 = new MockAdapter([textResponse('a')]) const { ctx: ctx1, root } = await persistentHarness(adapter1) - const forked = ctx1.sessions.create(SessionId('forked-sess'), { seed, meta: { cwd: '/w', parentSession: SessionId('parent-sess') } }) + const forked = ctx1.sessions.create(SessionId('forked-sess'), { + seed, + meta: { cwd: '/w', parentSession: SessionId('parent-sess'), seedLength: seed.length }, + }) await ctx1.parallel('session/flush', forked) await ctx1.fiber.dispose() - // Lifecycle 2: resume it; the parentSession header survives the round-trip - // (exercises resume's parentSession-present branch). + // Lifecycle 2: resume it; the parentSession + seedLength header survives the + // round-trip (exercises resume's parentSession- and seedLength-present + // branches). seedLength must come from the PERSISTED header, not from the + // resume seed length (which is the whole stored log, not the original + // boundary). const adapter2 = new MockAdapter([textResponse('b')]) const ctx2 = new Context() await ctx2.plugin(LlmService) @@ -123,6 +129,7 @@ describe('the session-persistence RFC: AgentLoop factory create/resume', () => { const a2 = (await ctx2.agents.resume({ agentId: AgentId('m'), resumeSessionId: SessionId('forked-sess') })).agent as ReactLoopAgent expect(a2.session.header.parentSession).toBe('parent-sess') expect(a2.session.header.cwd).toBe('/w') + expect(a2.session.header.seedLength).toBe(seed.length) await ctx2.fiber.dispose() }) diff --git a/packages/core/agent-loop/tsconfig.json b/packages/core/agent-loop/tsconfig.json index e8a471a08c..03df67cfc4 100644 --- a/packages/core/agent-loop/tsconfig.json +++ b/packages/core/agent-loop/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 408fd2ae69..fa6946b8cf 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 158946178c..940a5c9db1 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -6,7 +6,7 @@ */ import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import type { Agent, AgentId, AgentOptions } from './types.ts' export * from './types.ts' @@ -30,13 +30,25 @@ export interface CreateAgentOptions { /** The live session's id (NOT derived from agentId). */ sessionId: SessionId /** - * Session creation metadata: validated absolute `cwd` and `parentSession` - * fork lineage. Mirrors the `cwd`/`parentSession` fields of + * Session creation metadata: validated absolute `cwd`, `parentSession` + * fork lineage, and the `seedLength` seed boundary. Mirrors the + * `cwd`/`parentSession`/`seedLength` fields of * {@link CreateSessionOptions.meta} in dsh-session (the internal-only * `createdAt`, used when reconstructing a persisted session, is deliberately * excluded — a factory caller never sets it). */ - meta?: { cwd?: string; parentSession?: SessionId } + meta?: { cwd?: string; parentSession?: SessionId; seedLength?: number } + /** + * Seed events to reconstruct the child session's log from (the fork lineage + * primitive). When present, the factory creates the session with this event + * prefix so `deriveMessages()`/`lastTurnNumber` continue from it — used by the + * in-process FORK subagent backend to seed a child with a balanced + * completed-turn prefix of the parent's log. The prefix MUST be contiguous + * from seq 0 and balanced (no open turn/step, no dangling tool-call), or the + * session constructor (and the dev-mode invariants replay) reject it. Absent + * for a fresh (spawn) child. + */ + seed?: SessionEvent[] /** Per-agent options (model, system prompt). */ agentOptions?: AgentOptions } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index 6d27bf7256..efe392155c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -118,11 +118,12 @@ export interface Agent { */ whenIdle(): Promise - // TODO(sub-agents): spawn/fork seams — semantics deliberately deferred. - // The intended shape: a creation option referencing a parent agent - // (fork = seed the child Session with the parent's event log; spawn = - // fresh Session), with the child returned as an Agent handle so steer() - // and event subscription work uniformly. See docs/architecture.md. + // 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. } declare module 'cordis' { diff --git a/packages/core/agent/tsconfig.json b/packages/core/agent/tsconfig.json index 7a8eaa6e17..4d23ac46d3 100644 --- a/packages/core/agent/tsconfig.json +++ b/packages/core/agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 8bd0d98abe..8c6645e37e 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index 5df4db58e9..8908059dae 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -360,6 +360,7 @@ export class SessionStore extends Service { createdAt: options?.meta?.createdAt ?? Date.now(), ...cwd !== undefined ? { cwd } : {}, ...options?.meta?.parentSession !== undefined ? { parentSession: options.meta.parentSession } : {}, + ...options?.meta?.seedLength !== undefined ? { seedLength: options.meta.seedLength } : {}, } return new Session(sessionId, options?.seed, header) } diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 44e8e35fdf..41f878892f 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -50,6 +50,16 @@ export interface SessionHeader { cwd?: string /** The session this one was forked from (seed lineage), if any. */ 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). + */ + seedLength?: number } /** @@ -63,10 +73,16 @@ export interface CreateSessionOptions { /** * 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, and — when reconstructing a - * persisted session — the original `createdAt` to preserve it). + * 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. */ - meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number } + meta?: { cwd?: string; parentSession?: SessionId; createdAt?: number; seedLength?: number } } /** diff --git a/packages/core/session/tsconfig.json b/packages/core/session/tsconfig.json index ca6113bb3f..7ca1556695 100644 --- a/packages/core/session/tsconfig.json +++ b/packages/core/session/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index eed76b8907..672f7a03ef 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/system-prompt/tsconfig.json b/packages/core/system-prompt/tsconfig.json index 3423a0e06c..9f687793d7 100644 --- a/packages/core/system-prompt/tsconfig.json +++ b/packages/core/system-prompt/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index a92015e55c..a6d3bbe0ca 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/core/tools/tsconfig.json b/packages/core/tools/tsconfig.json index 27219e926d..dedc111d87 100644 --- a/packages/core/tools/tsconfig.json +++ b/packages/core/tools/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 077a2db169..8ebf71c5b5 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm/llm-deepseek/tsconfig.json b/packages/llm/llm-deepseek/tsconfig.json index b187cddf35..e9de391ba1 100644 --- a/packages/llm/llm-deepseek/tsconfig.json +++ b/packages/llm/llm-deepseek/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 6eb08a4b06..30911915ff 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index b187cddf35..e9de391ba1 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 0983c893d3..1dc84e13d7 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 63fc0f5b0c..7b1b9bdc47 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -19,6 +19,7 @@ * ``` */ +import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId } from './brand.ts' /** Cache hint attached to a content block (provider-interpreted). */ @@ -192,4 +193,18 @@ export interface GenerateOptions { */ stop?: string[] signal?: AbortSignal + /** + * The id of the session this request belongs to — stamped by the agent loop + * from `agent.session.id`. Adapters ignore it; it lets an `llm/stream` listener + * route a call by WHICH session issued it (the replay adapter keys its per-call + * cursor by session, so a parent and its in-process subagent — each with its + * own session on one context — replay from their own recorded scripts). + * + * Typed as `Branded<'SessionId'>` rather than importing `SessionId` from + * `dsh-session`: that package imports `Message` from here, so importing its + * `SessionId` back would cycle. `SessionId` IS `Branded<'SessionId'>`, so a + * real session id assigns with no cast. (A future ids package could own the + * brand and dissolve this note.) + */ + sessionId?: Branded<'SessionId'> } diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index 0e8e8c1878..342f636170 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/session-persistence/session-persistence-jsonl/package.json b/packages/session-persistence/session-persistence-jsonl/package.json index 6193af910b..ac18a38838 100644 --- a/packages/session-persistence/session-persistence-jsonl/package.json +++ b/packages/session-persistence/session-persistence-jsonl/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence/session-persistence-jsonl/src/format.ts b/packages/session-persistence/session-persistence-jsonl/src/format.ts index 32258c6a37..63cf899e45 100644 --- a/packages/session-persistence/session-persistence-jsonl/src/format.ts +++ b/packages/session-persistence/session-persistence-jsonl/src/format.ts @@ -24,6 +24,7 @@ export interface HeaderLine { createdAt: number cwd?: string parentSession?: SessionId + seedLength?: number } /** Build the header line object from a {@link SessionHeader}. */ @@ -35,6 +36,7 @@ export function toHeaderLine(header: SessionHeader): HeaderLine { createdAt: header.createdAt, ...header.cwd !== undefined ? { cwd: header.cwd } : {}, ...header.parentSession !== undefined ? { parentSession: header.parentSession } : {}, + ...header.seedLength !== undefined ? { seedLength: header.seedLength } : {}, } } @@ -46,6 +48,7 @@ export function fromHeaderLine(line: HeaderLine): SessionHeader { createdAt: line.createdAt, ...line.cwd !== undefined ? { cwd: line.cwd } : {}, ...line.parentSession !== undefined ? { parentSession: line.parentSession } : {}, + ...line.seedLength !== undefined ? { seedLength: line.seedLength } : {}, } } diff --git a/packages/session-persistence/session-persistence-jsonl/tsconfig.json b/packages/session-persistence/session-persistence-jsonl/tsconfig.json index adb2824e27..23970f5a57 100644 --- a/packages/session-persistence/session-persistence-jsonl/tsconfig.json +++ b/packages/session-persistence/session-persistence-jsonl/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/session-persistence/session-persistence-sqlite/package.json b/packages/session-persistence/session-persistence-sqlite/package.json index 463cf683be..b26c69461e 100644 --- a/packages/session-persistence/session-persistence-sqlite/package.json +++ b/packages/session-persistence/session-persistence-sqlite/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index d5a7a008ef..412c3b58fc 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -241,19 +241,21 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers */ private writeRow(meta: SessionHeader): void { this.db.prepare(` - INSERT INTO sessions (id, version, created_at, cwd, parent_session) - VALUES (?, ?, ?, ?, ?) + INSERT INTO sessions (id, version, created_at, cwd, parent_session, seed_length) + VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET version = excluded.version, created_at = excluded.created_at, cwd = excluded.cwd, - parent_session = excluded.parent_session + parent_session = excluded.parent_session, + seed_length = excluded.seed_length `).run( meta.id, meta.version, meta.createdAt, meta.cwd ?? null, meta.parentSession ?? null, + meta.seedLength ?? null, ) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index b747b7900c..37acc6f0f0 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -30,6 +30,7 @@ export interface SessionRow { created_at: number cwd: string | null parent_session: string | null + seed_length: number | null } /** An `events` table row: one `SessionEvent` mapped 1:1 (`data` is JSON text). */ @@ -55,8 +56,9 @@ export interface EventRow { * current {@link SCHEMA_VERSION}; an existing database whose version is NOT the * current one (written by a different, incompatible build — older or newer) is * REJECTED rather than opened against a layout this build does not understand. - * There are no migrations: v1 had a different `sessions` layout and is not - * upgraded in place. + * There are no migrations: an earlier layout (v1's different `sessions` shape, + * v2 without the `seed_length`/`source_event_seqs`/`surface_op` columns) is not + * upgraded in place — it is rejected. */ export function openDatabase(path: string): DatabaseSync { const db = new DatabaseSync(path) @@ -80,7 +82,8 @@ export function openDatabase(path: string): DatabaseSync { version INTEGER NOT NULL, created_at INTEGER NOT NULL, cwd TEXT, - parent_session TEXT + parent_session TEXT, + seed_length INTEGER ) STRICT `) db.exec(` @@ -106,6 +109,7 @@ export function rowToMeta(row: SessionRow): SessionHeader { createdAt: row.created_at, ...row.cwd !== null ? { cwd: row.cwd } : {}, ...row.parent_session !== null ? { parentSession: row.parent_session as SessionId } : {}, + ...row.seed_length !== null ? { seedLength: row.seed_length } : {}, } } diff --git a/packages/session-persistence/session-persistence-sqlite/tsconfig.json b/packages/session-persistence/session-persistence-sqlite/tsconfig.json index adb2824e27..23970f5a57 100644 --- a/packages/session-persistence/session-persistence-sqlite/tsconfig.json +++ b/packages/session-persistence/session-persistence-sqlite/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/session-persistence/session-persistence/package.json b/packages/session-persistence/session-persistence/package.json index bd84fd1826..ed6c80dfd9 100644 --- a/packages/session-persistence/session-persistence/package.json +++ b/packages/session-persistence/session-persistence/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts index 71cd296fb0..a21e5a7183 100644 --- a/packages/session-persistence/session-persistence/tests/coordinator-contract.ts +++ b/packages/session-persistence/session-persistence/tests/coordinator-contract.ts @@ -123,6 +123,26 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) + it('round-trips the seed boundary (seedLength) through persistence', async () => { + // A forked child records how many leading events were inherited via the + // seed; the boundary must survive a reload (so a resume/replay can tell the + // inherited prefix from the child's own events). Both backends carry it on + // the header — JSONL on the header line, SQLite in the seed_length column. + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const session = ctx.sessions.create(SessionId('forked-child'), { meta: { cwd: WORK, seedLength: 3 } }) + send(session, oneTurnLog()) + await ctx.parallel('session/flush', session) + + const loaded = await ctx.sessionPersistence.load(SessionId('forked-child')) + expect(loaded.meta.seedLength).toBe(3) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + it('snapshot-on-buffer: mutating an event after session/event does not corrupt the persisted copy', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) diff --git a/packages/session-persistence/session-persistence/tsconfig.json b/packages/session-persistence/session-persistence/tsconfig.json index df07556965..e817086a6a 100644 --- a/packages/session-persistence/session-persistence/tsconfig.json +++ b/packages/session-persistence/session-persistence/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/subagent/README.md b/packages/subagent/README.md new file mode 100644 index 0000000000..6da8ee42f5 --- /dev/null +++ b/packages/subagent/README.md @@ -0,0 +1,16 @@ +# subagent/ — subagent capability family + +The subagent seam: an agent delegating work to a child agent. Like the [bash](../bash/README.md) and [llm](../llm/README.md) families this is a capability seam (see [capability seams](../../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)) — but with one defining difference: **multiple provider implementations coexist in one context**, registered by name, rather than the single-implementation bash shape. The registry mirrors the LLM adapter registry. + +| Package | Role | ctx key | +|---|---|---| +| `subagent/` | Abstract subagent seam: named-provider registry + vocabulary | `ctx.subagents` | +| `subagent-inprocess/` | Shared in-process run driver (pure lib; registers nothing) | — | +| `subagent-spawn/` | In-process backend: a fresh child agent | (registers on `ctx.subagents`) | +| `subagent-fork/` | In-process backend: a child seeded with the parent's completed-turn prefix | (registers on `ctx.subagents`) | +| `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 pure library — both depend on it, neither on the other), and the out-of-process `subagent-acp` backend ships alongside them here; the test-only `dsh-subagent-mock` (in [support](../support/README.md)) is separate. All **product** packages except the mock. + +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/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md new file mode 100644 index 0000000000..03990a7369 --- /dev/null +++ b/packages/subagent/subagent-acp/README.md @@ -0,0 +1,69 @@ +# @deepseek-ai/dsh-subagent-acp + +The out-of-process **ACP subagent backend**: runs each child agent in a spawned subprocess, driven over the [Agent Client Protocol](https://github.com/zed-industries/agent-client-protocol) (ACP) as the *client*. Registers a `SubagentProvider` on `ctx.subagents` (the [subagent seam](../subagent)), alongside the in-process [`-spawn`](../subagent-spawn)/[`-fork`](../subagent-fork) backends — multiple backends coexist by name. + +It is the direction-inverted twin of the server-side bridge in [`@deepseek-ai/dsh-acp`](../../ui/acp): that package is the ACP *agent* (it answers `initialize`/`newSession`/`prompt`); this one is the ACP *client* (it *calls* them and implements the `sessionUpdate`/`requestPermission` callbacks). Point the configured command at the `acp-agent` example to "talk to our own process". + +## What it does + +`start(request)` spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, and drives one session: `initialize` → `newSession` → `prompt`. The child's streamed `agent_message_chunk` text becomes the `SubagentResult.output`; the prompt's terminal `StopReason` maps to the stop reason. `dispose()` kills the subprocess and awaits its exit. + +**Fresh process per run.** Each `start` spawns a new child, runs exactly one ACP session, and disposes it. Persistent-process pooling is a future optimization (see the RFC). + +Unlike the in-process backends, the child does NOT share this cordis context — it is a separate process with its own session, model client, and tools. So this backend: +- injects only `subagents` (no `ctx.agents`); +- advertises NO start-time capabilities (an out-of-process child can't enforce the parent's depth/tool-filter); +- ignores `request.parent`. + +## Config + +| Key | Type | Default | Notes | +|---|---|---|---| +| `providerName` | string | `acp` | Registry name on `ctx.subagents`. | +| `command` | string | — (required) | The executable to spawn for each run (the child ACP agent). | +| `args` | string[] | `[]` | Arguments passed to `command`. | +| `cwd` | string | parent cwd | Working directory for the child process and its ACP session. | +| `permission` | `'allow' \| 'reject'` | `reject` | How to auto-answer the child's `session/request_permission` prompts. `reject` declines every prompt (answer `cancelled`); `allow` approves via the first allow-shaped option. The first cut surfaces no prompt to a human. | +| `env` | Record | `{}` | Extra env vars for the child (e.g. its own `DEEPSEEK_API_KEY`). Forwarded on top of a credential-scrubbed copy of the parent env, so an explicit key reaches the child while ambient secrets do not leak implicitly. | + +```yaml +- id: subagent-acp + name: '@deepseek-ai/dsh-subagent-acp' + config: + providerName: acp + command: node + args: ['--import', 'tsx', './packages/ui/acp-agent/src/bin.ts', './examples/acp-agent/cordis.yml'] + permission: reject + env: + DEEPSEEK_API_KEY: !!js process.env.DEEPSEEK_API_KEY +``` + +## StopReason mapping + +ACP `StopReason` → harness `SubagentStopReason`: + +| ACP | harness | +|---|---| +| `end_turn` | `completed` | +| `max_tokens` | `max-tokens` | +| `refusal` | `refusal` | +| `cancelled` | `aborted` | +| `max_turn_requests` | `error` (no clean equivalent; the task did not finish) | +| _(unknown)_ | `error` | + +A spawn/transport/RPC failure resolves `error` (or `aborted` if a cancel was requested) — `result` never rejects on a child-level failure, per the seam contract. + +## Environment scrub + +Credential-shaped ambient vars (`/KEY|SECRET|TOKEN/i`) are NOT forwarded to the child by default — the parent harness's own secrets must not leak into a spawned process implicitly. The child's OWN credentials are supplied explicitly via `config.env`, layered AFTER the scrub, so an intended `DEEPSEEK_API_KEY` survives while an incidental `AWS_SECRET_ACCESS_KEY` does not. + +## Testing + +- **Keyless** (`subagent-acp.spec.ts`): spawns a scripted mock ACP server subprocess (`tests/mock-acp-server.ts`) and drives it through the real backend over real ACP stdio — connection setup, client callbacks, the prompt round-trip, stop-reason mapping, cancellation (including the early-cancel race and a torn-pipe-after-cancel), permission auto-answer, and quiescent disposal. No model, no key. +- **With-key e2e** (`subagent-acp.e2e.ts`): the harness drives ITSELF — the backend spawns the real `acp-agent` example process and a real model in that child answers a prompt and does real file work (verified on disk). Self-skips without `DEEPSEEK_API_KEY`. + +`TODO(acp-subagent-replay)`: snapshot-tier coverage of an ACP child is a separate replay shape (each child is its own PROCESS with its own single-agent replay, distinct from the in-process per-session keying), deferred — see the RFC. + +## Plugin export shape + +Named `name` / `inject` / `Config` / `apply`, with **no default export**: the cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray default would collapse the module to the bare function and drop the `inject` namespace (see [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md)). diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json new file mode 100644 index 0000000000..2c55051da9 --- /dev/null +++ b/packages/subagent/subagent-acp/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-subagent-acp", + "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", + "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-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.25.1", + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts new file mode 100644 index 0000000000..037d32889e --- /dev/null +++ b/packages/subagent/subagent-acp/src/index.ts @@ -0,0 +1,95 @@ +/** + * The out-of-process ACP subagent backend: registers a {@link SubagentProvider} + * on `ctx.subagents` that runs each child agent in a SPAWNED SUBPROCESS, driven + * over the Agent Client Protocol (ACP) as the client. The parent process is the + * ACP client; the child is any ACP agent (point the configured command at the + * `acp-agent` example to "talk to our own process"). + * + * Unlike the in-process backends (`-spawn`/`-fork`), the child does NOT share + * this cordis context — it is a separate process with its own session, model + * client, and tools. So this backend injects only `subagents` (no `agents`), + * advertises NO start-time capabilities (an out-of-process child cannot enforce + * the parent's depth/tool-filter), and ignores `request.parent`. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default + * export (the cordis Loader's `unwrapExports` does `exports.default ?? exports`, + * so a stray default would drop the namespace — see docs/postmortem/0001). + * + * @module @deepseek-ai/dsh-subagent-acp + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { type AcpRunSpec, type PermissionPolicy, startAcpRun } from './run.ts' + +export const name = 'subagent-acp' +export const inject = ['subagents'] + +/** Config: how to spawn and drive the child ACP agent process. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `acp`). */ + providerName: string + /** The executable to spawn for each run (the child ACP agent). */ + command: string + /** Arguments passed to {@link command}. */ + args: string[] + /** + * Working directory for the child process and its ACP session. Defaults to + * the parent process's cwd when omitted. + */ + cwd?: string + /** + * How to auto-answer the child's `session/request_permission` prompts: + * `reject` (default — decline every prompt) or `allow` (approve via the first + * allow-shaped option). The first cut surfaces no prompt to a human. + */ + permission: PermissionPolicy + /** + * Extra environment variables for the child process — e.g. the child + * harness's own `DEEPSEEK_API_KEY`. Forwarded on top of a credential-scrubbed + * copy of the parent env, so an explicit key here reaches the child while + * ambient secrets do not leak implicitly. + */ + env: Record +} + +export const Config: z = z.object({ + providerName: z.string().default('acp'), + command: z.string().required(), + args: z.array(z.string()).default([]), + cwd: z.string(), + permission: z.union(['allow', 'reject'] as const).default('reject'), + env: z.dict(z.string()).default({}), +}) + +/** + * The ACP provider. Advertises NO start-time capabilities: an out-of-process + * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects + * a request needing any of them before `start` runs). + */ +class AcpProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context, private readonly config: Config) {} + + start(request: SubagentStartRequest) { + const spec: AcpRunSpec = { + command: this.config.command, + args: this.config.args, + cwd: this.config.cwd ?? process.cwd(), + permission: this.config.permission, + env: this.config.env, + onError: (error, stopReason) => { + // The seam forbids `result` rejecting, so a child-level failure is + // flattened to a stop reason — preserve it here rather than losing it. + this.ctx.logger.warn(`subagent-acp "${this.name}": child run failed (${stopReason}): ${error.message}`) + }, + } + return startAcpRun(request, spec) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config)) +} diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts new file mode 100644 index 0000000000..06f7a9ece8 --- /dev/null +++ b/packages/subagent/subagent-acp/src/run.ts @@ -0,0 +1,401 @@ +/** + * The out-of-process ACP subagent run driver. Spawns a child agent as a + * subprocess, speaks the Agent Client Protocol (ACP) to it over stdio as the + * CLIENT, drives one session to completion, and shapes the result into a + * {@link SubagentResult}. The mirror image of the server-side bridge in + * `@deepseek-ai/dsh-acp` (which is the ACP *agent* side): here we are the ACP + * *client*, so we CALL `initialize`/`newSession`/`prompt`/`cancel` and we + * IMPLEMENT the `Client` callbacks (`sessionUpdate`, `requestPermission`). + * + * One subprocess per run (fresh-process-per-run): `start` spawns, runs exactly + * one ACP session, and `dispose` kills the subprocess and awaits its exit. + * Persistent-process pooling is a future optimization (see the RFC). + * + * TODO(acp-subagent-replay): snapshot-tier coverage of an ACP child is a + * distinct replay shape — each child is its own PROCESS with its own + * single-agent replay (the child boots under `DSH_SNAPSHOT=replay` with its own + * sessions-root + fixture), unlike the in-process per-session keying in + * `dsh-llm-replay`. Deferred to a follow-up; keyless coverage here is via a + * scripted mock ACP server subprocess, and the with-key e2e drives the real + * `acp-agent` example. See the ACP-subagent-backend RFC. + * + * @module @deepseek-ai/dsh-subagent-acp/run + */ + +import { spawn, type ChildProcess } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { Readable, Writable } from 'node:stream' +import { + ClientSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent as AcpAgent, + type Client, + type ContentBlock as AcpContentBlock, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type StopReason, +} from '@agentclientprotocol/sdk' +import { AgentId } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +/** + * How the client answers a child's `session/request_permission`. The first cut + * does not surface permission prompts to a human, so every request is + * auto-answered by this fixed policy: + * + * - `reject` — decline every prompt (answer `cancelled`). Safe default: a child + * that asks before a side effect does not get to take it. + * - `allow` — approve every prompt by selecting its first `allow_*` option (or, + * if none is offered, `cancelled`). Use when the child is trusted to act. + */ +export type PermissionPolicy = 'allow' | 'reject' + +/** Resolved spawn spec for an ACP child process (no defaults — see Config). */ +export interface AcpRunSpec { + /** The executable to spawn (the child ACP agent). */ + command: string + /** Arguments passed to {@link command}. */ + args: string[] + /** Working directory for the child process AND its ACP session `cwd`. */ + cwd: string + /** How to auto-answer the child's permission prompts. */ + permission: PermissionPolicy + /** + * Extra environment variables to ADD for the child (e.g. the child harness's + * `DEEPSEEK_API_KEY`). Merged on top of the scrubbed ambient env — see + * {@link buildChildEnv}. A value here is forwarded even if its name matches + * the credential-scrub pattern (an explicit opt-in for the child's own creds). + */ + env: Record + /** + * Grace period (ms) for the child's EOF-driven quiesce in + * {@link SubagentRun.dispose} — the window to flush persistence and tear down + * its OWN nested subprocesses before the parent escalates to a signal. Defaults + * to {@link DEFAULT_DISPOSE_EOF_GRACE_MS}; a test injects a small value. + */ + disposeEofGraceMs?: number + /** + * Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation in + * {@link SubagentRun.dispose}. Defaults to {@link DEFAULT_DISPOSE_GRACE_MS}; + * a test injects a small value to exercise the escalation without a long wait. + */ + disposeGraceMs?: number + /** + * Sink for a child-level failure that the run flattened into a stop reason + * (the seam contract forbids `result` rejecting). The driver calls this with + * the original error and the chosen stop reason so the fault is preserved + * rather than silently lost; the provider wires it to `ctx.logger.warn`. + * Optional — omitted in a unit test that asserts the stop reason directly. + */ + onError?: (error: Error, stopReason: SubagentStopReason) => void +} + +/** + * Default grace for the child's EOF-driven quiesce on dispose — the window for it + * to flush persistence and tear down its OWN nested subprocesses (which may run + * their own `SIGTERM`→`SIGKILL` escalation) before the parent escalates to a + * signal. Deliberately LARGER than {@link DEFAULT_DISPOSE_GRACE_MS}: a cooperative + * child whose teardown is itself waiting on a signal-trapping grandchild (e.g. a + * bash subprocess in its own ~3s SIGTERM→SIGKILL grace) plus a final flush needs + * MORE than a single signal-grace of headroom, or the parent's SIGTERM cuts it off + * exactly as it reaches its own SIGKILL+flush. The child is an arbitrary ACP agent, + * so this is a standalone generous default, NOT derived from any child's internals. + */ +export const DEFAULT_DISPOSE_EOF_GRACE_MS = 6_000 + +/** Default grace between SIGTERM and SIGKILL on dispose (mirrors the bash executor). */ +export const DEFAULT_DISPOSE_GRACE_MS = 3_000 + +/** + * Credential-shaped ambient env vars are NOT forwarded to the child by default + * (the parent harness's own `DEEPSEEK_API_KEY`/secrets must not leak into a + * spawned process implicitly). Same pattern as the bash executor. The child + * agent needs its OWN credentials to reach a model — those are supplied + * explicitly via {@link AcpRunSpec.env}, which is layered on top AFTER 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 + +/** The ambient env minus credential-shaped vars, plus the spec's explicit env. */ +export function buildChildEnv(extra: Record): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {} + for (const [key, value] of Object.entries(process.env)) { + if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value + } + return { ...env, ...extra } +} + +/** Map an ACP {@link StopReason} to a harness {@link SubagentStopReason}. */ +export function acpStopReason(reason: StopReason): SubagentStopReason { + switch (reason) { + case 'end_turn': + return 'completed' + case 'max_tokens': + return 'max-tokens' + case 'refusal': + return 'refusal' + case 'cancelled': + return 'aborted' + // `max_turn_requests` (the child hit its turn-request budget) has no direct + // harness equivalent and means the task did NOT finish cleanly — surface it + // as a generic failure so the consumer maps it to an isError result rather + // than reporting a partial answer as success. + case 'max_turn_requests': + return 'error' + // ACP StopReason is a closed wire union, but a future SDK could add a + // variant; treat an unknown terminal reason as a failure (never silently + // 'completed'). + default: + return 'error' + } +} + +/** Collect the text of an ACP content block (non-text blocks contribute nothing). */ +export function acpContentText(content: AcpContentBlock): string { + return content.type === 'text' ? content.text : '' +} + +/** Translate the harness prompt blocks into ACP prompt blocks (text only). */ +export function toAcpPrompt(prompt: ContentBlock[]): AcpContentBlock[] { + const blocks: AcpContentBlock[] = [] + for (const block of prompt) { + if (block.type === 'text') blocks.push({ type: 'text', text: block.text }) + } + return blocks +} + +/** Normalize an unknown thrown value to an Error (the catch binding is `unknown`). */ +function toError(value: unknown): Error { + // The catch only sees rejections from the ACP SDK RPCs and the spawn `error` + // event, which are always `Error`s; the `String(value)` arm is a defensive + // fallback for a non-Error throw that the typed surfaces cannot produce. + /* v8 ignore next */ + return value instanceof Error ? value : new Error(String(value)) +} + +/** Resolve once the child process exits (any code/signal); immediate if gone. */ +function waitForExit(child: ChildProcess): Promise { + // Already-exited fast path: dispose guards on exitCode before calling, so in + // tests the child is always still alive here. + /* v8 ignore next */ + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve() + return new Promise(resolve => child.once('exit', () => { resolve() })) +} + +/** Resolve `true` if the child exits within `ms`, `false` on timeout. */ +function exitsWithin(child: ChildProcess, ms: number): Promise { + return Promise.race([ + waitForExit(child).then(() => true), + // `.unref()` so a pending grace timer never keeps the parent's loop alive. + new Promise(resolve => setTimeout(() => { resolve(false) }, ms).unref()), + ]) +} + +/** + * Start an out-of-process ACP child for `request` and return a {@link SubagentRun}. + * + * Spawns the configured command, wraps its stdio in an ACP `ClientSideConnection`, + * and drives one session: `initialize` → `newSession` → `prompt`. The accumulated + * `agent_message_chunk` text is the result output; the prompt's terminal + * `StopReason` maps to the stop reason. `result` never REJECTS on a child-level + * failure (a spawn/transport/RPC error resolves with `stopReason: 'error'`), per + * the seam contract. `cancel()` sends `session/cancel`; `dispose()` kills the + * subprocess and awaits its exit (quiescent teardown). + */ +export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): SubagentRun { + const id = AgentId(randomUUID()) + + // A request already aborted before it starts never spawns the child at all — + // return an inert run that settled `aborted`, rather than launching the + // configured binary just to tear it down. `dispose`/`cancel` are no-ops. + if (request.signal?.aborted) { + return { + id, + result: Promise.resolve({ output: [], stopReason: 'aborted' }), + cancel(_reason?: string): void { /* nothing was started */ }, + dispose(): Promise { return Promise.resolve() }, + } + } + + // Spawn the child ACP agent. stdin = ACP request channel, stdout = ACP + // response channel, stderr = INHERIT so the child's diagnostics surface on the + // parent's stderr (no separate capture to drain — we don't fold child stderr + // into the result; the seam reports only output + stop reason). + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: buildChildEnv(spec.env), + stdio: ['pipe', 'pipe', 'inherit'], + }) + // A spawn-level failure (e.g. ENOENT for a bad command) is emitted as an + // `error` event, NOT a thrown exception — without a listener Node treats it as + // an unhandled error and crashes the parent. Capture it into a promise the + // result path races, so a bad command settles `error` like any child failure. + const spawnFailed = new Promise((resolve) => { + child.once('error', (err) => { resolve(err) }) + }) + + // Accumulate the child's streamed assistant text — the SubagentResult output. + const output: string[] = [] + // `cancelled` records that a cancel was requested (signal or cancel()), so a + // run torn down before the prompt resolves settles `aborted` rather than the + // generic error mapping. Held on a mutable object so the async closures that + // set it (the abort listener) and the IIFE that reads it don't fight TS's + // control-flow narrowing of a bare `let` (which would type the catch-time read + // as always-`false`). + const flags = { cancelled: false } + + const makeClient = (_agent: AcpAgent): Client => ({ + sessionUpdate(params: SessionNotification): Promise { + const update = params.update + if (update.sessionUpdate === 'agent_message_chunk') { + output.push(acpContentText(update.content)) + } + // Other updates (thoughts, tool calls, plans) are consumed but not + // surfaced in this cut — the subagent returns only its final answer. + return Promise.resolve() + }, + requestPermission(params: RequestPermissionRequest): Promise { + // Auto-answer by the configured policy. `allow` selects the first + // allow-shaped option the child offered; if it offered none (or we + // reject), answer `cancelled` so the child does not proceed. + if (spec.permission === 'allow') { + const allow = params.options.find(o => o.kind === 'allow_once' || o.kind === 'allow_always') + if (allow !== undefined) { + return Promise.resolve({ outcome: { outcome: 'selected', optionId: allow.optionId } }) + } + } + return Promise.resolve({ outcome: { outcome: 'cancelled' } }) + }, + }) + + const conn = new ClientSideConnection( + makeClient, + ndJsonStream( + Writable.toWeb(child.stdin) as WritableStream, + Readable.toWeb(child.stdout) as ReadableStream, + ), + ) + + 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 + // FIRST to settle wins, so `cancel()` always honors the contract (`result` + // settles `aborted`) without waiting on a non-cooperative child. `dispose` + // still kills the process and reaps it; this only unblocks `result`. The + // executor runs synchronously, so `signalCancelSettled` is assigned before the + // Promise constructor returns (the `!` asserts the definite assignment). + let signalCancelSettled!: () => void + const cancelSettled = new Promise((resolve) => { signalCancelSettled = resolve }) + const requestCancel = (): void => { + flags.cancelled = true + signalCancelSettled() + // Best-effort: tell the child to cancel the in-flight turn. Swallows a + // rejection — the session may not exist yet, or the pipe may be gone; the + // dispose path kills the process regardless. If the session has NOT been + // created yet (cancel raced ahead of `newSession`), the `cancelled` flag + // alone carries it: the result path re-checks the flag after each await and + // settles `aborted` without running the prompt. The `.catch` swallow is + // defensive for a narrow transport race (child gone mid-send) — v8-ignored + // because dispose kills the process regardless, so it can't be hit in tests. + /* v8 ignore next */ + if (sessionId !== undefined) void conn.cancel({ sessionId }).catch(() => { /* child gone / no session */ }) + } + const onAbort = (): void => { requestCancel() } + request.signal?.addEventListener('abort', onAbort, { once: true }) + + const result: Promise = (async (): Promise => { + // The accumulated child text as harness ContentBlocks (empty array when the + // child streamed nothing). Read at every return so a partial answer survives + // a later cancel/error. + const collectOutput = (): ContentBlock[] => { + const text = output.join('') + return text.length > 0 ? [{ type: 'text', text }] : [] + } + try { + // Race three outcomes, first to settle wins: + // - driveAcp: the normal initialize → newSession → prompt path; + // - spawnFailed: a bad command never speaks ACP, so `initialize` would + // hang forever — the spawn `error` event is the only signal, and a + // rejected race settles the run `error` via the catch; + // - cancelSettled: a cancel was requested — settle `aborted` immediately + // rather than waiting on a child that may ignore `session/cancel` or + // wedge the prompt (the `cancel()` contract: `result` settles `aborted`). + const driveAcp = async (): Promise => { + await conn.initialize({ + protocolVersion: PROTOCOL_VERSION, + // Advertise NO optional client capabilities (no fs, no terminal): the + // child self-serves in its own process. + clientCapabilities: {}, + }) + const session = await conn.newSession({ cwd: spec.cwd, mcpServers: [] }) + sessionId = session.sessionId + // A cancel that raced ahead of `newSession` set `cancelled` but could not + // send `session/cancel` (no session id yet). Honor it here: settle + // `aborted` without ever issuing the prompt, rather than running the child + // to completion and ignoring the cancel. + if (flags.cancelled) return { output: collectOutput(), stopReason: 'aborted' } + const promptResult = await conn.prompt({ sessionId, prompt: toAcpPrompt(request.prompt) }) + return { output: collectOutput(), stopReason: acpStopReason(promptResult.stopReason) } + } + return await Promise.race([ + driveAcp(), + spawnFailed.then((err): SubagentResult => { throw err }), + cancelSettled.then((): SubagentResult => ({ output: collectOutput(), stopReason: 'aborted' })), + ]) + } catch (error: unknown) { + // The seam contract: result resolves (never rejects) on a child-level + // failure. Cancellation is handled by the `cancelSettled` race arm above + // (it settles `aborted` the instant cancel is requested, beating any + // rejection), so a rejection that reaches HERE is always a genuine + // child-level error — the awaited ACP RPCs or the spawn-failure race + // (initialize/newSession/prompt transport/RPC errors, or ENOENT), not a + // local bug. Flatten to `error` and surface the original via onError so a + // real fault is preserved rather than silently lost. + spec.onError?.(toError(error), 'error') + return { output: collectOutput(), stopReason: 'error' } + } + })() + + return { + id, + result, + cancel(_reason?: string): void { + requestCancel() + }, + async dispose(): Promise { + request.signal?.removeEventListener('abort', onAbort) + // Reach quiescence, not merely request it (dispose must AWAIT the child + // actually stopping). If the child is already gone, nothing to do. + if (child.exitCode !== null || child.signalCode !== null) return + const eofGraceMs = spec.disposeEofGraceMs ?? DEFAULT_DISPOSE_EOF_GRACE_MS + const graceMs = spec.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS + // 1. Graceful: end the ACP request stream (stdin EOF) and let the child + // quiesce ON ITS OWN. Our acp-agent has NO SIGTERM handler in a normal + // session — it tears down via the server bridge's connection-close path + // (conn.closed → per-agent dispose → final session/flush), driven by the + // stdin EOF, NOT by a signal. A prompt response can resolve from a + // turn/end BEFORE that post-turn flush lands, so the child still has + // durable work owed when dispose runs. Give the EOF-driven quiesce a real + // window — wider than a single signal-grace, since the child's own + // teardown may itself be awaiting a signal-trapping grandchild (a bash + // subprocess in its own SIGTERM→SIGKILL grace) plus a flush — and only + // escalate if it overruns. Sending SIGTERM in the same tick (or too soon) + // would default-terminate the child mid-flush, orphaning its nested work. + child.stdin.end() + if (await exitsWithin(child, eofGraceMs)) return + // 2. SIGTERM, then escalate to SIGKILL if it still does not exit within the + // grace period — a child that ignores EOF and traps SIGTERM must not + // wedge dispose forever (the seam requires bounded quiescence). + child.kill('SIGTERM') + if (await exitsWithin(child, graceMs)) return + // 3. Force-kill and await the (now-certain) exit. + child.kill('SIGKILL') + await waitForExit(child) + }, + } +} diff --git a/packages/subagent/subagent-acp/tests/mock-acp-server.ts b/packages/subagent/subagent-acp/tests/mock-acp-server.ts new file mode 100644 index 0000000000..9cfeac1f44 --- /dev/null +++ b/packages/subagent/subagent-acp/tests/mock-acp-server.ts @@ -0,0 +1,228 @@ +/** + * A minimal mock ACP AGENT, run as a subprocess, for the keyless + * `dsh-subagent-acp` tests. It speaks the agent side of ACP over stdio and is + * fully scripted by environment variables — no model, no network: + * + * - `MOCK_TEXT` — the assistant text it streams as one `agent_message_chunk`. + * - `MOCK_STOP` — the ACP `StopReason` it returns from `prompt` + * (`end_turn` default, or `max_tokens`/`refusal`/…). + * - `MOCK_HANG` — if `1`, `prompt` never resolves on its own (it waits for + * a `session/cancel`), to exercise the client's cancel path. + * - `MOCK_IGNORE_CANCEL` — if `1` (with MOCK_HANG), the agent receives + * `session/cancel` but NEVER resolves the pending prompt + * and never exits — a non-cooperative child. The backend's + * `result` must still settle `aborted` on its own and + * `dispose()` must still kill the process. + * - `MOCK_PERMISSION` — if `1`, the agent calls `session/request_permission` + * before answering, to exercise the client's auto-answer. + * - `MOCK_READY_FILE` — if set, the path the agent touches once its `prompt` + * 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_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 + * path and exits ON ITS OWN — no signal. Stands in for a + * child whose durable flush completes only if dispose + * gives EOF a real window before escalating to SIGTERM. + * - `MOCK_IGNORE_EOF` — if `1`, keep the event loop alive past stdin EOF (a bare + * timer) but install a SIGTERM handler that exits (and, if + * MOCK_SIGTERM_FILE is set, touches it as an observable + * proof the SIGTERM rung fired). The child ignores the + * graceful EOF window yet dies cooperatively on SIGTERM — + * exercising dispose's middle tier (exit during the SIGTERM + * grace, before the SIGKILL escalation). Touches + * MOCK_READY_FILE once armed. + * + * It is NOT a test spec (no `describe`/`it`) — it is spawned BY the specs as the + * child process the ACP backend drives. Kept as a `.ts` run under tsx by the + * spec (which passes its own tsconfig), mirroring how the snapshot harness boots + * the real example. + * + * @module @deepseek-ai/dsh-subagent-acp/tests/mock-acp-server + */ + +import { randomUUID } from 'node:crypto' +import { existsSync, writeFileSync } from 'node:fs' +import { Readable, Writable } from 'node:stream' +import { + AgentSideConnection, + ndJsonStream, + PROTOCOL_VERSION, + type Agent, + type CancelNotification, + type AuthenticateRequest, + type InitializeRequest, + type InitializeResponse, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type StopReason, +} from '@agentclientprotocol/sdk' + +const TEXT = process.env.MOCK_TEXT ?? 'mock child answer' +const STOP = (process.env.MOCK_STOP ?? 'end_turn') as StopReason +const HANG = process.env.MOCK_HANG === '1' +const WANT_PERMISSION = process.env.MOCK_PERMISSION === '1' +const NO_ALLOW = process.env.MOCK_NO_ALLOW === '1' +const THOUGHT = process.env.MOCK_THOUGHT === '1' +const CRASH_ON_CANCEL = process.env.MOCK_CRASH_ON_CANCEL === '1' +const IGNORE_CANCEL = process.env.MOCK_IGNORE_CANCEL === '1' +const READY_FILE = process.env.MOCK_READY_FILE +const FLUSH_ON_EOF = process.env.MOCK_FLUSH_ON_EOF +// When MOCK_NEWSESSION_READY/GO are set, newSession touches READY then blocks +// until GO appears — letting a test cancel mid-newSession deterministically. +const NEWSESSION_GATE = process.env.MOCK_NEWSESSION_READY !== undefined && process.env.MOCK_NEWSESSION_GO !== undefined + ? { ready: process.env.MOCK_NEWSESSION_READY, go: process.env.MOCK_NEWSESSION_GO } + : undefined + +function makeAgent(conn: AgentSideConnection): Agent { + // Pending cancel resolver for the HANG path: a `session/cancel` resolves the + // prompt with `cancelled`. + let resolveCancel: ((reason: StopReason) => void) | undefined + + return { + initialize(_params: InitializeRequest): Promise { + return Promise.resolve({ + protocolVersion: PROTOCOL_VERSION, + agentCapabilities: { loadSession: false, promptCapabilities: { image: false, audio: false, embeddedContext: false } }, + authMethods: [], + }) + }, + async newSession(_params: NewSessionRequest): Promise { + // Optionally signal "newSession reached" and block until released, so a + // test can cancel DURING newSession (the early-cancel race window) on a + // condition rather than a timeout. + if (NEWSESSION_GATE !== undefined) { + writeFileSync(NEWSESSION_GATE.ready, 'at-newSession') + while (!existsSync(NEWSESSION_GATE.go)) await new Promise(r => setTimeout(r, 10)) + } + return { sessionId: randomUUID() } + }, + authenticate(_params: AuthenticateRequest): Promise { + // No auth methods advertised; nothing to do. + return Promise.resolve() + }, + async prompt(params: PromptRequest): Promise { + if (WANT_PERMISSION) { + // Ask the client to approve before answering; honor its decision. Under + // MOCK_NO_ALLOW the only options are reject-shaped, so an `allow`-policy + // client finds no allow option and must fall back to cancelled. + const options = NO_ALLOW + ? [{ optionId: 'no', name: 'Reject', kind: 'reject_once' as const }] + : [ + { optionId: 'yes', name: 'Allow', kind: 'allow_once' as const }, + { optionId: 'no', name: 'Reject', kind: 'reject_once' as const }, + ] + const decision = await conn.requestPermission({ + sessionId: params.sessionId, + toolCall: { toolCallId: 'mock-call', title: 'mock side effect' }, + options, + }) + if (decision.outcome.outcome === 'cancelled') { + return { stopReason: 'cancelled' } + } + } + // Optionally emit a NON-message update first (a thought), so the client's + // sessionUpdate sees an update it must consume-but-not-accumulate. + if (THOUGHT) { + await conn.sessionUpdate({ + sessionId: params.sessionId, + update: { sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: 'thinking…' } }, + }) + } + // Stream the canned assistant text as one chunk. + await conn.sessionUpdate({ + sessionId: params.sessionId, + update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: TEXT } }, + }) + // Signal "prompt is in flight" by touching the readiness file, so a test + // can wait on a CONDITION (file exists) rather than an arbitrary timeout + // before cancelling — deterministic regardless of subprocess cold-start. + if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ready') + if (HANG) { + // Never resolve on our own: wait for session/cancel to settle us. + return new Promise((resolve) => { + resolveCancel = (reason) => { resolve({ stopReason: reason }) } + }) + } + return { stopReason: STOP } + }, + cancel(_params: CancelNotification): Promise { + if (CRASH_ON_CANCEL) { + // Exit hard instead of answering — tears the ACP pipe, so the client's + // pending prompt REJECTS (exercises the backend's catch-while-cancelled + // path: a transport failure after a cancel settles `aborted`). + process.exit(1) + } + if (IGNORE_CANCEL) { + // A NON-COOPERATIVE child: receive session/cancel but never resolve the + // pending prompt and never exit. The backend's `result` must still settle + // `aborted` on its own (the cancel-settle race), and `dispose()` must + // still kill the process — proving cancellation does not depend on the + // child cooperating. + return Promise.resolve() + } + resolveCancel?.('cancelled') + return Promise.resolve() + }, + } +} + +new AgentSideConnection( + makeAgent, + ndJsonStream( + Writable.toWeb(process.stdout) as WritableStream, + Readable.toWeb(process.stdin) as ReadableStream, + ), +) + +// Under MOCK_TRAP_SIGTERM, ignore SIGTERM and keep stdin open so the process +// neither quiesces on EOF nor dies on the graceful signal — exercising the +// backend dispose path's SIGKILL escalation. Without this the process exits +// normally on SIGTERM / stdin end. Touch READY_FILE once the trap is armed, so +// a test waits for that CONDITION before disposing (the trap must be in place, +// not merely the process spawned — otherwise SIGTERM hits the default handler). +if (process.env.MOCK_TRAP_SIGTERM === '1') { + process.on('SIGTERM', () => { /* trapped: refuse to exit on the graceful signal */ }) + // Keep the event loop alive (a bare timer) so nothing else lets it exit. + setInterval(() => { /* stay alive until SIGKILL */ }, 1000) + if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'trap-armed') +} + +// Under MOCK_FLUSH_ON_EOF, model the real acp-agent's EOF-driven quiesce: on +// stdin 'end' (the dispose path's `child.stdin.end()`), take an ASYNC beat to +// "flush", then touch the marker and exit ON OUR OWN — no signal involved. The +// beat is MOCK_FLUSH_DELAY_MS (default 150). A dispose that sends SIGTERM before +// the beat completes (no graceful window, or an EOF grace shorter than the +// flush) default-terminates this process and the marker is missing; a dispose +// that gives the EOF quiesce enough window first lets the flush land. +if (FLUSH_ON_EOF !== undefined) { + const flushDelayMs = Number(process.env.MOCK_FLUSH_DELAY_MS ?? '150') + process.stdin.on('end', () => { + setTimeout(() => { + writeFileSync(FLUSH_ON_EOF, 'flushed') + process.exit(0) + }, flushDelayMs) + }) +} + +// Under MOCK_IGNORE_EOF, keep the loop alive past stdin EOF (so the graceful EOF +// window times out) but INSTALL A SIGTERM HANDLER that records it and exits — the +// child ignores the graceful EOF window yet dies cooperatively on SIGTERM, +// exercising dispose's MIDDLE tier (exit during the SIGTERM grace, before the +// SIGKILL escalation). When MOCK_SIGTERM_FILE is set the handler touches it, an +// OBSERVABLE proof that the SIGTERM rung fired: if dispose skipped the middle +// rung and jumped EOF→SIGKILL, SIGKILL is uncatchable so the handler never runs +// and the marker is missing. Touch READY_FILE once armed (a test waits on it). +if (process.env.MOCK_IGNORE_EOF === '1') { + const sigtermFile = process.env.MOCK_SIGTERM_FILE + process.on('SIGTERM', () => { + if (sigtermFile !== undefined) writeFileSync(sigtermFile, 'sigterm') + process.exit(0) + }) + setInterval(() => { /* stay alive past EOF until SIGTERM */ }, 1000) + if (READY_FILE !== undefined) writeFileSync(READY_FILE, 'ignore-eof-armed') +} + diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts new file mode 100644 index 0000000000..826ef198dd --- /dev/null +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -0,0 +1,110 @@ +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 { Context } from 'cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as acp from '../src/index.ts' + +/** + * With-key e2e for the ACP subagent backend: the harness drives ITSELF as an ACP + * server. The backend spawns the real `acp-agent` example as a child PROCESS, + * speaks ACP to it over stdio, and the child runs the REAL model in its own + * process to answer a prompt. We verify the child's real answer comes back + * through the seam — the "talk to our own process" smoke the design called for. + * Key-gated (self-skips without DEEPSEEK_API_KEY). + * + * This is the out-of-process analogue of the in-process spawn e2e: there a + * parent agent on the same context drove a child; here the child is a separate + * process reached over ACP, proving the seam generalizes across the boundary. + */ + +// The real acp-agent example: its bin + cordis.yml (the live DeepSeek config). +const binScript = fileURLToPath(new URL('../../../ui/acp-agent/src/bin.ts', import.meta.url)) +const exampleConfig = fileURLToPath(new URL('../../../../examples/acp-agent/cordis.yml', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** The ACP backend ignores the parent, but the seam requires one. */ +const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +describe.skipIf(!process.env.DEEPSEEK_API_KEY)('ACP backend with-key e2e (drive our own acp-agent)', () => { + it('drives the real acp-agent example process to answer a prompt', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) + ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, binScript, exampleConfig], + cwd: workdir, + permission: 'reject', + // The child harness needs the key to reach the model; forward it + // explicitly (buildChildEnv scrubs ambient creds but keeps these extras). + env: { + ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, + ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, + TSX_TSCONFIG_PATH: repoTsconfig, + }, + }) + + const run = ctx.subagents.start('acp', { + prompt: [{ type: 'text', text: 'Reply with exactly the word PONG and nothing else. Do not use any tools.' }], + parent: fakeParent, + }) + const result = await run.result + await run.dispose() + + // The real child process completed its turn and streamed a real answer back + // across the ACP boundary. + expect(result.stopReason).toBe('completed') + const text = result.output.filter(b => b.type === 'text').map(b => (b as { text: string }).text).join('') + expect(text.length).toBeGreaterThan(0) + expect(text.toUpperCase()).toContain('PONG') + }, 180_000) + + it('drives the child to do real file work via its own bash tool', async () => { + workdir = await mkdtemp(join(tmpdir(), 'dsh-subagent-acp-e2e-')) + ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, binScript, exampleConfig], + cwd: workdir, + // The child needs to act (run bash), so approve its permission prompts. + permission: 'allow', + env: { + ...process.env.DEEPSEEK_API_KEY !== undefined ? { DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY } : {}, + ...process.env.DEEPSEEK_BASE_URL !== undefined ? { DEEPSEEK_BASE_URL: process.env.DEEPSEEK_BASE_URL } : {}, + TSX_TSCONFIG_PATH: repoTsconfig, + }, + }) + + const run = ctx.subagents.start('acp', { + prompt: [{ type: 'text', text: + 'Use the bash tool to write the text ACP_CHILD_WAS_HERE into a file named proof.txt ' + + 'in the current directory. Then reply DONE.' }], + parent: fakeParent, + }) + const result = await run.result + await run.dispose() + + expect(result.stopReason).toBe('completed') + // Verify the WORLD: the child process actually wrote the file in its cwd. + const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') + expect(proof).toContain('ACP_CHILD_WAS_HERE') + }, 180_000) +}) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts new file mode 100644 index 0000000000..9819320ec5 --- /dev/null +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -0,0 +1,512 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import SubagentService from '@deepseek-ai/dsh-subagent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as acp from '../src/index.ts' +import { acpStopReason, acpContentText, buildChildEnv, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' + +/** + * Keyless integration tests for the ACP subagent backend. Each spawns a REAL + * subprocess — the scripted mock ACP server (tests/mock-acp-server.ts) — and + * drives it through the REAL backend over real ACP JSON-RPC stdio, so the + * connection setup, the client callbacks, the prompt round-trip, the stop-reason + * mapping, cancellation, and quiescent disposal are all exercised end to end. + * No model, no key. + */ + +const mockServer = fileURLToPath(new URL('./mock-acp-server.ts', import.meta.url)) +const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** A throwaway parent Agent — the ACP backend ignores it, but the seam requires one. */ +const fakeParent = { id: 'parent', session: { header: {} } } as unknown as Agent + +interface SetupEnv { + /** Mock-server scripting env: MOCK_TEXT / MOCK_STOP / MOCK_HANG / MOCK_PERMISSION. */ + [key: string]: string +} + +/** + * Mount the ACP backend pointed at the mock server, scripted by `mockEnv`. + * `permission` selects the backend's auto-answer policy. + */ +async function setup(mockEnv: SetupEnv = {}, permission: 'allow' | 'reject' = 'reject') { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + permission, + // The mock-server scripting vars must reach the child; TSX_TSCONFIG_PATH lets + // tsx resolve @deepseek-ai/* from a child cwd outside the repo. + env: { ...mockEnv, TSX_TSCONFIG_PATH: repoTsconfig }, + }) + return ctx +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +/** + * Poll until `file` exists (the mock touches it once its prompt is in flight), + * so a cancel test waits on a CONDITION rather than an arbitrary timeout — the + * subprocess cold-start under tsx is variable, and a fixed sleep both flakes and + * slows the suite. Fails loud if the child never signals readiness. + */ +async function waitForFile(file: string, timeoutMs = 5000): Promise { + const deadline = Date.now() + timeoutMs + while (!existsSync(file)) { + if (Date.now() > deadline) throw new Error(`mock child never became ready (${file})`) + await new Promise(r => setTimeout(r, 10)) + } +} + +describe('acpStopReason', () => { + it('maps each ACP stop reason to the harness vocabulary', () => { + expect(acpStopReason('end_turn')).toBe('completed') + expect(acpStopReason('max_tokens')).toBe('max-tokens') + expect(acpStopReason('refusal')).toBe('refusal') + expect(acpStopReason('cancelled')).toBe('aborted') + expect(acpStopReason('max_turn_requests')).toBe('error') + }) + + it('treats an unknown terminal reason as an error', () => { + expect(acpStopReason('something-new' as never)).toBe('error') + }) +}) + +describe('acpContentText / toAcpPrompt', () => { + it('extracts text from a text content block, empty for non-text', () => { + expect(acpContentText({ type: 'text', text: 'hi' })).toBe('hi') + // A non-text ACP content block (e.g. an image) contributes no text. + expect(acpContentText({ type: 'image', data: 'x', mimeType: 'image/png' })).toBe('') + }) + + it('keeps text prompt blocks and drops non-text ones', () => { + expect(toAcpPrompt([{ type: 'text', text: 'a' }])).toEqual([{ type: 'text', text: 'a' }]) + // A non-text harness block (e.g. reasoning) is dropped from the ACP prompt. + expect(toAcpPrompt([{ type: 'text', text: 'a' }, { type: 'reasoning', text: 'think' }])) + .toEqual([{ type: 'text', text: 'a' }]) + }) +}) + +describe('buildChildEnv', () => { + it('drops credential-shaped ambient vars but keeps the explicit extras', () => { + process.env.DSH_ACP_TEST_SECRET_TOKEN = 'leak-me' + try { + const env = buildChildEnv({ DEEPSEEK_API_KEY: 'explicit' }) + // The credential-shaped ambient var is scrubbed. + expect(env.DSH_ACP_TEST_SECRET_TOKEN).toBeUndefined() + // 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 + } + }) +}) + +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 run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'do X' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('hello from acp child') + await run.dispose() + }) + + it('maps a max_tokens stop reason', async () => { + const ctx = await setup({ MOCK_TEXT: 'cut off', MOCK_STOP: 'max_tokens' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + await run.dispose() + }) + + it('maps a refusal stop reason', async () => { + const ctx = await setup({ MOCK_TEXT: '', MOCK_STOP: 'refusal' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('refusal') + await run.dispose() + }) + + it('cancelling a running child settles aborted (session/cancel via run.cancel)', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-cancel-')) + const readyFile = join(tmp, 'ready') + try { + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + // Wait until the child's prompt is in flight (condition, not a sleep), + // then cancel — so we exercise the mid-run session/cancel path. + await waitForFile(readyFile) + run.cancel('test') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('settles aborted WITHOUT spawning the child when the signal is already aborted', async () => { + // A pre-aborted request must not even launch the configured binary. Point + // the command at one that would create a sentinel file if it ever ran, and + // assert the sentinel never appears. + const tmp = mkdtempSync(join(tmpdir(), 'acp-preabort-')) + const sentinel = join(tmp, 'spawned') + try { + const controller = new AbortController() + controller.abort() + const run = startAcpRun( + { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }, + // `touch ` — runs only if the process is actually spawned. + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} }, + ) + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + // cancel/dispose on the inert run are safe no-ops. + run.cancel('noop') + await run.dispose() + // The binary was never launched — no sentinel. + expect(existsSync(sentinel)).toBe(false) + } 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 + // escalate to SIGKILL and return once the process is actually gone. + const tmp = mkdtempSync(join(tmpdir(), 'acp-trap-')) + const ready = join(tmp, 'trap-armed') + try { + const spec: AcpRunSpec = { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + // Short on BOTH tiers: the trap ignores EOF and SIGTERM, so dispose must + // burn the EOF window, then the SIGTERM window, then SIGKILL — keep each + // small so the whole ladder finishes well within the 4000ms bound. + disposeEofGraceMs: 150, + disposeGraceMs: 150, + } + const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) + // Wait until the child has BOOTED AND ARMED THE TRAP (a condition, not a + // sleep) — otherwise SIGTERM races the trap install and the default handler + // terminates the child, never exercising the escalation. + await waitForFile(ready) + // Don't await result (the child hangs). Dispose must still return promptly + // via the SIGKILL escalation — bound it so a regression (no escalation) + // fails loud instead of hanging the suite. + await expect(Promise.race([ + run.dispose(), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — no SIGKILL escalation')) }, 4000) }), + ])).resolves.toBeUndefined() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('dispose gives the child an EOF window that outlasts the SIGTERM grace (graceful flush)', async () => { + // The real acp-agent flushes ASYNCHRONOUSLY on stdin EOF (its bridge tears + // down on connection close, NOT on a signal) — and it has no SIGTERM handler. + // Its EOF teardown can itself await a signal-trapping grandchild (a bash + // subprocess in its own SIGTERM→SIGKILL grace) plus a flush, so the EOF window + // must be a SEPARATE, WIDER grace than the SIGTERM tier — not the same value. + // The mock models a flush that takes LONGER than the SIGTERM grace but well + // under the EOF grace: it lands only because tier 1 waits eofGraceMs, not + // graceMs. (If dispose reused the small SIGTERM grace for the EOF wait — the + // round-2 bug — SIGTERM would fire mid-flush and the marker would be missing.) + const tmp = mkdtempSync(join(tmpdir(), 'acp-eof-')) + const ready = join(tmp, 'ready') + const flushed = join(tmp, 'flushed') + try { + const spec: AcpRunSpec = { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + // MOCK_HANG so the prompt never resolves on its own — we tear down a live + // child. The flush beat (400ms) outlasts the 50ms SIGTERM grace but fits + // the 2000ms EOF grace; the marker lands iff the EOF tier honored its own + // wider grace. + env: { + MOCK_HANG: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, + MOCK_FLUSH_ON_EOF: flushed, MOCK_FLUSH_DELAY_MS: '400', TSX_TSCONFIG_PATH: repoTsconfig, + }, + disposeEofGraceMs: 2000, + disposeGraceMs: 50, + } + const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) + // Wait until the child is fully booted with its prompt in flight (its ACP + // stdin reader is attached), so dispose's stdin EOF reaches a live child. + await waitForFile(ready) + await run.dispose() + // dispose returned via the natural-exit tier — the EOF-driven flush landed + // despite taking longer than the SIGTERM grace. + expect(existsSync(flushed)).toBe(true) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('escalates to SIGTERM for a child that ignores EOF but is not SIGTERM-trapping', async () => { + // A child that keeps its loop alive past stdin EOF (so the graceful window + // times out) but exits cooperatively on SIGTERM must die on the SIGTERM tier + // — dispose returns there, never reaching the SIGKILL tier. The child touches + // a SIGTERM marker from its signal handler: SIGKILL is uncatchable, so if + // dispose had skipped the middle rung (EOF→SIGKILL) the handler would never + // run and the marker would be absent — making this a GENUINE middle-tier guard. + const tmp = mkdtempSync(join(tmpdir(), 'acp-ignore-eof-')) + const ready = join(tmp, 'ready') + const sigterm = join(tmp, 'sigterm') + try { + const spec: AcpRunSpec = { + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + cwd: process.cwd(), + permission: 'reject', + env: { + MOCK_HANG: '1', MOCK_IGNORE_EOF: '1', MOCK_TEXT: 'x', + MOCK_READY_FILE: ready, MOCK_SIGTERM_FILE: sigterm, TSX_TSCONFIG_PATH: repoTsconfig, + }, + // Tiny EOF grace so the ignored-EOF window elapses fast, then SIGTERM. + disposeEofGraceMs: 150, + disposeGraceMs: 2000, + } + const run = startAcpRun({ prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, spec) + await waitForFile(ready) + // Bound it so a hang fails loud rather than stalling the suite. + await expect(Promise.race([ + run.dispose(), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return')) }, 5000) }), + ])).resolves.toBeUndefined() + // The child caught SIGTERM and exited — proof the middle rung fired (not a + // jump straight to the uncatchable SIGKILL). + expect(existsSync(sigterm)).toBe(true) + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('honors a cancel that races AHEAD of newSession (no session id yet) without running the prompt', async () => { + // Gate the child at newSession: it signals `ready` and blocks until `go`. + // We cancel WHILE newSession is pending (sessionId still undefined, so the + // backend cannot send session/cancel) — the `cancelled` flag alone must + // settle the run aborted after newSession resolves, never issuing the prompt. + const tmp = mkdtempSync(join(tmpdir(), 'acp-early-')) + const ready = join(tmp, 'ready') + const go = join(tmp, 'go') + try { + const ctx = await setup({ MOCK_NEWSESSION_READY: ready, MOCK_NEWSESSION_GO: go, MOCK_TEXT: 'should not run' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) // newSession is now in flight, sessionId undefined + run.cancel('early') // sets cancelled; cannot send session/cancel yet + writeFileSync(go, 'go') // let newSession resolve + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('bridges the request signal to a session/cancel mid-run', async () => { + const tmp = mkdtempSync(join(tmpdir(), 'acp-signal-')) + const readyFile = join(tmp, 'ready') + try { + const controller = new AbortController() + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_READY_FILE: readyFile }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }) + await waitForFile(readyFile) + controller.abort() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('auto-rejects a permission prompt by default (child settles cancelled→aborted)', async () => { + const ctx = await setup({ MOCK_TEXT: 'x', MOCK_PERMISSION: '1' }, 'reject') + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + // The child asked permission, the backend rejected, the child returned cancelled. + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('auto-approves a permission prompt under the allow policy', async () => { + const ctx = await setup({ MOCK_TEXT: 'approved answer', MOCK_PERMISSION: '1', MOCK_STOP: 'end_turn' }, 'allow') + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('approved answer') + await run.dispose() + }) + + it('falls back to cancelled under the allow policy when the child offers no allow option', async () => { + // The child asks permission but offers ONLY reject-shaped options, so an + // allow-policy client finds nothing to select and must answer cancelled. + const ctx = await setup({ MOCK_PERMISSION: '1', MOCK_NO_ALLOW: '1' }, 'allow') + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('consumes a non-message update (a thought) without adding it to the output', async () => { + // The child streams an agent_thought_chunk before its answer; the backend + // must consume it but NOT include it in the result output. + const ctx = await setup({ MOCK_THOUGHT: '1', MOCK_TEXT: 'final answer', MOCK_STOP: 'end_turn' }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + // Only the message text, NOT the thought. + expect(text(result.output)).toBe('final answer') + await run.dispose() + }) + + it('resolves error (not reject) when the spawn command does not exist', async () => { + // Direct startAcpRun with NO onError sink — the catch must still flatten the + // spawn failure to `error` (the onError call is optional, covering the + // absent-sink branch). + const run = startAcpRun( + { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {} }, + ) + const result = await run.result + // The seam contract: a child-level failure resolves error, never rejects. + expect(result.stopReason).toBe('error') + await run.dispose() + }) + + it('resolves error via the provider (real load path) when the command does not exist', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: '/nonexistent/acp-agent-binary', + args: [], + permission: 'reject', + env: {}, + }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + const result = await run.result + expect(result.stopReason).toBe('error') + await run.dispose() + }) + + it('reports a flattened child failure through onError (preserved, not silently lost)', async () => { + // The seam forbids `result` rejecting, so a child-level failure is flattened + // to a stop reason — onError must still surface the original error so a real + // fault is logged, not swallowed. A nonexistent command triggers the spawn + // failure path; the spy records the error + the chosen stop reason. + const errors: { message: string; stopReason: string }[] = [] + const run = startAcpRun( + { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }, + { + command: '/nonexistent/acp-agent-binary', + args: [], + cwd: process.cwd(), + permission: 'reject', + env: {}, + onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, + }, + ) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(errors).toHaveLength(1) + expect(errors[0]!.stopReason).toBe('error') + expect(errors[0]!.message.length).toBeGreaterThan(0) + await run.dispose() + }) + + it('settles aborted when the child crashes (tears the pipe) AFTER a cancel', async () => { + // The child hangs, we cancel, and instead of answering the child exits hard + // — the pending prompt RPC rejects. With a cancel already requested, the + // backend's catch path must settle `aborted` (the failure is the cancel + // surfacing as a torn pipe), not `error`. + const tmp = mkdtempSync(join(tmpdir(), 'acp-crash-')) + const ready = join(tmp, 'ready') + try { + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_CRASH_ON_CANCEL: '1', MOCK_READY_FILE: ready }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) + run.cancel('crash it') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('settles aborted on cancel even when the child IGNORES session/cancel (non-cooperative)', async () => { + // The contract: run.cancel() → result settles `aborted`. A child that hangs + // its prompt AND ignores session/cancel must not wedge the parent — the + // backend's own cancel-settle path resolves `aborted` without the child's + // cooperation, and dispose() still reaps the process. + const tmp = mkdtempSync(join(tmpdir(), 'acp-ignorecancel-')) + const ready = join(tmp, 'ready') + try { + const ctx = await setup({ MOCK_TEXT: 'partial', MOCK_HANG: '1', MOCK_IGNORE_CANCEL: '1', MOCK_READY_FILE: ready }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) + run.cancel('test') + // Bound it: a regression (cancel only notifies the child, which ignores it) + // would hang result forever — fail loud instead of stalling the suite. + const result = await Promise.race([ + run.result, + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('result did not settle on cancel — backend waited on the child')) }, 4000) }), + ]) + expect(result.stopReason).toBe('aborted') + await run.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + + it('advertises no start-time capabilities (out-of-process child)', async () => { + const ctx = await setup() + const provider = ctx.subagents.getProvider('acp')! + expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: false, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const fiber = await ctx.plugin(acp, { providerName: 'acp', command: 'x', args: [], permission: 'reject', env: {} }) + expect(ctx.subagents.list()).toEqual(['acp']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in acp).toBe(false) + expect(acp.name).toBe('subagent-acp') + expect(acp.inject).toEqual(['subagents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(acp) as Record + expect(unwrapped).toBe(acp) + expect(unwrapped.name).toBe('subagent-acp') + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-acp/tsconfig.json b/packages/subagent/subagent-acp/tsconfig.json new file mode 100644 index 0000000000..3c06fef150 --- /dev/null +++ b/packages/subagent/subagent-acp/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/agent" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/subagent/subagent-fork/README.md b/packages/subagent/subagent-fork/README.md new file mode 100644 index 0000000000..c691d56355 --- /dev/null +++ b/packages/subagent/subagent-fork/README.md @@ -0,0 +1,23 @@ +# @deepseek-ai/dsh-subagent-fork + +The in-process **fork** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a child [`Agent`](../../core/agent) **seeded with a prefix of the parent's session log** — so the child inherits the parent's conversation context instead of starting fresh. Shares the run driver (`startInProcessRun`) with [`dsh-subagent-spawn`](../subagent-spawn/README.md); the only difference is the seed. + +## The seed boundary (the crux) + +At the moment a subagent tool's `execute` runs, the parent's CURRENT turn is open and unbalanced: the log holds the `assistant/message` carrying this spawn's tool-call and the dangling `tool/call` with no `tool/result` yet. Seeding that raw prefix would give the child an open turn that the session constructor and the dev-mode [invariants](../../support/invariants) replay **reject**. + +So the fork seeds only the **balanced completed-turn prefix** — the parent's log up to and including its last `turn/end`, excluding the in-flight turn entirely (`completedTurnPrefix`). Because the live log keeps `seq === index`, the slice is contiguous-from-0 and a valid seed. A parent on its very first (not-yet-complete) turn forks an *empty* seed — i.e. effectively a fresh child. + +The seam this rides on: `CreateAgentOptions.seed` (added on `dsh-agent`, threaded through `AgentLoop.createAgent` → `ctx.sessions.prepare({ seed })`), the same primitive `resume` uses. + +## Capabilities + +`{ outputSchema: false, depthLimit: true, toolFilter: false }` — identical to spawn (the depth/model/output behavior is the shared driver's). + +## Config + +| Key | Meaning | +|---|---| +| `providerName` | Registry name on `ctx.subagents` (default `fork`). | + +See [`dsh-subagent-spawn`](../subagent-spawn/README.md) for the run lifecycle, model inheritance, and depth tracking — all shared. diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json new file mode 100644 index 0000000000..7b1c40c4f3 --- /dev/null +++ b/packages/subagent/subagent-fork/package.json @@ -0,0 +1,48 @@ +{ + "name": "@deepseek-ai/dsh-subagent-fork", + "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", + "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-session": "^0.0.1", + "@deepseek-ai/dsh-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-subagent-spawn": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts new file mode 100644 index 0000000000..02c1811d82 --- /dev/null +++ b/packages/subagent/subagent-fork/src/index.ts @@ -0,0 +1,81 @@ +/** + * The in-process FORK subagent backend: registers a {@link SubagentProvider} on + * `ctx.subagents` that runs each child as a child {@link Agent} SEEDED with a + * prefix of the parent's session log — so the child inherits the parent's + * conversation context instead of starting fresh. The run mechanics live in + * `@deepseek-ai/dsh-subagent-inprocess` ({@link startInProcessRun}); this + * backend just computes the seed. The spawn backend is an independent peer over + * the same driver. + * + * The seed boundary is the crux: at the moment a subagent tool's `execute` + * runs, the parent's CURRENT turn is open and unbalanced (it holds the + * `assistant/message` with this spawn's tool-call, plus the dangling `tool/call` + * with no `tool/result`). Seeding that raw prefix gives the child an open turn + * the session constructor and the dev-mode invariants replay REJECT. So the + * fork seeds only the **balanced completed-turn prefix**: the parent's log up + * to and including its last `turn/end`, excluding the in-flight turn entirely. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. + * + * @module @deepseek-ai/dsh-subagent-fork + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' + +export const name = 'subagent-fork' +export const inject = ['subagents', 'agents'] + +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `fork`). */ + providerName: string +} + +export const Config: z = z.object({ + providerName: z.string().default('fork'), +}) + +/** + * The balanced completed-turn prefix of `parent`'s log: every event up to and + * including the last `turn/end`. Empty if the parent has never completed a turn + * (the in-flight turn is excluded, so a parent on its very first turn forks an + * empty — i.e. fresh — child). The result is contiguous from seq 0 (the live + * log keeps `seq === index`), so it is a valid session seed; the in-flight, + * unbalanced turn is dropped so the invariants replay accepts it. + */ +export function completedTurnPrefix(parent: Agent): SessionEvent[] { + const events = parent.session.events + const lastEnd = events.findLast(e => e.type === 'turn/end') + if (lastEnd === undefined) return [] + // seq === array index (the append contract), so slice up to and including it. + return events.slice(0, lastEnd.seq + 1) +} + +/** + * The fork provider. Supports `depthLimit`; NOT `outputSchema`/`toolFilter` this + * cut (the service rejects a request needing either before `start` runs). + */ +class ForkProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context) {} + + start(request: SubagentStartRequest) { + const seed = completedTurnPrefix(request.parent) + return startInProcessRun(this.ctx, request, { + providerName: this.name, + // Only pass a seed when there's a completed turn to inherit; an empty seed + // is equivalent to a fresh child, so omit it to keep the session unseeded. + ...seed.length > 0 ? { seed } : {}, + }) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new ForkProvider(config.providerName, ctx)) +} diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts new file mode 100644 index 0000000000..1f932fbaf9 --- /dev/null +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -0,0 +1,99 @@ +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 SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } 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' +import * as Spawn from '@deepseek-ai/dsh-subagent-spawn' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as fork from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** + * The two in-process backends coexist on one context: the SAME parent agent + * delegates to a `spawn` child (fresh) and a `fork` child (seeded with its log), + * and keeps working itself. This is the multi-provider coexistence the seam + * exists for — the named registry lets one runtime hold both transports. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + 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' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('multi-subagent coexistence (spawn + fork on one context)', () => { + it('both providers register and coexist', async () => { + const { ctx } = await setup([]) + expect(ctx.subagents.list().sort()).toEqual(['fork', 'spawn']) + }) + + it('the same parent drives a spawn child AND a fork child, then keeps working', async () => { + // Script order: parent turn 1, spawn child, fork child, parent turn 2. + const { ctx, parent } = await setup([ + textResponse('parent turn one'), + textResponse('spawn child reply'), + textResponse('fork child reply'), + textResponse('parent turn two'), + ]) + + // Parent does one real turn first, so the fork has a completed turn to seed. + parent.send([{ type: 'text', text: 'parent q1' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + // Delegate to a fresh spawn child. + const spawnRun = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'spawn task' }], parent }) + const spawnResult = await spawnRun.result + expect(spawnResult.stopReason).toBe('completed') + expect(text(spawnResult.output)).toBe('spawn child reply') + + // Delegate to a fork child (seeded with the parent's turn-1 prefix). + const forkRun = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'fork task' }], parent }) + const forkResult = await forkRun.result + expect(forkResult.stopReason).toBe('completed') + expect(text(forkResult.output)).toBe('fork child reply') + + // The two children are distinct sessions, both lineage-stamped to the parent. + const spawnChild = ctx.agents.get(spawnRun.id)! + const forkChild = ctx.agents.get(forkRun.id)! + expect(spawnChild.session.header.id).not.toBe(forkChild.session.header.id) + expect(spawnChild.session.header.parentSession).toBe(parent.session.header.id) + expect(forkChild.session.header.parentSession).toBe(parent.session.header.id) + // The fork child inherited the parent's prefix; the spawn child did not. + expect(forkChild.session.events.slice(0, parentPrefixLen).some(e => e.type === 'user/message')).toBe(true) + + await spawnRun.dispose() + await forkRun.dispose() + + // The parent is unaffected and keeps working after both delegations. + parent.send([{ type: 'text', text: 'parent q2' }]) + await parent.whenIdle() + const lastParentMessage = parent.session.events.findLast(e => e.type === 'assistant/message') + expect(lastParentMessage?.type === 'assistant/message' && text(lastParentMessage.data.content)).toBe('parent turn two') + // The parent's OWN log never recorded the children's internal steps — its + // only subagent-related entries would be tool/call+tool/result IF it had + // used the tool, but here we called the service directly, so the parent log + // is purely its own two turns. + expect(parent.session.events.filter(e => e.type === 'turn/end')).toHaveLength(2) + }) +}) diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts new file mode 100644 index 0000000000..56441a656f --- /dev/null +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -0,0 +1,190 @@ +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 SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } 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' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import * as fork from '../src/index.ts' +import { completedTurnPrefix } from '../src/index.ts' + +type Script = ConstructorParameters[0] + +/** A bare `stop` finish that streams no content → the turn ends `completed` + * with NO `assistant/message` of its own. */ +const emptyStop: StreamChunk[] = [{ type: 'finish', reason: { kind: 'stop' } }] + +/** + * Drives the REAL fork backend with a real loop + scripted mock MODEL + the + * real dsh-invariants plugin. The invariants plugin re-replays a seeded child + * log on `session/created` (its freeze-check), so a malformed (unbalanced) fork + * seed makes these tests THROW — that is the regression guard for the + * completed-turn-prefix boundary. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + 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' }) + return { ctx, parent } +} + +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('forks an UNSEEDED (fresh) child when the parent has no completed turn', async () => { + // 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 = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('fresh child') + 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) + await run.dispose() + }) + + it('seeds the child with the parent\'s completed-turn prefix (child inherits context)', async () => { + // Parent runs one turn, then we fork. The child's seeded log should contain + // the parent's first turn, and the child should run its own new turn on top. + const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + const parentPrefixLen = parent.session.events.length + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child answer') + + const child = ctx.agents.get(run.id)! + // The child's log STARTS with the parent's prefix (seeded), then its own turn. + expect(child.session.events.length).toBeGreaterThan(parentPrefixLen) + // The seeded prefix carried the parent's user message. + const seededUser = child.session.events.slice(0, parentPrefixLen).find(e => e.type === 'user/message') + expect(seededUser).toBeDefined() + // Lineage stamped. + expect(child.session.header.parentSession).toBe(parent.session.header.id) + // The seed boundary is recorded on the header (= the seeded prefix length), + // so a reload / replay harness can tell the inherited prefix from the + // child's own events. + expect(child.session.header.seedLength).toBe(parentPrefixLen) + await run.dispose() + }) + + it('produces an invariant-CLEAN seed: forking mid-turn excludes the open turn', async () => { + // Drive the parent so it has ONE completed turn, then start a SECOND turn + // that is still open (a hanging model call), and fork while it's in flight. + // The fork must seed only the completed first turn — an unbalanced seed + // would make the invariants replay throw inside ctx.subagents.start. + const { ctx, parent } = await setup([textResponse('done'), 'hang', textResponse('child')]) + parent.send([{ type: 'text', text: 'q1' }]) + await parent.whenIdle() + // Start a second turn that hangs (open turn/start + open step, never ends). + parent.send([{ type: 'text', text: 'q2' }]) + await new Promise(r => setTimeout(r, 20)) // let the hanging turn open + + // Forking now must NOT throw (the open second turn is excluded from the seed). + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child q' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child') + + const child = ctx.agents.get(run.id)! + // The child's seed has exactly the ONE completed parent turn (the open one excluded). + const seedTurnEnds = child.session.events.filter(e => e.type === 'turn/end') + // 1 from the seeded parent turn + 1 from the child's own completed turn. + expect(seedTurnEnds.length).toBe(2) + + parent.cancel() + await run.dispose() + }) + + it('does NOT return the seeded parent output when the child produces no message of its own', async () => { + // Regression: readResult must scope to the child's OWN events (after the + // seed). The parent completes a turn with a distinctive assistant message, + // then the fork child's own turn finishes with a bare `stop` and NO + // assistant/message. Scanning the whole (seeded) log would return the + // parent's "parent stale" message with stopReason 'completed'; scoped to the + // child's own events the output is empty. + const { ctx, parent } = await setup([textResponse('parent stale'), emptyStop]) + parent.send([{ type: 'text', text: 'parent question' }]) + await parent.whenIdle() + + const run = ctx.subagents.start('fork', { prompt: [{ type: 'text', text: 'child question' }], parent }) + const result = await run.result + // The child completed its own (empty) turn — completed, but with NO output + // borrowed from the seeded parent prefix. + expect(result.stopReason).toBe('completed') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('advertises depthLimit but not outputSchema/toolFilter', async () => { + const { ctx } = await setup([]) + expect(ctx.subagents.getProvider('fork')!.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(fork, { providerName: 'fork' }) + expect(ctx.subagents.list()).toEqual(['fork']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in fork).toBe(false) + expect(fork.name).toBe('subagent-fork') + expect(fork.inject).toEqual(['subagents', 'agents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(fork) as Record + expect(unwrapped).toBe(fork) + expect(unwrapped.name).toBe('subagent-fork') + expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-fork/tsconfig.json b/packages/subagent/subagent-fork/tsconfig.json new file mode 100644 index 0000000000..bac12550af --- /dev/null +++ b/packages/subagent/subagent-fork/tsconfig.json @@ -0,0 +1,33 @@ +{ + "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": "../../core/session" + }, + { + "path": "../subagent" + }, + { + "path": "../subagent-inprocess" + } + ] +} diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md new file mode 100644 index 0000000000..af6d5792a9 --- /dev/null +++ b/packages/subagent/subagent-inprocess/README.md @@ -0,0 +1,28 @@ +# @deepseek-ai/dsh-subagent-inprocess + +The shared **in-process subagent run driver**. A pure library (no provider, no registration) that the in-process backends — [spawn](../subagent-spawn/README.md) (a fresh child) and [fork](../subagent-fork/README.md) (a child seeded with a prefix of the parent's log) — both build on. The backends are thin shells that differ ONLY in the session seed they pass; everything downstream lives here, so neither backend depends on the other. + +## What it exports + +### `startInProcessRun(ctx, request, options): SubagentRun` + +Runs a child as a child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`): + +1. computes child depth = `depthOf(parent) + 1`; if `request.maxDepth` is set and exceeded, throws `SubagentDepthError` (the `depthLimit` capability); +2. creates a child via `ctx.agents.create` with a fresh `AgentId`/`SessionId`, the parent's `cwd` + `parentSession` lineage, the optional `options.seed` (fork's completed-turn prefix; omitted for a fresh child), and `agentOptions` (the child inherits the **parent's model** by default — a child with no model can't run — overridable via `request.agentOptions.model`; the system prompt is NOT inherited); +3. drives the one-shot: `child.send(prompt)` then `await child.whenIdle()` (ordering matters — `send` enqueues synchronously, so `whenIdle` observes the queued work and resolves on the child's `running → idle` transition, never before the turn starts); +4. reads the result, scoped to the child's OWN events (everything at or after `seedLength`, so a seeded child that produced no message of its own never returns the seeded parent's last message): the last `assistant/message` content (deep-cloned — the log is frozen) and the last `turn/end.reason` mapped to a `SubagentStopReason`. + +`dispose()` delegates to `AgentHandle.dispose()` (stop loop → await quiescence → remove session); `cancel()` cancels the child's in-flight turn. A cancel landing before any `turn/end` (the pre-turn window) still settles `aborted`, honoring the cancel contract rather than the generic no-turn `error`. + +### `InProcessRunOptions` + +`{ providerName: string; seed?: SessionEvent[] }` — the per-backend inputs: the provider name (for error context) and the optional child-session seed. + +### `depthOf(agent): number` + +Delegation depth rides on a merge-extensible `AgentOptions.subagentDepth` field (0 for a top-level agent, parent + 1 for a child), so a nested spawn reads its parent's depth from `parent.options.subagentDepth`. `depthOf` reads it (absent ⇒ 0). + +### `SubagentDepthError` + +Thrown by `startInProcessRun` when a spawn would exceed the request's `maxDepth` cap; carries `attemptedDepth` and `maxDepth`. diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json new file mode 100644 index 0000000000..f3bd774554 --- /dev/null +++ b/packages/subagent/subagent-inprocess/package.json @@ -0,0 +1,42 @@ +{ + "name": "@deepseek-ai/dsh-subagent-inprocess", + "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", + "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.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts new file mode 100644 index 0000000000..4b8d2d4c99 --- /dev/null +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -0,0 +1,195 @@ +/** + * The shared in-process subagent run driver: run a child as a child + * {@link Agent} on the SAME cordis context (`ctx.agents`) — the cheapest + * transport, reusing the agent factory's quiescent {@link AgentHandle} + * teardown. The concrete in-process backends are thin shells over this driver, + * differing ONLY in the `seed` they pass (a fresh child vs. a child seeded with + * a prefix of the parent's log); everything downstream — drive the child, read + * its final output, map the stop reason, dispose — is identical and lives here. + * + * This package owns no provider and registers nothing; it is a pure library the + * backend packages depend on, so neither backend needs to know about the other. + * + * @module @deepseek-ai/dsh-subagent-inprocess + */ + +import { randomUUID } from 'node:crypto' +import type { Context } from 'cordis' +import { AgentId, type Agent, type AgentHandle, type 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 type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' + +declare module '@deepseek-ai/dsh-agent' { + interface AgentOptions { + /** + * The agent's delegation depth in the subagent tree — 0 for a top-level + * (config/ACP-created) agent, parent depth + 1 for a subagent. Set by the + * in-process backends on every child they create so a nested spawn reads its + * parent's depth from `parent.options.subagentDepth` and the `depthLimit` + * capability can cap the tree. Merge-extensible field (the seam owns it; the + * loop neither sets nor reads it). + */ + subagentDepth?: number + } +} + +/** Read an agent's delegation depth (absent ⇒ a top-level agent, depth 0). */ +export function depthOf(agent: Agent): number { + return agent.options.subagentDepth ?? 0 +} + +/** Thrown when a spawn would exceed the request's `maxDepth` cap. */ +export class SubagentDepthError extends Error { + constructor(public readonly attemptedDepth: number, public readonly maxDepth: number) { + super(`subagent depth ${attemptedDepth} exceeds maxDepth ${maxDepth}`) + this.name = 'SubagentDepthError' + } +} + +/** Map a session `turn/end` reason to a {@link SubagentStopReason}. */ +function toStopReason(reason: TurnEndReason | undefined): SubagentStopReason { + switch (reason?.kind) { + case 'completed': + return 'completed' + case 'max-tokens': + return 'max-tokens' + case 'aborted': + return 'aborted' + // `disposed` (torn down mid-turn) and `interrupted` (crash-closed) both mean + // the turn did not finish cleanly; surface them as a generic failure rather + // than a clean completion. A missing reason (no turn ran) is also an error. + case 'error': + case 'disposed': + case 'interrupted': + default: + return 'error' + } +} + +/** Extra inputs the spawn/fork backends supply to {@link startInProcessRun}. */ +export interface InProcessRunOptions { + /** The provider name (`spawn`/`fork`), for error context only. */ + readonly providerName: string + /** + * The child session's seed: a balanced, contiguous-from-0 prefix of the + * parent's log (FORK), or `undefined` for a fresh child (SPAWN). + */ + readonly seed?: SessionEvent[] +} + +/** + * Start an in-process child agent for `request` and return a {@link SubagentRun}. + * + * Drives the child as a one-shot: `send(prompt)` then `whenIdle()` (the ordering + * matters — `send` enqueues synchronously, so `whenIdle` observes the queued + * work and resolves only on the child's `running → idle` transition, never + * before the turn starts). The final `assistant/message` is the result output, + * the matching `turn/end.reason` the stop reason. `dispose()` delegates to the + * factory's {@link AgentHandle.dispose} (stop loop → await quiescence → remove + * session); `cancel()` cancels the child's in-flight turn. + */ +export function startInProcessRun( + ctx: Context, + request: SubagentStartRequest, + options: InProcessRunOptions, +): SubagentRun { + const childDepth = depthOf(request.parent) + 1 + if (request.maxDepth !== undefined && childDepth > request.maxDepth) { + throw new SubagentDepthError(childDepth, request.maxDepth) + } + + const childId = AgentId(randomUUID()) + // The child's OWN events begin after the seed (fork seeds the parent's + // completed-turn prefix; spawn seeds nothing). `readResult` scopes to this + // boundary so a child that produces no message of its own never returns the + // SEEDED parent's last assistant message as its result. + const seedLength = options.seed?.length ?? 0 + const parentHeader = request.parent.session.header + // Inherit the parent's model by default (a child with no model cannot run); + // an explicit `request.agentOptions.model` overrides it. The parent's + // systemPrompt is NOT inherited — a fresh child is a clean specialist unless + // the caller supplies one. + const agentOptions: AgentOptions = { + ...request.parent.options.model !== undefined ? { model: request.parent.options.model } : {}, + ...request.agentOptions, + subagentDepth: childDepth, + } + + const handle: AgentHandle = ctx.agents.create({ + agentId: childId, + sessionId: SessionId(randomUUID()), + meta: { + ...parentHeader.cwd !== undefined ? { cwd: parentHeader.cwd } : {}, + parentSession: parentHeader.id, + // Record the seed boundary so a reload (and a replay harness) can tell the + // inherited prefix from the child's OWN events. 0 for a fresh spawn. + ...seedLength > 0 ? { seedLength } : {}, + }, + ...options.seed !== undefined ? { seed: options.seed } : {}, + agentOptions, + }) + const child = handle.agent + + // Bridge the request's abort signal to the child (the consumer also bridges + // its own exec.signal, but a backend-level bridge keeps the contract local). + // `cancelled` records that a cancel was requested at all, so the pre-turn + // cancel window — where the child clears the queued prompt before any + // `turn/end` is logged — settles as `aborted` (honoring the cancel contract) + // rather than falling through to the no-turn `error` mapping. + let cancelled = false + const requestCancel = (reason: string): void => { + cancelled = true + child.cancel(reason) + } + const onAbort = (): void => { requestCancel('subagent cancelled') } + request.signal?.addEventListener('abort', onAbort, { once: true }) + + const result: Promise = (async () => { + try { + // A signal already aborted BEFORE the run starts never fires an `abort` + // event (`addEventListener` only fires on the transition), so the listener + // above won't catch it — settle `aborted` without running the child rather + // than completing an already-cancelled request. + if (request.signal?.aborted) return { output: [], stopReason: 'aborted' } + child.send(request.prompt) + await child.whenIdle() + return readResult(child, seedLength, cancelled) + } finally { + request.signal?.removeEventListener('abort', onAbort) + } + })() + + return { + id: childId, + result, + cancel(reason?: string): void { + requestCancel(reason ?? 'subagent cancelled') + }, + async dispose(): Promise { + request.signal?.removeEventListener('abort', onAbort) + await handle.dispose() + }, + } +} + +/** + * Read a settled child's terminal result from its session log, scoped to the + * child's OWN events (everything at or after `seedLength` — fork seeds the + * parent's completed-turn prefix, so a child that produced no message of its + * own must NOT return the seeded parent's last assistant message). The output + * is the child's last `assistant/message` content (deep-cloned — the log is + * frozen); the stop reason is the child's last `turn/end` reason mapped to a + * {@link SubagentStopReason}. When `cancelled` is set but no `turn/end` was + * logged (a cancel landed in the pre-turn window, before any turn ran), the + * run settles `aborted` per the {@link SubagentRun.cancel} contract rather than + * the generic no-turn `error`. + */ +function readResult(child: Agent, seedLength: number, cancelled: boolean): SubagentResult { + const own = child.session.events.slice(seedLength) + const lastMessage = own.findLast((e): e is SessionEvent<'assistant/message'> => e.type === 'assistant/message') + const lastEnd = own.findLast((e): e is SessionEvent<'turn/end'> => e.type === 'turn/end') + const output: ContentBlock[] = lastMessage ? structuredClone(lastMessage.data.content) : [] + if (lastEnd === undefined && cancelled) return { output, stopReason: 'aborted' } + return { output, stopReason: toStopReason(lastEnd?.data.reason) } +} diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts new file mode 100644 index 0000000000..7219e03988 --- /dev/null +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -0,0 +1,85 @@ +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 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 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' + +type Script = ConstructorParameters[0] + +/** + * Drives the shared in-process run driver DIRECTLY (no provider package), so the + * driver's own contract — depth read/cap, the one-shot drive, the result read — + * is covered independently of which backend (spawn/fork) calls it. The only + * mocked boundary is the model; the real agent loop, SubagentService, and + * dsh-invariants are mounted, so a malformed child session log fails the test. + */ +async function setup(script: Script) { + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + 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' }) + return { ctx, parent } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('depthOf', () => { + it('reads 0 for an agent with no subagentDepth, the set value otherwise', async () => { + const { parent } = await setup([]) + expect(depthOf(parent)).toBe(0) + const withDepth = { options: { subagentDepth: 3 } } as unknown as Agent + expect(depthOf(withDepth)).toBe(3) + }) +}) + +describe('startInProcessRun', () => { + it('drives a fresh child (no seed) to completion and returns its output', async () => { + const { ctx, parent } = await setup([textResponse('driver child answer')]) + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'do X' }], parent }, { providerName: 'spawn' }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('driver child answer') + expect(depthOf(ctx.agents.get(run.id)!)).toBe(1) + await run.dispose() + }) + + it('throws SubagentDepthError when the child would exceed maxDepth', async () => { + const { ctx, parent } = await setup([]) + expect(() => startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 }, { providerName: 'spawn' })) + .toThrow(SubagentDepthError) + }) + + it('seeds the child session when a seed is supplied', async () => { + // Drive the parent through one real turn, then seed the child with that + // completed-turn prefix — the child must SEE the parent's history but its + // result is scoped to its OWN events (not the seeded parent message). + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('seeded child reply')]) + parent.send([{ type: 'text', text: 'parent q' }]) + await parent.whenIdle() + const seed = parent.session.events.slice() + const run = startInProcessRun(ctx, { prompt: [{ type: 'text', text: 'child q' }], parent }, { providerName: 'fork', seed }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('seeded child reply') + const child = ctx.agents.get(run.id)! + // The child inherited the parent's prefix. + expect(child.session.events.slice(0, seed.length).some(e => e.type === 'user/message')).toBe(true) + await run.dispose() + }) +}) diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json new file mode 100644 index 0000000000..4cb435d4fb --- /dev/null +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/subagent/subagent-spawn/README.md b/packages/subagent/subagent-spawn/README.md new file mode 100644 index 0000000000..97dfae9304 --- /dev/null +++ b/packages/subagent/subagent-spawn/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-subagent-spawn + +The in-process **spawn** subagent backend: a [`SubagentProvider`](../subagent/README.md) that runs each child as a **fresh** child [`Agent`](../../core/agent) on the same cordis context (`ctx.agents`) — its own session, its own (or the parent's) model, zero inherited conversation. The cheapest transport, reusing the agent factory's quiescent [`AgentHandle`](../../core/agent) teardown. + +The run mechanics live in the shared [`@deepseek-ai/dsh-subagent-inprocess`](../subagent-inprocess/README.md) driver (`startInProcessRun`); this backend just passes **no seed** (a fresh child). The [fork](../subagent-fork/README.md) backend is an independent peer over the same driver — neither knows about the other. + +## What it does + +`start(request)` delegates to `startInProcessRun(ctx, request, { providerName })` with no seed: a fresh child agent with the parent's `cwd`/`parentSession` lineage and (by default) the parent's model. See the [driver README](../subagent-inprocess/README.md) for the full lifecycle (depth check, one-shot drive, result read, dispose). + +## Capabilities + +`{ outputSchema: false, depthLimit: true, toolFilter: false }`. It constructs the child, so it enforces a recursion cap; structured output and tool-scoping are deferred (the service rejects a request needing either before `start` runs). + +## Config + +| Key | Meaning | +|---|---| +| `providerName` | Registry name on `ctx.subagents` (default `spawn`). | diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json new file mode 100644 index 0000000000..087371ded2 --- /dev/null +++ b/packages/subagent/subagent-spawn/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-subagent-spawn", + "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.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-subagent": "^0.0.1", + "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts new file mode 100644 index 0000000000..2ea082e20a --- /dev/null +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -0,0 +1,54 @@ +/** + * The in-process SPAWN subagent backend: registers a {@link SubagentProvider} + * on `ctx.subagents` that runs each child as a FRESH child {@link Agent} on the + * same cordis context (its own session, own system prompt, zero parent + * context). The cheapest transport, reusing the agent factory's quiescent + * teardown. + * + * The run mechanics live in `@deepseek-ai/dsh-subagent-inprocess` + * ({@link startInProcessRun}); this backend just passes NO seed (a fresh + * child). The fork backend is an independent peer over the same driver. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default. + * + * @module @deepseek-ai/dsh-subagent-spawn + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import { startInProcessRun } from '@deepseek-ai/dsh-subagent-inprocess' + +export const name = 'subagent-spawn' +export const inject = ['subagents', 'agents'] + +/** Config: the registry name to register the provider under. */ +export interface Config { + /** Provider name on `ctx.subagents` (default `spawn`). */ + providerName: string +} + +export const Config: z = z.object({ + providerName: z.string().default('spawn'), +}) + +/** + * The spawn provider. Supports `depthLimit` (it constructs the child, so it can + * enforce a recursion cap) but NOT `outputSchema` or `toolFilter` in this cut — + * a request that needs either is rejected by the service before `start` runs. + */ +class SpawnProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities = { outputSchema: false, depthLimit: true, toolFilter: false } + + constructor(readonly name: string, private readonly ctx: Context) {} + + start(request: SubagentStartRequest) { + // Fresh child: no seed. The shared driver mints ids, stamps cwd/lineage/ + // depth, drives the one-shot, and maps the result. + return startInProcessRun(this.ctx, request, { providerName: this.name }) + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.subagents.registerProvider(new SpawnProvider(config.providerName, ctx)) +} diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts new file mode 100644 index 0000000000..ff551cfc3f --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -0,0 +1,49 @@ +import { Context } from 'cordis' +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, { 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 LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' +import SubagentService from '@deepseek-ai/dsh-subagent' +import * as Spawn from '../src/index.ts' +import * as ToolSubagent from '@deepseek-ai/dsh-tool-subagent' + +/** + * Shared harness for the spawn-backend e2e: the full real stack (DeepSeek + * adapter + real bash tool + the subagent tool bound to the spawn backend), so + * a real parent agent can delegate to a real in-process child that does real + * work (writes a file). Lives outside the *.e2e.ts pattern so importing it never + * re-registers another file's tests. + */ +export async function spawnHarness(workdir: string): 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) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(LlmDeepSeek, { models: ['deepseek-v4-flash'] }) + await ctx.plugin(LocalBashExecutor, { cwd: workdir, timeoutMs: 30_000 }) + await ctx.plugin(ToolBash) + await ctx.plugin(SubagentService) + await ctx.plugin(Spawn, { providerName: 'spawn' }) + // The model-facing subagent tool, bound to the spawn backend. + await ctx.plugin(ToolSubagent, { provider: 'spawn' }) + return ctx +} + +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') { + dispose() + resolve() + } + }) + }) +} diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts new file mode 100644 index 0000000000..8179027976 --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -0,0 +1,54 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +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' + +/** + * With-key smoke for the in-process spawn backend: a REAL parent agent delegates + * to a REAL child (via the `subagent` tool → spawn backend) that uses the REAL + * bash tool to write a file, and we verify the WORLD (the file on disk) — not + * the agent's self-report. This is the "green units, broken product" guard: + * mocks prove the plumbing, only a real model proves a parent can actually drive + * a child to do real work. Key-gated (self-skips without DEEPSEEK_API_KEY). + */ + +let ctx: Context | undefined +let workdir: string | undefined + +afterEach(async () => { + await ctx?.fiber.dispose() + ctx = undefined + if (workdir !== undefined) await rm(workdir, { recursive: true, force: true }) + workdir = undefined +}) + +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', + systemPrompt: 'You are an orchestrator. To do file work, delegate to a subagent with the `subagent` tool — ' + + 'give it a complete, standalone instruction. Report only when done.', + }) + + parent.send([{ type: 'text', text: + 'Use the subagent tool to delegate this exact task: "Use the bash tool to write the text ' + + 'SUBAGENT_WAS_HERE into a file named proof.txt in the current directory." ' + + 'After the subagent finishes, tell me it is done.' }]) + await waitForIdle(ctx, parent) + + // Verify the WORLD: the child actually wrote the file. + const proof = await readFile(join(workdir, 'proof.txt'), 'utf8') + expect(proof).toContain('SUBAGENT_WAS_HERE') + + // The parent's log records the subagent tool/call + its result (not the + // child's internal steps). + const events = [...parent.session.events] + const subagentCalls = events.filter(e => e.type === 'tool/call' && e.data.name === 'subagent') + expect(subagentCalls.length).toBeGreaterThan(0) + }, 180_000) +}) diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts new file mode 100644 index 0000000000..ccfd6492f4 --- /dev/null +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -0,0 +1,271 @@ +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 SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import AgentRegistry, { AgentId } 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' +import SubagentService from '@deepseek-ai/dsh-subagent' +import { MockAdapter, maxTokensResponse, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import * as spawn from '../src/index.ts' +import { depthOf, SubagentDepthError } from '@deepseek-ai/dsh-subagent-inprocess' + +type Script = ConstructorParameters[0] + +/** + * Drives the REAL spawn backend end-to-end: a real agent loop + a scripted mock + * MODEL (the only mocked boundary) + the real SubagentService + the real + * dsh-invariants plugin (so a malformed child session log would fail the test). + * The parent is a real config agent; the spawn provider creates a real child + * agent on the same context and we assert its output. + */ +async function setup(script: Script) { + const ctx = new Context() + const adapter = new MockAdapter(script) + await ctx.plugin(LlmService) + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(AgentRegistry) + await ctx.plugin(Invariants) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(spawn, { providerName: 'spawn' }) + ctx.llm.registerAdapter(['mock'], adapter) + const parent = ctx.agentLoop.create(AgentId('parent'), { model: 'mock' }) + return { ctx, parent, adapter } +} + +function text(blocks: { type: string; text?: string }[]): string { + return blocks.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('dsh-subagent-spawn', () => { + it('runs a fresh child to completion and returns its final assistant output', async () => { + // One model call for the child: a plain text answer. + const { ctx, parent } = await setup([textResponse('child answer')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'do X' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('child answer') + await run.dispose() + }) + + it('gives the child its OWN session (not the parent\'s), with parentSession lineage', async () => { + const { ctx, parent } = await setup([textResponse('hi')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.id).not.toBe(parent.session.header.id) + expect(child.session.header.parentSession).toBe(parent.session.header.id) + await run.dispose() + }) + + it('a fresh child does NOT inherit the parent conversation (its log starts empty before the prompt)', async () => { + // Drive the parent through one real turn so it has history, THEN spawn. + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('child sees nothing')]) + parent.send([{ type: 'text', text: 'parent prompt' }]) + await parent.whenIdle() + const parentEventCount = parent.session.events.length + expect(parentEventCount).toBeGreaterThan(0) + + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'child prompt' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + // The child's first user/message is its OWN prompt, not the parent's history. + const firstUser = child.session.events.find(e => e.type === 'user/message') + expect(firstUser).toBeDefined() + await run.dispose() + }) + + it('disposes the child to quiescence (agent removed from the registry)', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + expect(ctx.agents.get(run.id)).toBeDefined() + await run.dispose() + // After dispose, the child is unregistered (the AgentHandle teardown ran). + expect(ctx.agents.get(run.id)).toBeUndefined() + }) + + 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) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(depthOf(child)).toBe(1) + await run.dispose() + }) + + it('refuses to spawn past maxDepth (depthLimit capability)', async () => { + const { ctx, parent } = await setup([]) + // parent is depth 0, child would be depth 1 — cap at 0 forbids any child. + expect(() => ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, maxDepth: 0 })) + .toThrow(SubagentDepthError) + }) + + it('maps a child that hit its token ceiling to stopReason "max-tokens"', async () => { + const { ctx, parent } = await setup([maxTokensResponse('cut off')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + await run.dispose() + }) + + it('maps a child whose turn errored (script exhausted) to stopReason "error" with empty output', async () => { + // Empty script: the child's first model call throws "script exhausted", the + // turn ends `error`, and there is no assistant/message → empty output. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + const result = await run.result + expect(result.stopReason).toBe('error') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('settles aborted (without running the child) when the request signal is ALREADY aborted', async () => { + // Regression: a signal aborted BEFORE the run starts never fires an `abort` + // event, so the listener can't catch it. The driver must check the + // already-aborted case up front and settle `aborted` without running the + // child — otherwise an already-cancelled request runs to `completed`. The + // empty script proves the child's model is never called. + const controller = new AbortController() + controller.abort() + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('cancelling BEFORE the child turn starts settles aborted, not error', async () => { + // Regression: a cancel landing in the pre-turn window clears the queued + // prompt before any `turn/end` is logged. Deriving the stop reason from + // `turn/end` alone then mis-maps the no-turn case to `error`; the run must + // honor the cancel contract and settle `aborted`. The cancel is synchronous + // (same tick as start, before the loop's queued-wait continuation runs), so + // the turn is dropped and the empty script is never consumed. + const { ctx, parent } = await setup([]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + run.cancel('early') + const result = await run.result + expect(result.stopReason).toBe('aborted') + expect(result.output).toEqual([]) + await run.dispose() + }) + + it('cancelling a running child settles the run as aborted (the abort bridge + cancel())', async () => { + // 'hang' makes the child's model stream one chunk then wait until aborted. + const controller = new AbortController() + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent, signal: controller.signal }) + // Let the child's turn start, then abort via the request signal (the + // backend bridges it to child.cancel()). + await new Promise(r => setTimeout(r, 30)) + controller.abort() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('run.cancel() also cancels the child directly', async () => { + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await new Promise(r => setTimeout(r, 30)) + run.cancel('test cancel') + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('run.cancel() with no reason uses the default cancel reason', async () => { + const { ctx, parent } = await setup(['hang']) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + await new Promise(r => setTimeout(r, 30)) + run.cancel() + const result = await run.result + expect(result.stopReason).toBe('aborted') + await run.dispose() + }) + + it('does not expose the optional runtime methods (sendMessage/resume) in this cut', async () => { + const { ctx, parent } = await setup([textResponse('x')]) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent }) + expect('sendMessage' in run).toBe(false) + expect('resume' in run).toBe(false) + await run.result + await run.dispose() + }) + + it('inherits the parent cwd into the child session', async () => { + const { ctx } = await setup([textResponse('x')]) + // A parent WITH a cwd (config agents have none, so create one explicitly). + const parentHandle = ctx.agents.create({ + agentId: AgentId('cwd-parent'), + sessionId: SessionId('cwd-parent-session'), + meta: { cwd: '/tmp/parent-workspace' }, + agentOptions: { model: 'mock' }, + }) + const run = ctx.subagents.start('spawn', { prompt: [{ type: 'text', text: 'p' }], parent: parentHandle.agent }) + await run.result + const child = ctx.agents.get(run.id)! + expect(child.session.header.cwd).toBe('/tmp/parent-workspace') + await run.dispose() + await parentHandle.dispose() + }) + + it('uses request.agentOptions.model when the parent has no model of its own', async () => { + const { ctx } = await setup([textResponse('explicit model child')]) + // A parent with NO model (its own turns would need one supplied per-request). + const parentHandle = ctx.agents.create({ + agentId: AgentId('modelless-parent'), + sessionId: SessionId('modelless-parent-session'), + agentOptions: {}, + }) + // The request supplies the child's model explicitly. + const run = ctx.subagents.start('spawn', { + prompt: [{ type: 'text', text: 'p' }], + parent: parentHandle.agent, + agentOptions: { model: 'mock' }, + }) + const result = await run.result + expect(result.stopReason).toBe('completed') + expect(text(result.output)).toBe('explicit model child') + await run.dispose() + await parentHandle.dispose() + }) + + it('advertises depthLimit but not outputSchema/toolFilter', async () => { + const { ctx } = await setup([]) + const provider = ctx.subagents.getProvider('spawn')! + expect(provider.capabilities).toEqual({ outputSchema: false, depthLimit: true, toolFilter: false }) + }) + + it('unregisters the provider when its fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(spawn, { providerName: 'spawn' }) + expect(ctx.subagents.list()).toEqual(['spawn']) + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('has the namespace-plugin export shape (no stray default)', () => { + expect('default' in spawn).toBe(false) + expect(spawn.name).toBe('subagent-spawn') + expect(spawn.inject).toEqual(['subagents', 'agents']) + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(spawn) as Record + expect(unwrapped).toBe(spawn) + expect(unwrapped.name).toBe('subagent-spawn') + expect(unwrapped.inject).toEqual(['subagents', 'agents']) + expect(typeof unwrapped.apply).toBe('function') + }) +}) diff --git a/packages/subagent/subagent-spawn/tsconfig.json b/packages/subagent/subagent-spawn/tsconfig.json new file mode 100644 index 0000000000..219bf2a0c9 --- /dev/null +++ b/packages/subagent/subagent-spawn/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../subagent" + }, + { + "path": "../subagent-inprocess" + } + ] +} diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md new file mode 100644 index 0000000000..57862ca8ab --- /dev/null +++ b/packages/subagent/subagent/README.md @@ -0,0 +1,39 @@ +# @deepseek-ai/dsh-subagent + +The **subagent seam**: an abstract `SubagentService` (`ctx.subagents`) for an agent delegating work to another agent. A *subagent* is a child agent; a `SubagentProvider` is one transport for running it. + +This package is the interface third of the capability seam, split so each concern evolves (and swaps) independently: + +| Package | Role | +|---|---| +| `@deepseek-ai/dsh-subagent` (this) | the interface: registry service + vocabulary types | +| `@deepseek-ai/dsh-subagent-spawn` | an implementation: fresh in-process child | +| `@deepseek-ai/dsh-subagent-fork` | an implementation: in-process child seeded from the parent's log | +| `@deepseek-ai/dsh-subagent-acp` | an implementation: ACP client driving another process | +| `@deepseek-ai/dsh-tool-subagent` | the model-facing tool over `ctx.subagents` | + +Unlike the bash seam (one executor per context, second load throws), **multiple providers coexist** here. Each registers under a unique name and a caller picks one by name — the shape mirrors the LLM adapter registry (`LlmService.registerAdapter`), not the single-service bash executor. This is the requirement that rules out the bash shape: an agent may want an in-process child for a cheap subtask and an out-of-process ACP child for an isolated one, in the same runtime. + +## Service API (`ctx.subagents`) + +| Member | Semantics | +|---|---| +| `registerProvider(provider)` | Register under `provider.name`. Throws `SubagentError('DUPLICATE_PROVIDER')` on a name clash. Effect-scoped (HMR-safe); returns the disposer. | +| `getProvider(name)` | Look up a provider (`undefined` if absent). | +| `list()` | Registered provider names (insertion order). | +| `start(name, request)` | Resolve the provider (`NO_PROVIDER` if absent), validate every requested START-TIME capability (`UNSUPPORTED_CAPABILITY` for the first unmet one — before any child is created), then delegate to `provider.start` and emit `subagent/start` / `subagent/end` around the run. | + +## Capabilities: two kinds, discovered two ways + +- **Start-time features** (`outputSchema`, `depthLimit`, `toolFilter`) are a static `provider.capabilities` descriptor, checked by the service BEFORE a run exists. A request that needs one the provider lacks is **rejected loud** (`UNSUPPORTED_CAPABILITY`), never accepted-then-ignored. +- **Runtime features** (steering, resume) are **optional methods** on `SubagentRun` (`sendMessage?`, `resume?`). The method's presence IS the capability; TS narrowing is the discovery mechanism — a consumer cannot call an absent method without narrowing first, so there is no silent degradation path. + +## Run lifecycle + +`provider.start(request)` returns a `SubagentRun`: a handle with a `result` promise, `cancel()`, `dispose()`, and the optional runtime methods. `result` resolves with a `SubagentResult` (`output`, optional `structured`, `stopReason`) — it does **not** reject on a child-level failure (a model/transport failure resolves with `stopReason: 'error'`), so the consumer maps a non-`completed` reason to an `isError` tool result. The consumer MUST `dispose()` on every path (success, error, abort) to reach child quiescence and avoid leaking an idle child / session. + +## Scope (first cut) + +The consumer collects **synchronously**: it starts a run and awaits `result`. Steering (`sendMessage`) is part of the contract but intentionally unused. Background / poll / spill semantics are deferred to a future redesign unifying long-running-tool handling across subagents and bash. See the RFC: [docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md). + +See `src/types.ts` for the full contracts. diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json new file mode 100644 index 0000000000..be5baeb0e1 --- /dev/null +++ b/packages/subagent/subagent/package.json @@ -0,0 +1,36 @@ +{ + "name": "@deepseek-ai/dsh-subagent", + "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child 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-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts new file mode 100644 index 0000000000..356ad60a00 --- /dev/null +++ b/packages/subagent/subagent/src/index.ts @@ -0,0 +1,224 @@ +/** + * The subagent seam (`ctx.subagents`): a named-provider registry plus a + * capability-validating `start` surface. A subagent is an agent delegating + * work to another agent; a {@link SubagentProvider} is one transport for + * running that child (in-process spawn/fork, ACP to another process, and — + * later — A2A, the Codex app-server, the Claude Code Agent SDK). + * + * Unlike the bash seam (one executor per context, second load throws), MULTIPLE + * providers coexist here: each registers under a unique name and a caller picks + * one by name. The shape mirrors the LLM adapter registry + * (`LlmService.registerAdapter`), not the single-service bash executor. + * + * This package is the INTERFACE third of the capability seam. Implementations + * (`@deepseek-ai/dsh-subagent-spawn`, `-fork`, `-acp`) and the model-facing + * consumer (`@deepseek-ai/dsh-tool-subagent`) are separate packages. + * + * Scope (first cut): the consumer collects synchronously — it starts a run and + * awaits {@link SubagentRun.result}. Steering ({@link SubagentRun.sendMessage}) + * is part of the contract but intentionally unused; background / poll / spill + * semantics are deferred to a future redesign that unifies long-running-tool + * handling across subagents and bash. + * + * @module @deepseek-ai/dsh-subagent + */ + +import { Context, Service } from 'cordis' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import type { AgentId } from '@deepseek-ai/dsh-agent' +import type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, +} from './types.ts' + +export type { + SubagentCapabilities, + SubagentProvider, + SubagentResult, + SubagentRun, + SubagentStartRequest, + SubagentStopReason, + SubagentStopReasonMap, +} from './types.ts' + +declare module 'cordis' { + interface Context { + subagents: SubagentService + } + + interface Events { + /** + * A subagent run started — emitted after the provider is resolved and its + * capabilities validated, as the child run begins. Paired with + * {@link Events['subagent/end']}. + * @mode emit + */ + 'subagent/start'(info: SubagentRunInfo): void + /** + * A subagent run settled — emitted when {@link SubagentRun.result} + * resolves (any stop reason). Paired with {@link Events['subagent/start']}. + * @mode emit + */ + 'subagent/end'(info: SubagentRunEndInfo): void + } +} + +/** Identifying detail for a started subagent run (the `subagent/start` payload). */ +export interface SubagentRunInfo { + /** The provider that started the run. */ + provider: string + /** The child agent's id. */ + id: AgentId +} + +/** Outcome detail for a settled subagent run (the `subagent/end` payload). */ +export interface SubagentRunEndInfo { + /** The provider that ran it. */ + provider: string + /** The child agent's id. */ + id: AgentId + /** The terminal stop reason. */ + stopReason: SubagentResult['stopReason'] +} + +/** + * Typed error for subagent-seam failures. Extends {@link HarnessError}, so the + * `code` string (`DUPLICATE_PROVIDER`, `NO_PROVIDER`, `UNSUPPORTED_CAPABILITY`) + * is shared, machine-routable taxonomy. + */ +export class SubagentError extends HarnessError { + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, code, options) + this.name = 'SubagentError' + } +} + +/** + * The `subagents` service: a registry of named {@link SubagentProvider}s and a + * capability-checked {@link start} surface. + */ +export class SubagentService extends Service { + private providers = new Map() + + constructor(ctx: Context) { + super(ctx, 'subagents') + } + + /** + * Register a provider under its `provider.name`. Throws {@link SubagentError} + * (`DUPLICATE_PROVIDER`) if the name is already taken. Effect-scoped: disposed + * with the calling fiber (HMR-safe). + */ + registerProvider(provider: SubagentProvider): () => void { + const dispose = this.ctx.effect(function* (this: SubagentService) { + if (this.providers.has(provider.name)) { + throw new SubagentError(`a subagent provider named "${provider.name}" is already registered`, 'DUPLICATE_PROVIDER') + } + this.providers.set(provider.name, provider) + yield () => { + this.providers.delete(provider.name) + } + }.bind(this), 'subagents.registerProvider()') + // ctx.effect's disposer returns Promise; our disposer API is + // synchronous fire-and-forget — discard the (always-resolved) promise. + return () => void dispose() + } + + /** Look up a registered provider by name (`undefined` if absent). */ + getProvider(name: string): SubagentProvider | undefined { + return this.providers.get(name) + } + + /** The names of all registered providers (insertion order). */ + list(): string[] { + return [...this.providers.keys()] + } + + /** + * Start a subagent run on the named provider. Resolves the provider (throws + * `NO_PROVIDER` if absent), validates every requested START-TIME capability + * against {@link SubagentProvider.capabilities} (throws `UNSUPPORTED_CAPABILITY` + * for the first unmet one — fail loud, before any child is created), then + * delegates to {@link SubagentProvider.start} and emits `subagent/start` / + * `subagent/end` around the run. + */ + start(name: string, request: SubagentStartRequest): SubagentRun { + const provider = this.providers.get(name) + if (!provider) { + throw new SubagentError(`no subagent provider registered for "${name}"`, 'NO_PROVIDER') + } + this.assertCapabilities(provider, request) + + const run = provider.start(request) + // Emit `subagent/start` with PER-LISTENER containment (see {@link emitLifecycle}): + // the run is already live, so neither a throwing subscriber escaping + // `start()` (the caller would never receive the run to dispose it — a leaked + // child) NOR one bad subscriber starving the listeners after it is + // acceptable. `ctx.emit` halts the dispatch on the first throw, so a single + // surrounding try/catch is not enough — each listener is invoked and + // contained individually. + this.emitLifecycle('subagent/start', { provider: name, id: run.id }) + // Emit `subagent/end` when the run settles. The result promise does not + // reject on a child-level failure (it resolves with stopReason 'error'), + // so a rejection here is an infrastructure fault — surface its stop reason + // as 'error' for the telemetry event without swallowing the rejection + // (the consumer still observes it via `run.result`). Per-listener + // containment also keeps a thrown `subagent/end` listener from becoming an + // unhandled rejection on this detached `.then`. + void run.result.then( + (result) => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: result.stopReason }) }, + () => { this.emitLifecycle('subagent/end', { provider: name, id: run.id, stopReason: 'error' }) }, + ) + return run + } + + /** + * Emit a `subagent/*` lifecycle event with PER-LISTENER containment: dispatch + * each subscriber individually and log (never propagate) a thrown one, so one + * bad subscriber can neither strand the already-live run, surface as an + * unhandled rejection on the detached settle hook, NOR starve the listeners + * registered after it. A single try/catch around `ctx.emit` would not do the + * last part — cordis `emit` runs listeners in a `.map(cb => cb())` that halts + * on the first throw — so this resolves the listener callbacks via + * `ctx.events.dispatch` and contains each call, the same guarantee + * `BashExecutor.notifyTaskDone` gives its own listener set. + */ + private emitLifecycle( + name: 'subagent/start' | 'subagent/end', + info: SubagentRunInfo | SubagentRunEndInfo, + ): void { + for (const callback of this.ctx.events.dispatch('emit', [name, info])) { + try { + callback(info) + } catch (error: unknown) { + this.ctx.logger.warn(`subagent: ${name} listener threw: ${String(error)}`) + } + } + } + + /** + * Reject a request that needs a start-time capability the provider lacks. + * Each optional request field maps to one {@link SubagentCapabilities} flag; + * the first unmet one throws `UNSUPPORTED_CAPABILITY`. + */ + private assertCapabilities(provider: SubagentProvider, request: SubagentStartRequest): void { + const needs: { when: boolean; cap: keyof SubagentCapabilities }[] = [ + { when: request.outputSchema !== undefined, cap: 'outputSchema' }, + { when: request.maxDepth !== undefined, cap: 'depthLimit' }, + { when: request.toolFilter !== undefined, cap: 'toolFilter' }, + ] + for (const { when, cap } of needs) { + if (when && !provider.capabilities[cap]) { + throw new SubagentError( + `subagent provider "${provider.name}" does not support the "${cap}" capability`, + 'UNSUPPORTED_CAPABILITY', + ) + } + } + } +} + +export default SubagentService diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts new file mode 100644 index 0000000000..fb60d5667c --- /dev/null +++ b/packages/subagent/subagent/src/types.ts @@ -0,0 +1,172 @@ +/** + * Subagent seam vocabulary: the request/result/capability types a + * {@link SubagentProvider} consumes and produces. No runtime code — types + * only, per the package convention. + * + * @module @deepseek-ai/dsh-subagent/types + */ + +import type { Agent, AgentId, AgentOptions } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SchemaSpec } from '@deepseek-ai/dsh-tools' + +/** + * 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). + * + * Start-time features live here (a static descriptor) because they must be + * checked before a run exists. RUNTIME features (steering, resume) are instead + * modeled as OPTIONAL METHODS on {@link SubagentRun}: the method's presence IS + * the capability, and TS narrowing is the discovery mechanism — a consumer + * cannot call an absent method without narrowing first. + */ +export interface SubagentCapabilities { + /** Honor {@link SubagentStartRequest.outputSchema} (structured final output). */ + outputSchema: boolean + /** Enforce {@link SubagentStartRequest.maxDepth} (recursion cap). */ + depthLimit: boolean + /** Enforce {@link SubagentStartRequest.toolFilter} (child tool scoping). */ + toolFilter: boolean +} + +/** + * 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}. + */ +export interface SubagentStartRequest { + /** The task/prompt for the child agent (a user message in the child session). */ + 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. + */ + parent: Agent + /** + * Cancellation signal from the spawning context (the tool's `exec.signal`). + * A provider that honors it aborts the child when the signal fires; the + * consumer also bridges it to {@link SubagentRun.cancel} explicitly. + */ + signal?: AbortSignal + /** Per-child agent options (model, system prompt). */ + agentOptions?: AgentOptions + /** + * Optional structured-output schema. When set AND the provider's + * {@link SubagentCapabilities.outputSchema} is `true`, the child's final + * answer is shaped to this schema and surfaced as {@link SubagentResult.structured}. + * Requesting it against a provider that lacks the capability is rejected at start. + */ + outputSchema?: SchemaSpec + /** + * Optional recursion cap (max delegation depth below this child). Requires + * {@link SubagentCapabilities.depthLimit}; rejected at start otherwise. + */ + maxDepth?: number + /** + * Optional child tool scoping. Requires {@link SubagentCapabilities.toolFilter}; + * rejected at start otherwise. + */ + toolFilter?: { allow?: string[]; deny?: string[] } +} + +/** + * 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. + */ +export interface SubagentStopReasonMap { + /** The child finished its turn normally. */ + completed: 'completed' + /** The run was cancelled (parent signal, explicit `cancel()`, or peer cancel). */ + 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' +} + +export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonMap] + +/** + * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. + */ +export interface SubagentResult { + /** The child's final assistant output (the last assistant message's content). */ + output: ContentBlock[] + /** + * The structured result, present IFF the request carried an `outputSchema` + * AND the provider honored it. Shape is validated against the request schema + * by the provider; `unknown` here because the seam is schema-agnostic. + */ + structured?: unknown + /** Why the run ended. A non-`completed` reason means `output` may be partial. */ + stopReason: SubagentStopReason +} + +/** + * A live subagent run: a handle the consumer holds while a child executes. + * Returned by {@link SubagentProvider.start} (via the service). The consumer + * awaits {@link result}, may {@link cancel} mid-flight, and MUST {@link dispose} + * on every path to reach child quiescence (no leaked idle child / session). + * + * {@link sendMessage} and {@link resume} are OPTIONAL: a provider that supports + * the runtime capability defines the method; one that doesn't omits it. The + * presence of the method IS the capability — narrow before calling. + */ +export interface SubagentRun { + /** The child agent's id (use `ctx.agents.get(id)` to reach the live child). */ + readonly id: AgentId + /** + * 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 + /** Request cancellation of the in-flight run; {@link result} settles `aborted`. */ + cancel(reason?: string): void + /** + * Reach child quiescence and release the run's resources (in-process: dispose + * the owned agent handle and remove its session; ACP: kill the subprocess). + * Idempotent; awaits the child actually stopping, not merely requesting it. + */ + 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[]): SubagentRun +} + +/** + * 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). + */ +export 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 + /** + * Start a child run. 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. + */ + start(request: SubagentStartRequest): SubagentRun +} diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts new file mode 100644 index 0000000000..6876c6cd80 --- /dev/null +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -0,0 +1,242 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { AgentId, type Agent } from '@deepseek-ai/dsh-agent' +import { HarnessError } from '@deepseek-ai/dsh-llm' +import SubagentService, { + SubagentError, + type SubagentCapabilities, + type SubagentProvider, + type SubagentResult, + type SubagentRun, + type SubagentStartRequest, +} from '@deepseek-ai/dsh-subagent' + +/** A minimal parent Agent stand-in — the service only reads `parent.id`. */ +function fakeParent(id = 'parent-1'): Agent { + return { id: AgentId(id) } as unknown as Agent +} + +const ALL_CAPS: SubagentCapabilities = { outputSchema: true, depthLimit: true, toolFilter: true } +const NO_CAPS: SubagentCapabilities = { outputSchema: false, depthLimit: false, toolFilter: false } + +/** A scripted provider whose run settles immediately with a fixed result. */ +class StubProvider implements SubagentProvider { + startCount = 0 + constructor( + readonly name: string, + readonly capabilities: SubagentCapabilities = ALL_CAPS, + private readonly result: SubagentResult = { output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' }, + ) {} + + start(request: SubagentStartRequest): SubagentRun { + this.startCount++ + return { + id: AgentId(`child:${this.name}:${request.parent.id}`), + result: Promise.resolve(this.result), + cancel() {}, + async dispose() {}, + } + } +} + +function baseRequest(overrides: Partial = {}): SubagentStartRequest { + return { prompt: [{ type: 'text', text: 'do a thing' }], parent: fakeParent(), ...overrides } +} + +describe('SubagentService', () => { + it('registers a provider and starts a run on it by name', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('alpha') + ctx.subagents.registerProvider(provider) + + expect(ctx.subagents.list()).toEqual(['alpha']) + expect(ctx.subagents.getProvider('alpha')).toBe(provider) + + const run = ctx.subagents.start('alpha', baseRequest()) + expect(provider.startCount).toBe(1) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + }) + + it('lets multiple providers coexist (the defining requirement)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('spawn')) + ctx.subagents.registerProvider(new StubProvider('acp')) + + expect(ctx.subagents.list()).toEqual(['spawn', 'acp']) + expect(ctx.subagents.getProvider('spawn')).toBeDefined() + expect(ctx.subagents.getProvider('acp')).toBeDefined() + }) + + it('throws NO_PROVIDER when starting on an unregistered name', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + try { + ctx.subagents.start('missing', baseRequest()) + expect.fail('expected NO_PROVIDER') + } catch (error: unknown) { + expect(error).toBeInstanceOf(SubagentError) + expect((error as SubagentError).code).toBe('NO_PROVIDER') + } + }) + + it('rejects duplicate provider names with DUPLICATE_PROVIDER', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('dup')) + try { + ctx.subagents.registerProvider(new StubProvider('dup')) + expect.fail('expected DUPLICATE_PROVIDER') + } catch (error: unknown) { + expect(error).toBeInstanceOf(SubagentError) + expect((error as SubagentError).code).toBe('DUPLICATE_PROVIDER') + } + }) + + it('unregisters a provider when its owning fiber is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.subagents.registerProvider(new StubProvider('scoped')) + }, { inject: ['subagents'] })) + expect(ctx.subagents.list()).toEqual(['scoped']) + + await fiber.dispose() + expect(ctx.subagents.list()).toEqual([]) + }) + + it('re-registers a name after its prior registration is disposed (not wedged)', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + + const dispose = ctx.subagents.registerProvider(new StubProvider('reuse')) + expect(ctx.subagents.list()).toEqual(['reuse']) + dispose() + expect(ctx.subagents.list()).toEqual([]) + + const disposeAgain = ctx.subagents.registerProvider(new StubProvider('reuse')) + expect(ctx.subagents.list()).toEqual(['reuse']) + disposeAgain() + expect(ctx.subagents.list()).toEqual([]) + }) + + describe('start-time capability validation (fail loud, before any child)', () => { + it.each([ + { field: 'outputSchema', request: baseRequest({ outputSchema: { x: { type: 'string' } } }) }, + { field: 'maxDepth', request: baseRequest({ maxDepth: 2 }) }, + { field: 'toolFilter', request: baseRequest({ toolFilter: { deny: ['bash'] } }) }, + ])('rejects $field against a provider that lacks the capability — before start() runs', ({ request }) => { + const ctx = new Context() + return ctx.plugin(SubagentService).then(() => { + const provider = new StubProvider('weak', NO_CAPS) + ctx.subagents.registerProvider(provider) + try { + ctx.subagents.start('weak', request) + expect.fail('expected UNSUPPORTED_CAPABILITY') + } catch (error: unknown) { + expect(error).toBeInstanceOf(SubagentError) + expect((error as SubagentError).code).toBe('UNSUPPORTED_CAPABILITY') + } + // The child was never started — the check is pre-spawn. + expect(provider.startCount).toBe(0) + }) + }) + + it('allows a capability request when the provider supports it', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + const provider = new StubProvider('strong', ALL_CAPS) + ctx.subagents.registerProvider(provider) + ctx.subagents.start('strong', baseRequest({ outputSchema: { x: { type: 'string' } }, maxDepth: 1 })) + expect(provider.startCount).toBe(1) + }) + }) + + it('emits subagent/start then subagent/end around a run', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('events')) + + const started = vi.fn() + const ended = vi.fn() + ctx.on('subagent/start', started) + ctx.on('subagent/end', ended) + + const run = ctx.subagents.start('events', baseRequest()) + expect(started).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id })) + + await run.result + // `subagent/end` fires from a `.then` on the result — let the microtask run. + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'events', id: run.id, stopReason: 'completed' })) + }) + + it('emits subagent/end with stopReason "error" when the run result promise rejects', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + // A provider whose run.result REJECTS (an infrastructure fault — the seam + // contract says child-level failures resolve with stopReason 'error', but a + // rejection is still surfaced as an 'error' telemetry event). + ctx.subagents.registerProvider({ + name: 'rejecter', + capabilities: NO_CAPS, + start: () => ({ + id: AgentId('rej-child'), + result: Promise.reject(new Error('infra fault')), + cancel() {}, + dispose: async () => {}, + }), + }) + + const ended = vi.fn() + ctx.on('subagent/end', ended) + const run = ctx.subagents.start('rejecter', baseRequest()) + // Observe (and swallow) the rejection the consumer would see, then let the + // detached `.then` settle the telemetry emit. + await run.result.catch(() => {}) + await Promise.resolve() + expect(ended).toHaveBeenCalledWith(expect.objectContaining({ provider: 'rejecter', id: run.id, stopReason: 'error' })) + }) + + it('contains a throwing subagent/start listener per-listener: a later listener still observes the event and start() returns the run', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('contain')) + // Two listeners; the FIRST throws. Per-listener containment means the second + // must STILL run (a single try/catch around ctx.emit would let the first + // throw halt the dispatch and starve the second — the round-2 regression). + const second = vi.fn() + ctx.on('subagent/start', () => { throw new Error('bad start listener') }) + ctx.on('subagent/start', second) + + const run = ctx.subagents.start('contain', baseRequest()) + expect(run.id).toBeDefined() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain', id: run.id })) + await expect(run.result).resolves.toMatchObject({ stopReason: 'completed' }) + }) + + it('contains a throwing subagent/end listener per-listener: a later listener still observes the settle, no unhandled rejection', async () => { + const ctx = new Context() + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider(new StubProvider('contain-end')) + const second = vi.fn() + ctx.on('subagent/end', () => { throw new Error('bad end listener') }) + ctx.on('subagent/end', second) + + const run = ctx.subagents.start('contain-end', baseRequest()) + await run.result + // Let the detached `.then` + the contained emit run. + await Promise.resolve() + await Promise.resolve() + expect(second).toHaveBeenCalledWith(expect.objectContaining({ provider: 'contain-end', id: run.id, stopReason: 'completed' })) + }) + + it('SubagentError extends the shared HarnessError base', () => { + const err = new SubagentError('boom', 'NO_PROVIDER') + expect(err).toBeInstanceOf(HarnessError) + expect(err.name).toBe('SubagentError') + expect(err.code).toBe('NO_PROVIDER') + }) +}) diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json new file mode 100644 index 0000000000..0781a1129c --- /dev/null +++ b/packages/subagent/subagent/tsconfig.json @@ -0,0 +1,27 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/tools" + } + ] +} diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md new file mode 100644 index 0000000000..1bb48f29ff --- /dev/null +++ b/packages/subagent/tool-subagent/README.md @@ -0,0 +1,19 @@ +# @deepseek-ai/dsh-tool-subagent + +The model-facing `subagent` tool: delegate a self-contained task to a child agent and return its final output. Pure schema + lifecycle shaping over the [`ctx.subagents`](../subagent/README.md) provider registry — an in-process, ACP, or future A2A backend swaps in without changing what the model sees. + +## Provider selection is config, not model-facing + +This plugin binds to **exactly one** provider (`Config.provider`). The model sees only `{ description, prompt }` — there is no provider/type parameter in the schema. To expose more than one transport, load the plugin more than once, each bound to a different provider **and a distinct `toolName`** (the tool registry rejects a duplicate name, so a second load that kept the default `subagent` name would throw). Keeping selection in config (not the schema) is the deliberate split: the *service* holds a multi-provider registry; the *tool* picks one. + +| Config key | Meaning | +|---|---| +| `provider` (required) | The `ctx.subagents` provider name to start runs on (`spawn`, `fork`, `acp`, …). | +| `toolName` | The model-facing tool name to register (default `subagent`). Set a distinct value per load when exposing multiple providers, e.g. `subagent` + `subagent_acp`. | +| `agentOptions` | Default per-child `{ model?, systemPrompt? }` applied to every spawned child. | + +## Lifecycle (synchronous collect) + +`execute` starts a run on the configured provider and **awaits `run.result` inside a `try/finally` that always `dispose()`s the run** — the owned child agent/session is torn down on every path (success, error, abort), never leaked. The tool's abort signal (`exec.signal`) is bridged to `run.cancel()`. A non-`completed` stop reason (aborted/error/max-tokens/refusal) maps to an `isError` tool result rather than returning partial output as success. + +Background / poll collection is deferred (see the [RFC](../../../docs/rfc/implemented/feature/2026-06-21-subagent-capability-seam.md)); this cut blocks the parent turn until the child finishes. diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json new file mode 100644 index 0000000000..254c9e2928 --- /dev/null +++ b/packages/subagent/tool-subagent/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-tool-subagent", + "description": "Model-facing subagent delegation tool over the ctx.subagents seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./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-subagent": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@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-tools": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts new file mode 100644 index 0000000000..05490127ea --- /dev/null +++ b/packages/subagent/tool-subagent/src/index.ts @@ -0,0 +1,161 @@ +/** + * 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 }`. + * + * 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. + * + * @module @deepseek-ai/dsh-tool-subagent + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { AgentOptions } from '@deepseek-ai/dsh-agent' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' + +export const name = 'tool-subagent' +export const inject = ['tools', 'subagents'] + +/** Config: which registered provider this tool delegates to, plus child defaults. */ +export interface Config { + /** The `ctx.subagents` provider name to start runs on (e.g. `spawn`, `acp`). */ + provider: string + /** + * The model-facing tool name to register (default `subagent`). To expose more + * than one transport, load this plugin once per provider — each load MUST set + * a distinct `toolName` (the tool registry rejects a duplicate name), e.g. + * `{ provider: 'spawn', toolName: 'subagent' }` and + * `{ provider: 'acp', toolName: 'subagent_acp' }`. + */ + toolName?: string + /** + * Default per-child agent options (model, system prompt) applied to every + * spawned child. Omitted fields fall back to the child loop's own defaults. + */ + agentOptions?: AgentOptions +} + +export const Config: z = z.object({ + provider: z.string().required(), + toolName: z.string().default('subagent'), + agentOptions: z.object({ + model: z.string(), + systemPrompt: z.string(), + }), +}) + +/** + * Flatten a child's final output blocks to text for the tool result. The child + * may return non-text blocks; this cut surfaces the text content (the common + * case) and drops the rest, which is acceptable for a synchronous summary — + * the structured path (`outputSchema`) is the channel for non-text results. + */ +function outputText(blocks: ContentBlock[]): string { + return blocks + .filter((b): b is Extract => b.type === 'text') + .map(b => b.text) + .join('') +} + +/** A non-`completed` stop reason means the child did not finish cleanly. */ +function stopReasonError(result: SubagentResult): string | undefined { + switch (result.stopReason) { + case 'completed': + return undefined + case 'aborted': + return 'subagent run was cancelled' + case 'error': + return 'subagent run failed' + case 'max-tokens': + return 'subagent run hit its token limit before finishing' + case 'refusal': + return 'subagent declined the task' + // Merge-extensible union: a backend may add stop reasons. Treat an unknown + // terminal reason as a failure rather than reporting partial output as success. + default: + return `subagent run ended abnormally (${String(result.stopReason)})` + } +} + +export function apply(ctx: Context, config: Config): void { + ctx.tools.register(defineTool({ + name: config.toolName ?? '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: { + description: { + type: 'string', + required: true, + description: 'A short (3-5 word) description of the delegated task, for display.', + }, + prompt: { + type: 'string', + required: true, + description: 'The complete, self-contained task for the subagent. It does not share this ' + + 'conversation\'s context, so include everything it needs.', + }, + }, + async execute(args, exec): Promise { + const parent = exec.agent + if (!parent) { + // The loop sets `exec.agent` for every model-driven call; its absence + // means a non-agent caller invoked the tool directly, which has no + // parent to attribute the child to. Fail loud rather than guess. + throw new Error('subagent tool requires a calling agent (exec.agent was undefined)') + } + + const request: SubagentStartRequest = { + prompt: [{ type: 'text', text: args.prompt }], + parent, + ...exec.signal ? { signal: exec.signal } : {}, + ...config.agentOptions ? { agentOptions: config.agentOptions } : {}, + } + + const run: SubagentRun = ctx.subagents.start(config.provider, request) + + // Bridge the tool's abort signal to the run: if the parent step is + // aborted while the child is in flight, cancel the child too. + const onAbort = (): void => { run.cancel('parent step aborted') } + exec.signal?.addEventListener('abort', onAbort, { once: true }) + // `addEventListener` does NOT fire for a signal already aborted before this + // line, so a step cancelled before the tool ran would never reach the + // child. Cancel explicitly in that case — the bridge must honor an + // already-aborted signal, not lean on each provider re-checking it. + if (exec.signal?.aborted) run.cancel('parent step aborted') + + try { + const result = await run.result + const error = stopReasonError(result) + if (error !== undefined) { + // Map a non-clean finish to an isError result (the registry turns a + // throw into an isError). Report the reason, not partial output. + throw new Error(error) + } + return [{ type: 'text', text: outputText(result.output) }] + } finally { + exec.signal?.removeEventListener('abort', onAbort) + // Always reach child quiescence — never leak a live idle child/session. + await run.dispose() + } + }, + })) +} diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts new file mode 100644 index 0000000000..dda4e7c3d0 --- /dev/null +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -0,0 +1,357 @@ +import { describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +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 SubagentService from '@deepseek-ai/dsh-subagent' +import * as mock from '@deepseek-ai/dsh-subagent-mock' +import * as tool from '../src/index.ts' + +/** + * 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. + */ + +/** 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 +} + +async function setup(toolConfig: tool.Config, mockConfig: Partial = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + await ctx.plugin(mock, { name: 'mock', ...mockConfig }) + await ctx.plugin(tool, toolConfig) + return ctx +} + +let callCounter = 0 +function callSubagent(ctx: Context, args: unknown, over: { agent?: Agent | undefined; signal?: AbortSignal } = {}) { + // Distinguish "no override" (use a default agent) from an explicit + // `{ agent: undefined }` (test the no-agent path). Under + // exactOptionalPropertyTypes the key is omitted rather than set to undefined. + const agent = 'agent' in over ? over.agent : fakeAgent() + return ctx.tools.execute({ + callId: CallId(`call-${++callCounter}`), + name: 'subagent', + arguments: args, + ...agent ? { agent } : {}, + ...over.signal ? { signal: over.signal } : {}, + }) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('dsh-tool-subagent', () => { + it('registers a `subagent` tool that delegates to the configured provider and returns its output', async () => { + const ctx = await setup({ provider: 'mock' }, { reply: 'child says hi' }) + const result = await callSubagent(ctx, { description: 'do a thing', prompt: 'go research X' }) + expect(result.isError).toBe(false) + expect(text(result)).toBe('child says hi') + }) + + it('exposes only description + prompt to the model (no provider/type parameter)', async () => { + const ctx = await setup({ provider: 'mock' }) + const schema = ctx.tools.schemas().find(s => s.name === 'subagent') + expect(schema).toBeDefined() + const props = (schema!.parameters as { properties?: Record }).properties ?? {} + expect(Object.keys(props).sort()).toEqual(['description', 'prompt']) + }) + + it.each([ + { stopReason: 'aborted' as const, fragment: 'cancelled' }, + { stopReason: 'error' as const, fragment: 'failed' }, + { stopReason: 'max-tokens' as const, fragment: 'token limit' }, + { stopReason: 'refusal' as const, fragment: 'declined' }, + ])('maps stop reason $stopReason to an isError result (not partial success)', async ({ stopReason, fragment }) => { + const ctx = await setup({ provider: 'mock' }, { stopReason }) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain(fragment) + }) + + it('registers under a configurable toolName so multiple providers can coexist', async () => { + // The defining multi-provider use case: two loads, two distinct tool names, + // each bound to a different provider — the tool registry rejects duplicate + // names, so a configurable name is what makes this work. + const ctx = new Context() + 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 ctx.plugin(tool, { provider: 'spawn', toolName: 'subagent' }) + await ctx.plugin(tool, { provider: 'acp', toolName: 'subagent_acp' }) + + const names = ctx.tools.schemas().map(s => s.name).filter(n => n.startsWith('subagent')).sort() + expect(names).toEqual(['subagent', 'subagent_acp']) + + const viaSpawn = await ctx.tools.execute({ callId: CallId('c-spawn'), name: 'subagent', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + const viaAcp = await ctx.tools.execute({ callId: CallId('c-acp'), name: 'subagent_acp', arguments: { description: 'd', prompt: 'p' }, agent: fakeAgent() }) + expect(text(viaSpawn)).toBe('from spawn') + expect(text(viaAcp)).toBe('from acp') + }) + + it('treats an unknown (plugin-added) stop reason as an isError result', async () => { + // SubagentStopReason is merge-extensible; the tool's stopReasonError default + // arm must treat an unrecognized terminal reason as a failure, not success. + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'weird', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('weird-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'partial' }], stopReason: 'frobnicated' as never }), + cancel() {}, + dispose: async () => {}, + }), + }) + await ctx.plugin(tool, { provider: 'weird' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('abnormally') + }) + + it('forwards configured agentOptions into the start request', async () => { + // Cover the `config.agentOptions ? … : {}` spread: a provider that captures + // the request lets us assert the agentOptions reached it. + let seen: { agentOptions?: { model?: string } } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'capture', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('capture-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'capture', agentOptions: { model: 'child-model', systemPrompt: 'be terse' } }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.agentOptions).toEqual({ model: 'child-model', systemPrompt: 'be terse' }) + }) + + it('defaults toolName and omits agentOptions when apply() is called directly (schema bypass)', async () => { + // `ctx.plugin` validates+defaults config first (toolName→'subagent', the + // agentOptions object→{}), so the runtime `?? 'subagent'` fallback and the + // no-agentOptions branch are only reachable via a direct apply() that + // bypasses schemastery — the same pattern acp-agent uses for its defaults. + let seen: { agentOptions?: unknown } | undefined + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'bare', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: (request) => { + seen = request + return { + id: AgentId('bare-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => {}, + } + }, + }) + // Direct apply with only `provider` — no toolName, no agentOptions. + tool.apply(ctx, { provider: 'bare' }) + await new Promise(r => setTimeout(r, 10)) + + expect(ctx.tools.schemas().some(s => s.name === 'subagent')).toBe(true) + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(seen?.agentOptions).toBeUndefined() + }) + + it('fails loud when invoked without a calling agent', async () => { + const ctx = await setup({ provider: 'mock' }) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { agent: undefined }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('requires a calling agent') + }) + + it('surfaces an UNSUPPORTED_CAPABILITY rejection as an isError result is NOT applicable here ' + + '(the tool requests no capabilities) — a missing provider IS surfaced', async () => { + // Bind the tool to a provider name that is not registered: the service throws + // NO_PROVIDER, the registry turns it into an isError result. + const ctx = await setup({ provider: 'does-not-exist' }) + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no subagent provider') + }) + + it('disposes the run on the success path (no leaked child)', async () => { + // Spy on the provider's run.dispose via a wrapping provider registered + // directly on the service, then point the tool at it. + const disposed = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('spy-child'), + result: Promise.resolve({ output: [{ type: 'text', text: 'ok' }], stopReason: 'completed' as const }), + cancel() {}, + dispose: async () => void disposed(), + }), + }) + await ctx.plugin(tool, { provider: 'spy' }) + + await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(disposed).toHaveBeenCalledTimes(1) + }) + + it('disposes the run on the error path too', async () => { + const disposed = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => ({ + id: AgentId('spy-child'), + result: Promise.resolve({ output: [], stopReason: 'error' as const }), + cancel() {}, + dispose: async () => void disposed(), + }), + }) + await ctx.plugin(tool, { provider: 'spy' }) + + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) + expect(result.isError).toBe(true) + expect(disposed).toHaveBeenCalledTimes(1) + }) + + it('bridges the tool abort signal to run.cancel()', async () => { + const cancelled = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => { + let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void + const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) + return { + id: AgentId('spy-child'), + result, + cancel: () => { + cancelled() + resolveResult({ output: [], stopReason: 'aborted' }) + }, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'spy' }) + + const controller = new AbortController() + const pending = callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) + controller.abort() + const result = await pending + expect(cancelled).toHaveBeenCalledTimes(1) + expect(result.isError).toBe(true) + }) + + it('cancels the run when the tool signal is ALREADY aborted before execute (no missed abort)', async () => { + // `addEventListener('abort')` does not fire for a signal already aborted + // before the listener is added, so a step cancelled before the tool ran + // would never reach the child unless the bridge re-checks `signal.aborted`. + // A provider that leans only on the abort EVENT (this spy never inspects + // request.signal) proves the bridge itself must cancel. + const cancelled = vi.fn() + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(SubagentService) + ctx.subagents.registerProvider({ + name: 'spy', + capabilities: { outputSchema: false, depthLimit: false, toolFilter: false }, + start: () => { + let resolveResult: (r: { output: never[]; stopReason: 'aborted' }) => void + const result = new Promise<{ output: never[]; stopReason: 'aborted' }>((res) => { resolveResult = res }) + return { + id: AgentId('spy-child'), + result, + cancel: () => { + cancelled() + resolveResult({ output: [], stopReason: 'aborted' }) + }, + dispose: async () => {}, + } + }, + }) + await ctx.plugin(tool, { provider: 'spy' }) + + const controller = new AbortController() + controller.abort() // already aborted BEFORE the tool runs + const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }, { signal: controller.signal }) + expect(cancelled).toHaveBeenCalledTimes(1) + expect(result.isError).toBe(true) + }) + + it('tools depend on the service: no `subagent` tool without ctx.subagents', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + // No SubagentService mounted. The tool injects ['tools','subagents'] so its + // apply never runs; the tool is absent rather than half-registered. + let booted = true + try { + await ctx.plugin(tool, { provider: 'mock' }) + await new Promise(r => setTimeout(r, 20)) + } catch { + booted = false + } + // Either it never booted, or it booted but registered no tool. + const present = ctx.get('tools')?.schemas().some(s => s.name === 'subagent') ?? false + expect(booted && present).toBe(false) + }) + + it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => { + // Postmortem 0001 guard: this plugin HAS `inject = ['tools','subagents']`, so + // a stray `export default apply` would collapse the module via + // `unwrapExports` (`exports.default ?? exports`), DROP `inject`, and crash at + // load with "cannot get property … without inject". Guard the shape directly. + expect('default' in tool).toBe(false) + expect(tool.name).toBe('tool-subagent') + expect(tool.inject).toEqual(['tools', 'subagents']) + + const loader = Object.create(Loader.prototype) as Loader + const unwrapped = loader.unwrapExports(tool) as Record + expect(unwrapped).toBe(tool) + expect(unwrapped.name).toBe('tool-subagent') + expect(unwrapped.inject).toEqual(['tools', 'subagents']) + expect(typeof unwrapped.apply).toBe('function') + expect(unwrapped.Config).toBeDefined() + }) +}) diff --git a/packages/subagent/tool-subagent/tsconfig.json b/packages/subagent/tool-subagent/tsconfig.json new file mode 100644 index 0000000000..0a10bce7c5 --- /dev/null +++ b/packages/subagent/tool-subagent/tsconfig.json @@ -0,0 +1,33 @@ +{ + "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": "../../core/tools" + }, + { + "path": "../subagent" + } + ] +} diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 7508d3e7d8..97d6160b03 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/support/invariants/tsconfig.json b/packages/support/invariants/tsconfig.json index 76021c9ae5..8dca14c786 100644 --- a/packages/support/invariants/tsconfig.json +++ b/packages/support/invariants/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index 91230cc113..06803eaf0e 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -10,26 +10,35 @@ The fixture IS the persisted session log (`/session.jsonl`). Its `assi Two failure modes are not reconstructable from `assistant/chunk` alone — a pure throw before any chunk (e.g. an HTTP 401, where the log holds only a `turn/end {error}` and no chunks) and a cancel/hang (timing, not chunk content). A scenario that needs those supplies an optional sidecar (`/replay.override.json`: a `ReplayEntry[]`) that REPLACES the derived script. +## Nested agents: per-session keying + +A scenario where a parent agent delegates to in-process subagents records more than one log: the parent (`session.jsonl`) plus one per child (`session.1.jsonl`, …). Each agent runs as its own `Session` on the same context, so replay must serve each one its own script. + +Replay keys every call by its calling session id (`GenerateOptions.sessionId`, stamped by the agent loop). Live session ids are freshly random each run and never equal the recorded ones, so a live session binds to a recorded script by **first-call order**: scripts are ordered by header `createdAt` (parent first — it streams before it can delegate), and the first live session to make any call claims the first script, the next new session the next, and so on. Each session then advances its own cursor. A call with no `sessionId` is one anonymous session bound to the primary script, so single-session scenarios behave exactly as before. More distinct live sessions than recorded scripts fails loud. + ## Config | Key | Type | Default | Notes | |---|---|---|---| -| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the per-scenario `session.jsonl` fixture. Required (config or env). | -| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the derived script. | +| `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | +| `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional path to a `ReplayEntry[]` sidecar that replaces the PRIMARY session's derived script. | +| `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | ```yaml - id: llm-replay name: '@deepseek-ai/dsh-llm-replay' - # file/overrideFile default to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE, - # set by the snapshot harness per scenario. + # file/overrideFile/childFiles default to $DSH_SNAPSHOT_FILE / + # $DSH_SNAPSHOT_OVERRIDE / $DSH_SNAPSHOT_CHILD_FILES, set by the snapshot + # harness per scenario. ``` ## Exports - `installLlmReplay(ctx, config)` — install the `llm/stream` listener; returns the disposer (HMR safety). Use this in tests to drive replay without the Loader or env vars. -- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for a scenario (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing). -- `deriveReplayScript(events)` / `parseSessionLog(text)` — the pure helpers that turn a recorded session log into a script. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. -- Types `ReplayEntry` / `ReplayConfig` / `Config`. +- `loadSessionScripts(config)` — resolve the ordered `SessionScript[]` (primary + children) for a scenario, ready to bind to live sessions in first-call order. +- `loadReplayScript(config)` — resolve the `ReplayEntry[]` for the PRIMARY session only (sidecar override if present, else derived from the JSONL; fail-loud if the fixture is missing). +- `deriveReplayScript(events)` / `parseSessionLog(text)` / `parseSessionHeader(text)` — the pure helpers that turn a recorded session log into a script and read its header `id`/`createdAt`. A derived group must end in a `finish` chunk; a group without one is the fingerprint of a thrown `stream()` and must instead be expressed via an override sidecar. +- Types `ReplayEntry` / `SessionScript` / `ReplayConfig` / `Config`. ## Plugin export shape diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 50d469a352..ce57ea18ef 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 67fcf09697..78f46e705e 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -14,6 +14,15 @@ * therefore "run the real agent once and harvest the `.jsonl`", done by the * snapshot harness — this plugin does not record. * + * A NESTED-agent scenario records more than one log: the parent plus one per + * in-process subagent (each subagent runs as its own {@link Session} on the same + * context). Replay loads them all ({@link loadSessionScripts}), derives a script + * per recorded session, and keys each live call by its calling session id + * (`GenerateOptions.sessionId`, stamped by the loop). Live session ids are fresh + * random values, so a live session binds to a recorded script by FIRST-CALL + * order (parent first — it streams before it delegates); see + * {@link installLlmReplay}. + * * Two failure modes are NOT reconstructable from `assistant/chunk` alone — a * pure throw before any chunk (e.g. an HTTP 401: the log holds only a * `turn/end {error}`, no chunks) and a cancel/hang (timing, not chunk content). @@ -36,6 +45,7 @@ */ import { existsSync, readFileSync } from 'node:fs' +import { delimiter as pathDelimiter } from 'node:path' import type { Context } from 'cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' @@ -65,14 +75,49 @@ export type ReplayEntry = /** Resolved plugin configuration. */ export interface ReplayConfig { - /** Path to the per-scenario `session.jsonl` fixture (the recorded log). */ + /** + * Path to the PRIMARY (parent) `session.jsonl` fixture. For a single-session + * scenario this is the only log; for a nested-agent scenario it is the parent, + * and the child logs ride in {@link childFiles}. + */ file: string /** - * Optional path to a `ReplayEntry[]` sidecar that REPLACES the derived - * script. Used by the two scenarios not expressible as `assistant/chunk` - * (pure throw-before-chunk, cancel/hang). Absent for normal scenarios. + * Optional `ReplayEntry[]` sidecar that REPLACES the derived script for the + * PRIMARY session. Used by the two single-session scenarios not expressible as + * `assistant/chunk` (pure throw-before-chunk, cancel/hang). Absent for normal + * and nested scenarios. */ overrideFile?: string + /** + * Additional recorded child-session logs (a nested-agent scenario's subagent + * sessions). Each is derived independently; the full set is ordered by + * `createdAt` so the parent (earliest) binds to the first live session. Empty + * for a single-session scenario. + */ + childFiles?: string[] +} + +/** + * One recorded session's replay script: the per-call entries plus the header + * facts needed to ORDER and key it. Live session ids are freshly random at + * replay time and never equal the recorded `id`, so the recorded id is only a + * diagnostic; `createdAt` is the load-bearing field — scripts are ordered by it + * (a parent is created before its children) and each newly-seen live session is + * bound to the next script in that order (= first-call order in the synchronous + * nested cut, where the parent streams before it delegates). + */ +export interface SessionScript { + /** The recorded session id (diagnostics only — the live id differs). */ + recordedId: string + /** Session creation time; the deterministic ordering key (parent < child). */ + createdAt: number + /** The per-`stream()`-call replay entries, in recorded call order. */ + entries: ReplayEntry[] + /** + * Whether this is the PRIMARY (parent) session. Breaks a `createdAt` tie in + * favor of the parent, which always issues the first model call. + */ + primary: boolean } /** @@ -93,6 +138,26 @@ export function parseSessionLog(text: string): SessionEvent[] { return events } +/** + * Read the identifying facts off a session log's header line (line 0): the + * recorded session `id` (diagnostics), `createdAt` (the deterministic ordering + * key that binds a recorded script to a live session — see + * {@link SessionScript}), and `seedLength` (the seed boundary — how many leading + * events were INHERITED via a fork seed rather than produced by this session's + * own model calls; absent ⇒ 0). A header missing a field falls back to a stable + * default (`''` / `0` / `0`) rather than throwing: a no-model fixture is + * header-only and still orders fine as the single (primary) script. + */ +export function parseSessionHeader(text: string): { id: string; createdAt: number; seedLength: number } { + const firstLine = text.split('\n').find(line => line.trim().length > 0) ?? '{}' + const parsed = JSON.parse(firstLine) as { id?: unknown; createdAt?: unknown; seedLength?: unknown } + return { + id: typeof parsed.id === 'string' ? parsed.id : '', + createdAt: typeof parsed.createdAt === 'number' ? parsed.createdAt : 0, + seedLength: typeof parsed.seedLength === 'number' ? parsed.seedLength : 0, + } +} + /** * Reconstruct the per-`stream()` replay script from a recorded session log. * @@ -144,11 +209,11 @@ export function deriveReplayScript(events: SessionEvent[]): ReplayEntry[] { } /** - * Build the replay script for a scenario: the sidecar override if present, - * otherwise the script derived from the recorded session JSONL. Fail-loud if - * the JSONL fixture is missing (the scenario was never recorded) — never - * silently returns an empty script, so a coverage hole can't masquerade as a - * passing replay. + * Build the replay script for the PRIMARY session: the sidecar override if + * present, otherwise the script derived from the recorded session JSONL. + * Fail-loud if the JSONL fixture is missing (the scenario was never recorded) — + * never silently returns an empty script, so a coverage hole can't masquerade + * as a passing replay. */ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { if (config.overrideFile !== undefined && existsSync(config.overrideFile)) { @@ -164,6 +229,69 @@ export function loadReplayScript(config: ReplayConfig): ReplayEntry[] { return deriveReplayScript(parseSessionLog(readFileSync(config.file, 'utf8'))) } +/** + * Load every recorded session's script for a scenario, ordered by `createdAt` + * (earliest first), ready to bind to live sessions in first-call order. + * + * The PRIMARY session (`config.file`, with its optional `overrideFile`) is the + * parent; each `config.childFiles` entry is a recorded subagent session. A + * single-session scenario has no `childFiles`, so this returns one script and + * behaves exactly like the old single-cursor replay. The primary always sorts + * first when ties occur (a sub-millisecond parent/child `createdAt` collision): + * the parent issues the FIRST model call (it must stream before it can delegate + * in the synchronous nested cut), so binding it to the first live session is + * correct regardless of a timestamp tie. + */ +export function loadSessionScripts(config: ReplayConfig): SessionScript[] { + const primaryEntries = loadReplayScript(config) + // The override path replaces the derived script but carries no header; read + // the header off the JSONL when it exists, else use a stable default so an + // override-only fixture (header-less) still orders first as the primary. + const primaryHeader = existsSync(config.file) + ? parseSessionHeader(readFileSync(config.file, 'utf8')) + : { id: '', createdAt: 0 } + const primary: SessionScript = { + recordedId: primaryHeader.id, createdAt: primaryHeader.createdAt, entries: primaryEntries, primary: true, + } + const children: SessionScript[] = [] + for (const childFile of config.childFiles ?? []) { + if (!existsSync(childFile)) { + throw new Error(`llm-replay: child fixture not found: ${childFile} — re-record the scenario`) + } + const text = readFileSync(childFile, 'utf8') + const header = parseSessionHeader(text) + // Derive the child's script from its OWN events only — events AT OR AFTER + // the seed boundary. A FORK child's log begins with the seeded parent prefix + // (the parent's events, including its `assistant/chunk`s); replaying those as + // the child's model calls would feed the child the PARENT's recorded + // responses. `seedLength` is 0 for a fresh (spawn) child, so this is a no-op + // there. + const ownEvents = parseSessionLog(text).slice(header.seedLength) + children.push({ + recordedId: header.id, + createdAt: header.createdAt, + entries: deriveReplayScript(ownEvents), + primary: false, + }) + } + // The primary (parent) always binds first — it issues the first model call, + // because it must run a turn before it can delegate. Children follow in + // createdAt order. In the current synchronous cut sibling children are created + // STRICTLY SEQUENTIALLY — the subagent tool awaits one child's result and + // disposes it before the parent's next tool call can start the next — so their + // createdAt values are strictly ordered and match first-call order exactly. + // The recordedId tiebreak only makes a degenerate same-millisecond collision + // (unreachable in this cut) deterministic; it does NOT recover first-call + // order, so it is arbitrary if such a tie ever occurs. + // XXX(concurrent-subagents): a future cut that runs siblings concurrently or + // backgrounded could create two children in the same millisecond, where this + // createdAt+id order may diverge from first-call order. That cut must thread a + // real first-call ordinal (the order live sessions first stream) instead of + // leaning on createdAt — see the per-session-replay RFC. + children.sort((a, b) => a.createdAt - b.createdAt || a.recordedId.localeCompare(b.recordedId)) + return [primary, ...children] +} + /** Yield a recorded stream back, honoring abort like a real adapter. */ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined): AsyncIterable { switch (entry.kind) { @@ -206,22 +334,70 @@ async function* replayEntry(entry: ReplayEntry, signal: AbortSignal | undefined) * disposer (so a fiber dispose removes it — HMR safety). Exported separately * from {@link apply} so unit tests can drive it without the Loader or env vars. * - * Replay is POSITIONAL: the Nth `stream()` call serves the Nth script entry. - * This is deterministic only with at most one model stream in flight at a time; - * the snapshot harness runs one ACP session per scenario to guarantee that. The - * cursor is advanced synchronously at listener-invocation time (not lazily - * inside the generator) so call ORDER, not iteration order, fixes the mapping. + * Replay is PER-SESSION POSITIONAL: each recorded session has its own script + * (parent + any subagent children, loaded by {@link loadSessionScripts} ordered + * by `createdAt`), and the Nth `stream()` call FROM A GIVEN SESSION serves that + * session's Nth entry. The calling session is read off `options.sessionId` (the + * agent loop stamps it from `agent.session.id`). + * + * Live session ids are freshly random and never equal the recorded ones, so a + * live session binds to a recorded script by FIRST-CALL ORDER: the first live + * session to make any call takes the first ordered script (the parent — earliest + * `createdAt`, and the first to stream because it must run before it delegates), + * the next new live session takes the next script, and so on. This keys by WHO + * calls rather than global call order, so it stays correct even if subagents + * ever run concurrently/backgrounded (a global cursor would interleave them). + * + * A call with no `sessionId` (a direct unit-test `ctx.llm.stream` that omits it) + * is treated as one anonymous session — it binds to the first script, so the + * single-session path behaves exactly as the old global cursor did. + * + * Each per-session cursor advances synchronously at listener-invocation time + * (not lazily inside the generator) so call ORDER within a session, not + * iteration order, fixes the mapping. */ export function installLlmReplay(ctx: Context, config: ReplayConfig): () => void { - const entries = loadReplayScript(config) - let cursor = 0 + const scripts = loadSessionScripts(config) + // Live-session → its bound script + cursor. A new live session id claims the + // next not-yet-bound script (scripts are in bind order); `nextScript` is the + // index of the next unclaimed one. + const bound = new Map() + let nextScript = 0 + const ANON = '\0anon\0' // the key for a call that carries no sessionId return ctx.on('llm/stream', (options: GenerateOptions, _next) => { - const index = cursor++ - const entry: ReplayEntry | undefined = entries[index] + const key = options.sessionId ?? ANON + let state = bound.get(key) + let unrecorded = false + if (state === undefined) { + const script = scripts[nextScript] + if (script === undefined) { + // More distinct live sessions made calls than the scenario recorded — + // an unrecorded subagent appeared. Defer the throw into the returned + // generator (the listener must return an AsyncIterable, not throw). + unrecorded = true + state = { entries: [], cursor: 0 } + } else { + nextScript++ + state = { entries: script.entries, cursor: 0 } + bound.set(key, state) + } + } + const boundState = state + const seenSessions = nextScript + const totalScripts = scripts.length + const index = boundState.cursor++ + const entry: ReplayEntry | undefined = boundState.entries[index] return (async function* () { + if (unrecorded) { + throw new Error( + `llm-replay: a model call arrived from an unrecorded session (#${seenSessions + 1}); ` + + `the scenario recorded only ${totalScripts} session(s) — re-record it`, + ) + } if (entry === undefined) { throw new Error( - `llm-replay: script exhausted — requested model call #${index + 1} but the fixture has only ${entries.length}; re-record the scenario`, + `llm-replay: script exhausted — session requested model call #${index + 1} ` + + `but its script has only ${boundState.entries.length}; re-record the scenario`, ) } yield* replayEntry(entry, options.signal) @@ -237,6 +413,12 @@ export interface Config { file?: string /** Override the sidecar path; defaults to `$DSH_SNAPSHOT_OVERRIDE`. */ overrideFile?: string + /** + * Override the child-log paths; defaults to `$DSH_SNAPSHOT_CHILD_FILES` (a + * path-separator-delimited list). Each is a recorded subagent session log for + * a nested-agent scenario; absent/empty for a single-session scenario. + */ + childFiles?: string[] } export function apply(ctx: Context, config: Config = {}): void { @@ -245,5 +427,12 @@ export function apply(ctx: Context, config: Config = {}): void { throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)') } const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE - installLlmReplay(ctx, overrideFile === undefined || overrideFile.length === 0 ? { file } : { file, overrideFile }) + const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES + const childFiles = config.childFiles + ?? (childEnv !== undefined && childEnv.length > 0 ? childEnv.split(pathDelimiter) : []) + installLlmReplay(ctx, { + file, + ...overrideFile !== undefined && overrideFile.length > 0 ? { overrideFile } : {}, + ...childFiles.length > 0 ? { childFiles } : {}, + }) } diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 925881273a..2dd8357cc7 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -7,12 +7,15 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session' import LlmService, { GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { type ReplayEntry, + type SessionScript, apply, deriveReplayScript, inject, installLlmReplay, loadReplayScript, + loadSessionScripts, name, + parseSessionHeader, parseSessionLog, } from '../src/index.ts' @@ -32,9 +35,15 @@ const TEXT_CHUNKS: StreamChunk[] = [ ] /** Build a minimal session-JSONL string: a header line + the given events. */ -function sessionJsonl(events: SessionEvent[]): string { - const header = JSON.stringify({ type: 'session', version: 0, id: 's1', createdAt: 0 }) - return [header, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' +function sessionJsonl(events: SessionEvent[], header?: { id?: string; createdAt?: number; seedLength?: number }): string { + const headerLine = JSON.stringify({ + type: 'session', + version: 0, + id: header?.id ?? 's1', + createdAt: header?.createdAt ?? 0, + ...header?.seedLength !== undefined ? { seedLength: header.seedLength } : {}, + }) + return [headerLine, ...events.map(e => JSON.stringify(e))].join('\n') + '\n' } /** A SessionEvent of type assistant/chunk for (turn, step). */ @@ -361,13 +370,219 @@ describe('installLlmReplay (through the real waterfall)', () => { }) }) +describe('parseSessionHeader', () => { + it('reads id, createdAt, and seedLength off the header line', () => { + expect(parseSessionHeader(sessionJsonl([], { id: 'abc', createdAt: 42 }))) + .toEqual({ id: 'abc', createdAt: 42, seedLength: 0 }) + }) + + it('reads a non-zero seedLength (a fork child header)', () => { + expect(parseSessionHeader('{"type":"session","version":0,"id":"child","createdAt":7,"seedLength":4}\n')) + .toEqual({ id: 'child', createdAt: 7, seedLength: 4 }) + }) + + it('falls back to id="" / createdAt=0 / seedLength=0 when the header lacks them', () => { + expect(parseSessionHeader('{"type":"session","version":0}\n')).toEqual({ id: '', createdAt: 0, seedLength: 0 }) + }) + + it('falls back on an empty buffer (no header line)', () => { + expect(parseSessionHeader('')).toEqual({ id: '', createdAt: 0, seedLength: 0 }) + }) +}) + +describe('loadSessionScripts', () => { + /** Write a session log file and return its path. */ + function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { + let seq = 1 + const events: SessionEvent[] = [] + calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) + const path = join(dir, filename) + writeFileSync(path, sessionJsonl(events, header), 'utf8') + return path + } + + it('returns one primary script for a single-session scenario', () => { + const f = writeSession('session.jsonl', { id: 'p', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts: SessionScript[] = loadSessionScripts({ file: f }) + expect(scripts).toHaveLength(1) + expect(scripts[0]).toMatchObject({ recordedId: 'p', createdAt: 100, primary: true }) + expect(scripts[0]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) + }) + + it('orders parent + children by createdAt with the primary first on a tie', () => { + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + // One child created LATER, one child sharing the parent's createdAt (tie). + const later = writeSession('session.1.jsonl', { id: 'late', createdAt: 200 }, [TEXT_CHUNKS]) + const tie = writeSession('session.2.jsonl', { id: 'tie', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file: f, childFiles: [later, tie] }) + // parent (100, primary) → tie (100, non-primary) → late (200). + expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'tie', 'late']) + expect(scripts[0]?.primary).toBe(true) + }) + + it('throws when a declared child fixture is missing', () => { + const f = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS]) + expect(() => loadSessionScripts({ file: f, childFiles: [join(dir, 'absent.jsonl')] })) + .toThrow(/child fixture not found/) + }) + + it('derives a FORK child script from its OWN events only (skips the seeded parent prefix)', () => { + // A fork child's log begins with the seeded parent prefix — the parent's + // events, INCLUDING its assistant/chunk events. Deriving the child script + // from the whole log would replay the PARENT's recorded responses as the + // child's model calls. With seedLength recorded, the child script must + // contain only the child's OWN chunks (those after the boundary). + const parentChunk: StreamChunk = { type: 'text-delta', index: 0, text: 'PARENT-RESPONSE' } + const childChunks: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'CHILD-RESPONSE' }, { type: 'finish', reason: { kind: 'stop' } }] + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + // The child fixture: 2 seeded parent events (a chunk + its finish) then the + // child's own turn. seedLength = 2 marks where the inherited prefix ends. + const childEvents: SessionEvent[] = [ + chunkEvent(0, 1, 1, parentChunk), + chunkEvent(1, 1, 1, { type: 'finish', reason: { kind: 'stop' } }), + chunkEvent(2, 2, 1, childChunks[0]!), + chunkEvent(3, 2, 1, childChunks[1]!), + ] + const childPath = join(dir, 'session.1.jsonl') + writeFileSync(childPath, sessionJsonl(childEvents, { id: 'child', createdAt: 200, seedLength: 2 }), 'utf8') + + const scripts = loadSessionScripts({ file: f, childFiles: [childPath] }) + // The child script is ONLY the child's own model call — the parent's seeded + // chunk is gone. + expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: childChunks }]) + }) + + it('uses the override for the primary and still derives children', () => { + writeFileSync(file, sessionJsonl([], { id: 'p', createdAt: 1 }), 'utf8') + const overrideFile = join(dir, 'replay.override.json') + const override: ReplayEntry[] = [{ kind: 'hang' }] + writeFileSync(overrideFile, JSON.stringify(override), 'utf8') + const child = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file, overrideFile, childFiles: [child] }) + expect(scripts[0]?.entries).toEqual(override) + expect(scripts[1]?.entries).toEqual([{ kind: 'chunks', chunks: TEXT_CHUNKS }]) + }) + + it('defaults the primary header to id="" / createdAt=0 when only an override (no JSONL) exists', () => { + // An override-only fixture: config.file does NOT exist, the override drives + // the primary script, so the header default branch applies. + const overrideFile = join(dir, 'replay.override.json') + writeFileSync(overrideFile, JSON.stringify([{ kind: 'hang' }]), 'utf8') + const scripts = loadSessionScripts({ file: join(dir, 'absent.jsonl'), overrideFile }) + expect(scripts).toHaveLength(1) + expect(scripts[0]).toMatchObject({ recordedId: '', createdAt: 0, primary: true }) + }) + + it('orders two same-createdAt children deterministically after the primary', () => { + // Two children sharing a createdAt (both non-primary): exercises the sort + // tie-break\'s "both same primary-ness" arm and a non-primary-vs-primary arm. + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + const c1 = writeSession('session.1.jsonl', { id: 'c1', createdAt: 100 }, [TEXT_CHUNKS]) + const c2 = writeSession('session.2.jsonl', { id: 'c2', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file: f, childFiles: [c1, c2] }) + // Primary first (its createdAt ties the children but primary wins); the two + // children keep a stable relative order. + expect(scripts[0]?.recordedId).toBe('parent') + expect(scripts.every(s => s.createdAt === 100)).toBe(true) + expect(scripts.map(s => s.primary)).toEqual([true, false, false]) + }) + + it('keeps the primary first even when a child sorts BEFORE it in input order', () => { + // The primary is appended first internally but the child has an EARLIER + // createdAt — the primary must still win on the tie-break against a + // later-but-equal child, and lose only to a genuinely earlier child via + // createdAt (here the child is earlier, so order is child-then-primary only + // if createdAt strictly less; equal createdAt keeps primary first). + const f = writeSession('session.jsonl', { id: 'parent', createdAt: 100 }, [TEXT_CHUNKS]) + const earlier = writeSession('session.1.jsonl', { id: 'early', createdAt: 100 }, [TEXT_CHUNKS]) + const scripts = loadSessionScripts({ file: f, childFiles: [earlier] }) + // Equal createdAt → primary first. + expect(scripts.map(s => s.recordedId)).toEqual(['parent', 'early']) + }) +}) + +describe('installLlmReplay (per-session keying)', () => { + const second: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'child' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + + /** Write a session log file and return its path. */ + function writeSession(filename: string, header: { id: string; createdAt: number }, calls: StreamChunk[][]): string { + let seq = 1 + const events: SessionEvent[] = [] + calls.forEach((chunks, step) => { for (const c of chunks) events.push(chunkEvent(seq++, 1, step + 1, c)) }) + const path = join(dir, filename) + writeFileSync(path, sessionJsonl(events, header), 'utf8') + return path + } + + const live = (id: string): GenerateOptions => + ({ model: 'm', messages: [], sessionId: id as NonNullable }) + + it('routes each live session to its own script by FIRST-CALL order', async () => { + const parentFile = writeSession('session.jsonl', { id: 'rec-parent', createdAt: 100 }, [TEXT_CHUNKS]) + const childFile = writeSession('session.1.jsonl', { id: 'rec-child', createdAt: 200 }, [second]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] }) + // The first live session to call binds to the parent script; a different + // live session id binds to the child script — regardless of recorded ids. + expect(await drain(ctx.llm.stream(live('live-A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('live-B')))).toEqual(second) + // The first session's SECOND call would exhaust its 1-entry script. + await expect(drain(ctx.llm.stream(live('live-A')))).rejects.toThrow(/exhausted/) + }) + + it('keeps each session\'s cursor independent (interleaved calls)', async () => { + const a2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'a2' }, { type: 'finish', reason: { kind: 'stop' } }] + const b2: StreamChunk[] = [{ type: 'text-delta', index: 0, text: 'b2' }, { type: 'finish', reason: { kind: 'stop' } }] + const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS, a2]) + const childFile = writeSession('session.1.jsonl', { id: 'c', createdAt: 2 }, [second, b2]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile, childFiles: [childFile] }) + // Interleave: A#1, B#1, A#2, B#2 — each cursor advances per-session. + expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(second) + expect(await drain(ctx.llm.stream(live('A')))).toEqual(a2) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(b2) + }) + + it('treats a call with no sessionId as the single anonymous (primary) session', async () => { + const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile }) + // No sessionId at all — the legacy single-session path. + expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) + }) + + it('fails loud when more distinct live sessions call than were recorded', async () => { + const parentFile = writeSession('session.jsonl', { id: 'p', createdAt: 1 }, [TEXT_CHUNKS]) + const ctx = new Context() + await ctx.plugin(LlmService) + installLlmReplay(ctx, { file: parentFile }) // only ONE recorded session + expect(await drain(ctx.llm.stream(live('first')))).toEqual(TEXT_CHUNKS) + // A SECOND distinct live session has no script to bind to. + await expect(drain(ctx.llm.stream(live('second')))).rejects.toThrow(/unrecorded session/) + }) +}) + describe('apply (the plugin entry)', () => { - const ORIG = { file: process.env.DSH_SNAPSHOT_FILE, override: process.env.DSH_SNAPSHOT_OVERRIDE } + const ORIG = { + file: process.env.DSH_SNAPSHOT_FILE, + override: process.env.DSH_SNAPSHOT_OVERRIDE, + children: process.env.DSH_SNAPSHOT_CHILD_FILES, + } afterEach(() => { if (ORIG.file === undefined) delete process.env.DSH_SNAPSHOT_FILE else process.env.DSH_SNAPSHOT_FILE = ORIG.file if (ORIG.override === undefined) delete process.env.DSH_SNAPSHOT_OVERRIDE else process.env.DSH_SNAPSHOT_OVERRIDE = ORIG.override + if (ORIG.children === undefined) delete process.env.DSH_SNAPSHOT_CHILD_FILES + else process.env.DSH_SNAPSHOT_CHILD_FILES = ORIG.children }) it('exposes the namespace plugin shape (name/inject, no default export)', () => { @@ -418,4 +633,52 @@ describe('apply (the plugin entry)', () => { await ctx.plugin(LlmService) expect(() => { apply(ctx, { file: '' }) }).toThrow(/a fixture path is required/) }) + + it('loads child fixtures from config.childFiles (per-session routing)', async () => { + const childSecond: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'kid' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + const childFile = join(dir, 'session.1.jsonl') + writeFileSync(childFile, sessionJsonl(childSecond.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') + const ctx = new Context() + await ctx.plugin(LlmService) + apply(ctx, { file, childFiles: [childFile] }) + const live = (id: string): GenerateOptions => + ({ model: 'm', messages: [], sessionId: id as NonNullable }) + expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(childSecond) + }) + + it('falls back to $DSH_SNAPSHOT_CHILD_FILES (path-delimited) when config omits childFiles', async () => { + const childChunks: StreamChunk[] = [ + { type: 'block-start', index: 0, blockType: 'text' }, + { type: 'text-delta', index: 0, text: 'env-kid' }, + { type: 'finish', reason: { kind: 'stop' } }, + ] + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + const childFile = join(dir, 'session.1.jsonl') + writeFileSync(childFile, sessionJsonl(childChunks.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'c', createdAt: 2 }), 'utf8') + process.env.DSH_SNAPSHOT_FILE = file + process.env.DSH_SNAPSHOT_CHILD_FILES = childFile // single entry, no delimiter needed + const ctx = new Context() + await ctx.plugin(LlmService) + apply(ctx) + const live = (id: string): GenerateOptions => + ({ model: 'm', messages: [], sessionId: id as NonNullable }) + expect(await drain(ctx.llm.stream(live('A')))).toEqual(TEXT_CHUNKS) + expect(await drain(ctx.llm.stream(live('B')))).toEqual(childChunks) + }) + + it('ignores an empty $DSH_SNAPSHOT_CHILD_FILES (single-session)', async () => { + writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c)), { id: 'p', createdAt: 1 }), 'utf8') + process.env.DSH_SNAPSHOT_FILE = file + process.env.DSH_SNAPSHOT_CHILD_FILES = '' + const ctx = new Context() + await ctx.plugin(LlmService) + apply(ctx) + expect(await drain(ctx.llm.stream({ model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) + }) }) diff --git a/packages/support/llm-replay/tsconfig.json b/packages/support/llm-replay/tsconfig.json index e7d274f2cd..95245937ec 100644 --- a/packages/support/llm-replay/tsconfig.json +++ b/packages/support/llm-replay/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/support/subagent-mock/README.md b/packages/support/subagent-mock/README.md new file mode 100644 index 0000000000..305aea93c4 --- /dev/null +++ b/packages/support/subagent-mock/README.md @@ -0,0 +1,19 @@ +# @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, start-time capability validation, the run lifecycle (`result` / `cancel` / `dispose`), and the structured-output branch — all 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`) the provider advertises. | +| `structured` | `{ reply }` | Structured value surfaced when a request carries an `outputSchema` and the capability is on. | + +A `cancel()` issued before `result` settles flips the stop reason to `aborted`, so the cancellation path is observable. diff --git a/packages/support/subagent-mock/package.json b/packages/support/subagent-mock/package.json new file mode 100644 index 0000000000..8980cc35d2 --- /dev/null +++ b/packages/support/subagent-mock/package.json @@ -0,0 +1,40 @@ +{ + "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-subagent": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "dependencies": { + "schemastery": "^3.18.0" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/support/subagent-mock/src/index.ts b/packages/support/subagent-mock/src/index.ts new file mode 100644 index 0000000000..c1a988fbfe --- /dev/null +++ b/packages/support/subagent-mock/src/index.ts @@ -0,0 +1,112 @@ +/** + * A scripted {@link SubagentProvider} for testing the subagent seam WITHOUT a + * model or a real child agent. Mirrors `@deepseek-ai/dsh-llm-replay`: it lets a + * test drive the service and the model-facing tool through the REAL cordis + * Loader / export path, exercising registration, capability validation, the + * run lifecycle, and the structured-output branch deterministically. + * + * Plugin export shape: named `name`/`inject`/`Config`/`apply`, NO default — + * a functional plugin (it only registers a provider; it is never injected). + * + * @module @deepseek-ai/dsh-subagent-mock + */ + +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 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 } + +/** + * A scripted provider: every {@link start} returns a run whose `result` + * resolves on a microtask with the configured reply (and a structured value + * when the request asked for one and the capability is on). `dispose` is a + * no-op; a `cancel()` before the result settles flips the stop reason to + * `aborted`, so the cancellation path is observable in a test. + */ +class MockSubagentProvider implements SubagentProvider { + readonly capabilities: SubagentCapabilities + + constructor( + readonly name: string, + private readonly config: Config, + ) { + this.capabilities = { ...DEFAULT_CAPS, ...config.capabilities } + } + + start(request: SubagentStartRequest): SubagentRun { + 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' + let cancelled = false + + // 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 resultFor = (): SubagentResult => ({ + output, + structured: wantsStructured ? (this.config.structured ?? { reply }) : undefined, + stopReason: cancelled ? 'aborted' : baseStop, + }) + + return { + id, + result: Promise.resolve().then(resultFor), + cancel() { + cancelled = true + }, + async dispose() { + // Scripted run holds no resources — nothing to await. + }, + } + } +} + +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 + /** + * 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(), + }), + 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 new file mode 100644 index 0000000000..f35ed884eb --- /dev/null +++ b/packages/support/subagent-mock/tests/subagent-mock.spec.ts @@ -0,0 +1,101 @@ +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 SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' +import * as mock from '../src/index.ts' + +/** A minimal parent — the mock provider only reads `parent.id`. */ +function fakeParent(id = 'parent-1'): Agent { + return { id: AgentId(id) } as unknown as Agent +} + +function baseRequest(over: Partial = {}): SubagentStartRequest { + return { prompt: [{ type: 'text', text: 'task' }], parent: fakeParent(), ...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 = ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toEqual({ + output: [{ type: 'text', text: 'hello from mock' }], + structured: undefined, + stopReason: 'completed', + }) + }) + + 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 = ctx.subagents.start('mock', baseRequest({ outputSchema: { 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 = ctx.subagents.start('mock', baseRequest({ outputSchema: { 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 = ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toMatchObject({ structured: undefined }) + }) + + it('honors a configured stop reason', async () => { + const ctx = await mount({ stopReason: 'refusal' }) + const run = ctx.subagents.start('mock', baseRequest()) + await expect(run.result).resolves.toMatchObject({ stopReason: 'refusal' }) + }) + + it('flips the stop reason to aborted when cancelled before the result settles', async () => { + const ctx = await mount() + const run = ctx.subagents.start('mock', baseRequest()) + run.cancel() + await expect(run.result).resolves.toMatchObject({ stopReason: 'aborted' }) + }) + + 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', () => { + // Postmortem 0001 guard: this plugin HAS `inject = ['subagents']`, so a stray + // `export default apply` would collapse the module via `unwrapExports` + // (`exports.default ?? exports`), DROP `inject`, and crash at load with + // "cannot get property … without inject". Guard the shape directly. + expect('default' in 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 new file mode 100644 index 0000000000..ccc9fa45ed --- /dev/null +++ b/packages/support/subagent-mock/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../subagent/subagent" + } + ] +} diff --git a/packages/support/ui-stdio/package.json b/packages/support/ui-stdio/package.json index 156984a72e..5d65356e79 100644 --- a/packages/support/ui-stdio/package.json +++ b/packages/support/ui-stdio/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/support/ui-stdio/tsconfig.json b/packages/support/ui-stdio/tsconfig.json index f7e9736f77..b333de0302 100644 --- a/packages/support/ui-stdio/tsconfig.json +++ b/packages/support/ui-stdio/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 0ecbba9e6a..72eb95b2f7 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -5,24 +5,27 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "bin": { "dsh-acp-agent": "lib/bin.js" }, "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./bin": { - "types": "./lib/bin.d.ts", + "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index 773ca2e293..ffea8ec6f6 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/ui/acp-agent/tsdown.config.ts b/packages/ui/acp-agent/tsdown.config.ts index a0710d6e4d..9dd130b30d 100644 --- a/packages/ui/acp-agent/tsdown.config.ts +++ b/packages/ui/acp-agent/tsdown.config.ts @@ -3,11 +3,12 @@ import { defineConfig } from 'tsdown' /** * acp-agent ships TWO entries: the plugin (`index`) and the CLI `bin` (`bin`), * the latter referenced by package.json `bin`/`exports["./bin"]`. The root - * tsdown builds only `src/index.ts`, so this override adds `bin.ts`. - * Declarations come from `tsc -b` (dts: false), matching every package. + * tsdown builds only `lib/types/index.js`, so this override adds + * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), + * matching every package. */ export default defineConfig({ - entry: ['src/index.ts', 'src/bin.ts'], + entry: ['lib/types/index.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/ui/acp/package.json b/packages/ui/acp/package.json index 0a4a890658..6973dc5e20 100644 --- a/packages/ui/acp/package.json +++ b/packages/ui/acp/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui/acp/tsconfig.json b/packages/ui/acp/tsconfig.json index 33d4d0b6f7..5989363d7f 100644 --- a/packages/ui/acp/tsconfig.json +++ b/packages/ui/acp/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index 45e8021607..bc9c98a411 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -5,24 +5,27 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "bin": { "dsh-stdio-agent": "lib/bin.js" }, "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./bin": { - "types": "./lib/bin.d.ts", + "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/bin.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 2130a6162c..58b492a549 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/packages/ui/stdio-agent/tsdown.config.ts b/packages/ui/stdio-agent/tsdown.config.ts index 62dc986c08..53797cdd79 100644 --- a/packages/ui/stdio-agent/tsdown.config.ts +++ b/packages/ui/stdio-agent/tsdown.config.ts @@ -3,11 +3,12 @@ import { defineConfig } from 'tsdown' /** * stdio-agent ships TWO entries: the plugin (`index`) and the CLI `bin` * (`bin`), the latter referenced by package.json `bin`/`exports["./bin"]`. - * The root tsdown builds only `src/index.ts`, so this override adds `bin.ts`. - * Declarations come from `tsc -b` (dts: false), matching every package. + * The root tsdown builds only `lib/types/index.js`, so this override adds + * `lib/types/bin.js`. Declarations come from `tsc -b` (dts: false), + * matching every package. */ export default defineConfig({ - entry: ['src/index.ts', 'src/bin.ts'], + entry: ['lib/types/index.js', 'lib/types/bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index f0dcf7a8d7..8059952170 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "license": "BSD-3-Clause", diff --git a/packages/util/brand/tsconfig.json b/packages/util/brand/tsconfig.json index f8fc535ab7..749cb0208e 100644 --- a/packages/util/brand/tsconfig.json +++ b/packages/util/brand/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib" + "outDir": "lib/types" }, "include": [ "src" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3bca330308..55e565cad2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -318,6 +318,202 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/subagent/subagent: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/subagent-acp: + dependencies: + '@agentclientprotocol/sdk': + specifier: 0.25.1 + version: 0.25.1(zod@4.4.3) + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/subagent-fork: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@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 + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../subagent-inprocess + '@deepseek-ai/dsh-subagent-spawn': + specifier: workspace:^ + version: link:../subagent-spawn + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/subagent-inprocess: + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@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 + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/subagent-spawn: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-bash-local': + specifier: workspace:^ + version: link:../../bash/bash-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-llm-deepseek': + specifier: workspace:^ + version: link:../../llm/llm-deepseek + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../subagent + '@deepseek-ai/dsh-subagent-inprocess': + specifier: workspace:^ + version: link:../subagent-inprocess + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:^ + version: link:../../bash/tool-bash + '@deepseek-ai/dsh-tool-subagent': + specifier: workspace:^ + version: link:../tool-subagent + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + + packages/subagent/tool-subagent: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@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 + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/invariants: devDependencies: '@deepseek-ai/dsh-agent': @@ -345,6 +541,28 @@ importers: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/subagent-mock: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.4 + version: 1.0.0-rc.4(cordis@4.0.0-rc.6) + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-subagent': + specifier: workspace:^ + version: link:../../subagent/subagent + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.4) + packages/support/ui-stdio: dependencies: schemastery: diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index eae8dd55f1..a669a8572d 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -33,6 +33,18 @@ interface PackageManifest { version?: string private?: boolean type?: string + main?: string + types?: string + bin?: string | Record + exports?: Record< + string, + | { + types?: string + default?: string + } + | undefined + > + files?: string[] peerDependencies?: Record devDependencies?: Record } @@ -73,6 +85,29 @@ function workspaceManifests(): WorkspaceManifest[] { return manifests } +const dshPackageFiles = [ + 'lib/index.js', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', +] as const + +const dshBinPackageFiles = [ + 'lib/index.js', + 'lib/bin.js', + 'lib/types/**/*.d.ts', + 'lib/types/**/*.d.ts.map', + 'src', +] as const + +function sameStringList(actual: readonly string[] | undefined, expected: readonly string[]): boolean { + return !!actual && actual.length === expected.length && actual.every((value, index) => value === expected[index]) +} + +function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { + return manifest.bin ? dshBinPackageFiles : dshPackageFiles +} + function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { const errors: string[] = [] const label = manifest.name ?? dir @@ -100,6 +135,22 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.type !== 'module') { errors.push(`${label}: package.json must set "type": "module"`) } + if (manifest.main !== 'lib/index.js') { + errors.push(`${label}: package.json must set "main": "lib/index.js"`) + } + if (manifest.types !== 'lib/types/index.d.ts') { + errors.push(`${label}: package.json must set "types": "lib/types/index.d.ts"`) + } + if (manifest.exports?.['.']?.types !== './lib/types/index.d.ts') { + errors.push(`${label}: package.json exports["."].types must be "./lib/types/index.d.ts"`) + } + if (manifest.exports?.['.']?.default !== './lib/index.js') { + errors.push(`${label}: package.json exports["."].default must be "./lib/index.js"`) + } + const expectedFiles = expectedDshPackageFiles(manifest) + if (!sameStringList(manifest.files, expectedFiles)) { + errors.push(`${label}: package.json files must be ${JSON.stringify(expectedFiles)}`) + } } return errors.map(error => `${relative(root, join(root, dir, 'package.json'))}: ${error}`) diff --git a/scripts/doc-typecheck.ts b/scripts/doc-typecheck.ts index 01a64b0eda..7422d6ac77 100644 --- a/scripts/doc-typecheck.ts +++ b/scripts/doc-typecheck.ts @@ -3,12 +3,12 @@ * Markdown so documentation can't drift from the API it documents. * * Every ```ts block in README.md, docs/** and packages/* /README.md is - * extracted to a temp file and compiled with `tsc --noEmit` against the - * workspace sources (resolved through the same `paths` map vitest uses, so no - * build is required first). A block that is a deliberate sketch rather than - * compilable code opts out with an explicit ` ```ts ignore-check ` info string - * — the opt-out is visible in the source, and this script reports the ratio so - * the escape hatch can't quietly become the norm. A third info string, + * extracted to a temp typecheck project and compiled against the workspace + * sources through the same project-reference boundaries used by repo + * typecheck. A block that is a deliberate sketch rather than compilable code + * opts out with an explicit ` ```ts ignore-check ` info string — the opt-out + * is visible in the source, and this script reports the ratio so the escape + * hatch can't quietly become the norm. A third info string, * doc-typecheck.ts recognizes two more fence variants and skips both (each is a * separately-checked category, not an unchecked sketch, so neither counts in the * opt-out ratio): ` ```ts type-equiv ` is a verbatim source-type paste that @@ -88,16 +88,9 @@ function extractBlocks(absPath: string): Block[] { return blocks } -/** - * Read the workspace `paths` map from tsconfig.typecheck.json (JSONC). This map - * resolves vendored packages to their BUILT declarations (`lib`) and harness - * packages to source (`src`) — the same resolution `pnpm run lint`/`typecheck` use. - * Resolving vendor to `lib` (not `src`) is essential: otherwise tsc type-checks - * raw vendor source and floods the run with unrelated errors. Requires the - * vendor `lib/` to exist (a fresh clone runs `pnpm run build` first; CI does too). - */ -function workspacePaths(): Record { - const file = join(root, 'tsconfig.typecheck.json') +/** Reuse the repo typecheck graph references from a temp project one directory below root. */ +function workspaceReferences(): { path: string }[] { + const file = join(root, 'tsconfig.json') // Parse with TypeScript's own JSONC reader, not a hand-rolled comment strip: // a regex strip mistakes the `/*/` in a wildcard path candidate // (`./packages/core/*/src`) for a block comment and corrupts the map. @@ -106,27 +99,24 @@ function workspacePaths(): Record { throw new Error(`doc-typecheck: cannot read ${file}: ${ts.flattenDiagnosticMessageText(result.error.messageText, '\n')}`) } // `config` is typed `any` by the TS API; narrow it to the one field we read. - const config = result.config as { compilerOptions: { paths: Record } } - return config.compilerOptions.paths + const { references } = result.config as { compilerOptions: { paths: Record }; references: { path: string }[] } + return references.map(({ path }) => { + const relativeToTemp = path.startsWith('./') ? `../${path.slice(2)}` : `../${path}` + return { path: relativeToTemp } + }) } -/** The standalone tsconfig for the temp project (copies base resolution, no - * composite/declaration settings that would fight `--noEmit`). */ +/** The standalone tsconfig for the temp typecheck project. */ function tempTsconfig(): string { return JSON.stringify({ + extends: '../tsconfig.json', compilerOptions: { - target: 'es2024', - module: 'esnext', - moduleResolution: 'bundler', - allowImportingTsExtensions: true, - strict: true, - noEmit: true, - skipLibCheck: true, - types: ['node'], - baseUrl: root, - ignoreDeprecations: '6.0', - paths: workspacePaths(), + noUnusedLocals: false, + noUnusedParameters: false, + tsBuildInfoFile: './tsconfig.tsbuildinfo', }, + include: ['block-*.ts'], + references: workspaceReferences(), }) } @@ -164,11 +154,12 @@ try { }) try { - execFileSync('node_modules/.bin/tsc', ['-p', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) + execFileSync('node_modules/.bin/tsc', ['-b', join(tmp, 'tsconfig.json')], { cwd: root, stdio: 'pipe' }) } catch (error: unknown) { - const out = (error as { stdout?: Buffer }).stdout?.toString() ?? '' + const failed = error as { stdout?: Buffer; stderr?: Buffer } + const out = `${failed.stdout?.toString() ?? ''}${failed.stderr?.toString() ?? ''}` // Rewrite "block-N.ts(line,col)" to the real "file:fenceLine" for triage. - const remapped = out.replace(/block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => { + const remapped = out.replace(/(?:[^\s:()]*[/\\])?block-(\d+)\.ts\((\d+),(\d+)\)/g, (_m, idx: string, ln: string, col: string) => { const block = fileForBlock.get(`block-${idx}.ts`) if (!block) return `block-${idx}.ts(${ln},${col})` return `${block.file} (block at line ${block.line}, +${ln}:${col})` diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 30165e2388..0f6378c3c0 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -39,6 +39,13 @@ { "doc": "docs/core-data-structures/bash.md", "symbol": "BashRunResult", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "CollectedOutput", "source": "packages/bash/bash/src/types.ts" }, { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTask", "source": "packages/bash/bash/src/types.ts" }, - { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" } + { "doc": "docs/core-data-structures/bash.md", "symbol": "BashTaskRead", "source": "packages/bash/bash/src/types.ts" }, + + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentCapabilities", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStartRequest", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentResult", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentStopReasonMap", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentRun", "source": "packages/subagent/subagent/src/types.ts" }, + { "doc": "docs/core-data-structures/subagent.md", "symbol": "SubagentProvider", "source": "packages/subagent/subagent/src/types.ts" } ] } diff --git a/scripts/verify-node-next-types.ts b/scripts/verify-node-next-types.ts new file mode 100644 index 0000000000..7883a855c3 --- /dev/null +++ b/scripts/verify-node-next-types.ts @@ -0,0 +1,160 @@ +/** + * Verify that built package declarations are consumable by a standard external + * TypeScript ESM project using NodeNext resolution. + * + * Run after `pnpm run build` has emitted declaration files under package + * `lib/types` directories. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' + +const root = resolve(import.meta.dirname, '..') + +interface ExportTarget { + types?: string +} + +interface PackageManifest { + name?: string + types?: string + exports?: Record +} + +interface WorkspacePackage { + dir: string + name: string + manifest: PackageManifest +} + +function readPackage(path: string): WorkspacePackage | null { + const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest + if (!manifest.name) return null + return { dir: dirname(path), name: manifest.name, manifest } +} + +function workspacePackages(): WorkspacePackage[] { + return [ + ...globSync('vendor/*/package.json', { cwd: root }), + ...globSync('packages/*/*/package.json', { cwd: root }), + ] + .map(path => readPackage(resolve(root, path))) + .filter(pkg => pkg !== null) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g +const hasExtension = /\.[^/.]+$/ + +function relativeSpecifiersMissingExtensions(): string[] { + const errors: string[] = [] + const files = [ + ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }), + ...globSync('packages/*/*/lib/types/**/*.d.ts', { cwd: root }), + ].sort() + + for (const file of files) { + const text = readFileSync(resolve(root, file), 'utf8') + for (const match of text.matchAll(declarationSpecifierPattern)) { + const specifier = match[1] + if (!specifier) continue + const isRelative = specifier === '.' || specifier.startsWith('./') || specifier.startsWith('../') + if (isRelative && !hasExtension.test(specifier)) errors.push(`${file}: ${specifier}`) + } + } + + return errors +} + +function publicSpecifiers(pkg: WorkspacePackage): string[] { + const specifiers = new Set() + if (pkg.manifest.types) specifiers.add(pkg.name) + + for (const [key, target] of Object.entries(pkg.manifest.exports ?? {})) { + if (key.includes('*') || key === './package.json') continue + if (typeof target !== 'object' || target === null || !target.types) continue + specifiers.add(key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`) + } + + return [...specifiers].sort() +} + +function linkPackage(pkg: WorkspacePackage, nodeModules: string): void { + const parts = pkg.name.split('/') + const link = resolve(nodeModules, ...parts) + mkdirSync(dirname(link), { recursive: true }) + symlinkSync(pkg.dir, link, 'dir') +} + +const packages = workspacePackages() +const badSpecifiers = relativeSpecifiersMissingExtensions() +if (badSpecifiers.length > 0) { + console.error('verify-node-next-types: declaration files still contain relative specifiers without file extensions.') + console.error(badSpecifiers.join('\n')) + process.exit(1) +} + +const missingOutputs = packages + .filter(pkg => pkg.manifest.types && !existsSync(resolve(pkg.dir, pkg.manifest.types))) + .map(pkg => `${pkg.name}: missing ${pkg.manifest.types}`) + +if (missingOutputs.length > 0) { + console.error('verify-node-next-types: build outputs are missing; run `pnpm run build` first.') + console.error(missingOutputs.join('\n')) + process.exit(1) +} + +const tmp = mkdtempSync(resolve(root, '.node-next-types-')) +let failed = false + +try { + const nodeModules = resolve(tmp, 'node_modules') + mkdirSync(nodeModules, { recursive: true }) + for (const pkg of packages) linkPackage(pkg, nodeModules) + + const rootTypes = resolve(root, 'node_modules/@types/node') + if (existsSync(rootTypes)) { + const typesDir = resolve(nodeModules, '@types') + mkdirSync(typesDir, { recursive: true }) + symlinkSync(rootTypes, resolve(typesDir, 'node'), 'dir') + } + + writeFileSync(resolve(tmp, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`) + writeFileSync(resolve(tmp, 'tsconfig.json'), `${JSON.stringify({ + compilerOptions: { + target: 'es2024', + module: 'NodeNext', + moduleResolution: 'NodeNext', + strict: true, + // Third-party SDK declarations can have their own lib-check noise under a + // symlinked temp install. The explicit scan above owns our regression: + // relative specifiers without file extensions in built declarations. + skipLibCheck: true, + preserveSymlinks: true, + noEmit: true, + types: ['node'], + }, + include: ['index.ts'], + }, null, 2)}\n`) + + const imports = packages.flatMap(publicSpecifiers) + .map((specifier, index) => `import * as mod${index} from ${JSON.stringify(specifier)};\nvoid mod${index};`) + .join('\n') + writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`) + + execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], { + cwd: root, + stdio: 'pipe', + }) + console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(s) compile under NodeNext.`) +} catch (error: unknown) { + failed = true + const output = error as { stdout?: Buffer; stderr?: Buffer } + console.error('verify-node-next-types: NodeNext consumer typecheck failed.\n') + console.error(`${output.stdout?.toString() ?? ''}${output.stderr?.toString() ?? ''}`) +} finally { + rmSync(tmp, { recursive: true, force: true }) +} + +if (failed) process.exit(1) diff --git a/tsconfig.base.json b/tsconfig.base.json index 8e2070fe28..380db50577 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,12 +4,14 @@ "module": "esnext", "moduleResolution": "bundler", "declaration": true, - "emitDeclarationOnly": true, + "sourceMap": true, + "declarationMap": true, "composite": true, "incremental": true, "skipLibCheck": true, "esModuleInterop": true, "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, "verbatimModuleSyntax": false, "strict": true, "noUncheckedIndexedAccess": true, @@ -19,11 +21,9 @@ "noUnusedLocals": true, "noUnusedParameters": true, "types": ["node"], - // Source-level resolution for the build graph: without this, a fresh - // checkout's first `tsc -b` resolves sibling vendor plugins through their - // package.json types (vendor/*/lib/*.d.ts) which don't exist yet — TS2307 - // until a second run. Derived configs that want lib resolution - // (tsconfig.typecheck.json) override this map wholesale. + // Source-level resolution for every repo-local graph. Project references, + // not declaration path aliases, keep each package/vendor source compiled + // under its own tsconfig boundary. "paths": { "cordis": ["./vendor/cordis/src"], "cosmokit": ["./vendor/cosmokit/src"], @@ -43,6 +43,7 @@ "./packages/core/*/src", "./packages/llm/*/src", "./packages/bash/*/src", + "./packages/subagent/*/src", "./packages/session-persistence/*/src", "./packages/ui/*/src", "./packages/util/*/src", diff --git a/tsconfig.build.json b/tsconfig.build.json index 27a17a3f17..4c71d2f14e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -31,6 +31,13 @@ { "path": "./packages/ui/acp-agent" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/ui-stdio" }, - { "path": "./packages/support/llm-replay" } + { "path": "./packages/support/llm-replay" }, + { "path": "./packages/subagent/subagent" }, + { "path": "./packages/support/subagent-mock" }, + { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-inprocess" }, + { "path": "./packages/subagent/subagent-spawn" }, + { "path": "./packages/subagent/subagent-fork" }, + { "path": "./packages/subagent/subagent-acp" } ] } diff --git a/tsconfig.json b/tsconfig.json index 725f31659f..dcc23b2fbe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,54 @@ { "extends": "./tsconfig.base.json", - "files": [] + "compilerOptions": { + "noEmit": true, + "rewriteRelativeImportExtensions": false + }, + "include": [ + "examples/*/src/**/*.ts", + "examples/*/start.ts", + "examples/*/tests/**/*.ts", + "packages/*/*/tests/**/*.ts", + "scripts/**/*.ts" + ], + "references": [ + { "path": "./vendor/cosmokit" }, + { "path": "./vendor/schemastery" }, + { "path": "./vendor/cordis" }, + { "path": "./vendor/loader" }, + { "path": "./vendor/include" }, + { "path": "./vendor/group" }, + { "path": "./vendor/timer" }, + { "path": "./vendor/hmr" }, + { "path": "./vendor/logger-console" }, + { "path": "./packages/util/brand" }, + { "path": "./packages/llm/llm" }, + { "path": "./packages/core/session" }, + { "path": "./packages/session-persistence/session-persistence" }, + { "path": "./packages/session-persistence/session-persistence-jsonl" }, + { "path": "./packages/session-persistence/session-persistence-sqlite" }, + { "path": "./packages/core/system-prompt" }, + { "path": "./packages/core/agent" }, + { "path": "./packages/core/tools" }, + { "path": "./packages/core/agent-loop" }, + { "path": "./packages/core/agent-core" }, + { "path": "./packages/bash/bash" }, + { "path": "./packages/llm/llm-deepseek" }, + { "path": "./packages/llm/llm-pi-ai" }, + { "path": "./packages/bash/bash-local" }, + { "path": "./packages/bash/tool-bash" }, + { "path": "./packages/support/invariants" }, + { "path": "./packages/ui/acp" }, + { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/stdio-agent" }, + { "path": "./packages/support/ui-stdio" }, + { "path": "./packages/support/llm-replay" }, + { "path": "./packages/subagent/subagent" }, + { "path": "./packages/support/subagent-mock" }, + { "path": "./packages/subagent/tool-subagent" }, + { "path": "./packages/subagent/subagent-inprocess" }, + { "path": "./packages/subagent/subagent-spawn" }, + { "path": "./packages/subagent/subagent-fork" }, + { "path": "./packages/subagent/subagent-acp" } + ] } diff --git a/tsconfig.test.json b/tsconfig.test.json deleted file mode 100644 index a7933dad55..0000000000 --- a/tsconfig.test.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "extends": "./tsconfig.json", - "compilerOptions": { - "noEmit": true, - "emitDeclarationOnly": false, - "composite": false, - "types": ["node"] - }, - "include": ["vendor/*/src", "packages/*/*/src", "packages/*/*/tests", "examples"] -} diff --git a/tsconfig.typecheck.json b/tsconfig.typecheck.json deleted file mode 100644 index 54769bdbc0..0000000000 --- a/tsconfig.typecheck.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "extends": "./tsconfig.base.json", - "compilerOptions": { - "noEmit": true, - "emitDeclarationOnly": false, - "composite": false, - "incremental": false, - "types": ["node"], - "paths": { - "cordis": ["./vendor/cordis/lib"], - "cosmokit": ["./vendor/cosmokit/lib"], - "schemastery": ["./vendor/schemastery/lib"], - "@cordisjs/plugin-loader": ["./vendor/loader/lib"], - "@cordisjs/plugin-include": ["./vendor/include/lib"], - "@cordisjs/plugin-group": ["./vendor/group/lib"], - "@cordisjs/plugin-timer": ["./vendor/timer/lib"], - "@cordisjs/plugin-hmr": ["./vendor/hmr/lib"], - "@cordisjs/plugin-logger-console": ["./vendor/logger-console/lib/shared"], - "@deepseek-ai/dsh-*": [ - "./packages/core/*/src", - "./packages/llm/*/src", - "./packages/bash/*/src", - "./packages/session-persistence/*/src", - "./packages/ui/*/src", - "./packages/util/*/src", - "./packages/support/*/src" - ] - } - }, - "include": ["packages/*/*/src", "packages/*/*/tests", "examples", "scripts"] -} diff --git a/tsdown.config.ts b/tsdown.config.ts index 6723d514b7..7b172180d1 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -2,9 +2,9 @@ import { defineConfig } from 'tsdown' /** * JS bundling for all workspace packages (vendor and the packages hierarchy). - * Declarations are NOT produced here — `tsc -b tsconfig.build.json` owns - * .d.ts output (composite project references); hence `dts: false` and - * `clean: false` (lib/ already holds tsc's declarations). + * TypeScript source is compiled first by `tsc -b tsconfig.build.json`; tsdown + * reads only the emitted JS under lib/types and writes lib/index.* runtime + * bundles. Declarations are NOT produced here, hence `dts: false`. * * Per-package shape overrides live in `/tsdown.config.ts` * (schemastery: dual ESM+CJS; logger-console: extra browser entry). @@ -14,7 +14,7 @@ export default defineConfig({ // package.json), but only vendor and the packages hierarchy are pnpm // workspaces. workspace: ['vendor/*', 'packages/*/*'], - entry: ['src/index.ts'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/README.md b/vendor/README.md index 04e63f26c1..bf0f0b5a8c 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -31,9 +31,10 @@ Intentionally **not** vendored (verified unused by this set): `reggol`, `@cordis Keep this log exhaustive — every divergence from upstream must be listed. 1. **`hmr/src/index.ts`**: removed the `./locales/en-US.yml` / `./locales/zh-CN.yml` imports, the `.i18n({...})` call on the `Config` schema, and the `src/locales/` directory. Rationale: those imports require a runtime YAML loader hook (`@cordisjs/unyaml`) that we do not vendor; the i18n texts only localize config descriptions. -2. **All `package.json` files**: regenerated — added `private: true`, added `src` to `files` and a `./src/*` export where missing, removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. -3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json` and declare project references. -4. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. Like the regenerated tsconfigs, they are not part of the upstream sync surface. +2. **All `package.json` files**: regenerated — added `private: true`, added precise `files` entries for bundled runtime files and `lib/types/**/*.d.ts` / `.d.ts.map`, preserved `src` in `files` only for packages whose previous file list already shipped it, added a `./src/*` export where missing, pointed declaration metadata at `lib/types`, and removed upstream `devDependencies`/`scripts`/`repository` fields. Dependency and peer-dependency ranges preserved, except `hmr` declares `esbuild` as a direct dev dependency because its source imports the `BuildFailure` type and pnpm's strict workspace resolution requires the owner package to name that dependency. +3. **All `tsconfig.json` files**: regenerated to extend the repo-root `tsconfig.base.json`, emit TypeScript intermediates to `lib/types`, and declare project references. +4. **Vendored TypeScript source internal specifiers**: changed local relative imports/exports from upstream's specifier shape to explicit `.ts` specifiers so TypeScript rewrites emitted JS to `.js` while declarations keep explicit, NodeNext-safe `.ts` specifiers. This includes `loader/src/config/isolate.ts` using `declare module './entry.ts'`. +5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. ## Sync procedure diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 6b9e59a00b..9d9ac07a34 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -6,18 +6,20 @@ "sideEffects": false, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "bin": "bin.js", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "bin.js" ], "author": "Shigma ", diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 768ba52d6f..8b21c464b2 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -1,10 +1,10 @@ import { Dict } from 'cosmokit' -import { EventsService } from './events' -import { LoggerService } from './logger' -import { ReflectService } from './reflect' -import { InjectKey, RegistryService } from './registry' -import { getTraceable, symbols } from './utils' -import { Fiber } from './fiber' +import { EventsService } from './events.ts' +import { LoggerService } from './logger.ts' +import { ReflectService } from './reflect.ts' +import { InjectKey, RegistryService } from './registry.ts' +import { getTraceable, symbols } from './utils.ts' +import { Fiber } from './fiber.ts' /** * Public shape of a Cordis context. diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index f7dcf011f4..4461816537 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -1,7 +1,7 @@ import { defineProperty, Promisify } from 'cosmokit' -import { Context } from './context' -import { Fiber, FiberState } from './fiber' -import { DisposableList, symbols } from './utils' +import { Context } from './context.ts' +import { Fiber, FiberState } from './fiber.ts' +import { DisposableList, symbols } from './utils.ts' /** Return whether an event result should stop a bail-style dispatch. */ export function isBailed(value: any) { @@ -25,7 +25,7 @@ export type ThisType = F extends (this: infer T, ...args: any) => any ? T : n */ export type DispatchMode = 'emit' | 'parallel' | 'serial' | 'bail' | 'waterfall' -declare module './context' { +declare module './context.ts' { export interface Context { /* eslint-disable max-len */ parallel(name: K, ...args: Parameters): Promise diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 840bc54352..fd472e7733 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -1,11 +1,11 @@ import { Awaitable, defineProperty, Dict, isNullable } from 'cosmokit' -import { Context } from './context' -import { Plugin } from './registry' -import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils' -import { Impl } from './reflect' +import { Context } from './context.ts' +import { Plugin } from './registry.ts' +import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' +import { Impl } from './reflect.ts' import { StandardSchemaV1 } from '@standard-schema/spec' -declare module './context' { +declare module './context.ts' { export interface Context extends Pick { fiber: Fiber } diff --git a/vendor/cordis/src/index.ts b/vendor/cordis/src/index.ts index 83d160395e..d0814213e0 100644 --- a/vendor/cordis/src/index.ts +++ b/vendor/cordis/src/index.ts @@ -1,14 +1,14 @@ /** Core context type and root context implementation. */ -export * from './context' +export * from './context.ts' /** Event bus, dispatch modes, and event augmentation types. */ -export * from './events' +export * from './events.ts' /** Plugin fiber lifecycle, effects, and config validation helpers. */ -export * from './fiber' +export * from './fiber.ts' /** Logger facade, logger service, message, exporter, and formatting types. */ -export * from './logger' +export * from './logger.ts' /** Plugin registry, dependency injection, and plugin entrypoint types. */ -export * from './registry' +export * from './registry.ts' /** Base service class and service lifecycle symbols. */ -export * from './service' +export * from './service.ts' /** Shared internal helpers used by context, services, and plugin fibers. */ -export * from './utils' +export * from './utils.ts' diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index f76ac2cdb7..a1e97c165a 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -1,9 +1,9 @@ import { defineProperty, hyphenate } from 'cosmokit' -import { Context } from './context' -import { Fiber } from './fiber' -import { createCallable, joinPrototype, symbols, Tracker } from './utils' +import { Context } from './context.ts' +import { Fiber } from './fiber.ts' +import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' -declare module './context' { +declare module './context.ts' { interface Intercept { logger: LoggerService.Intercept } diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index 4bc9fb44db..212ec4e779 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -1,9 +1,9 @@ import { defineProperty, Dict, isNullable } from 'cosmokit' -import { Context } from './context' -import { getTraceable, symbols, withProps } from './utils' -import { Fiber, FiberState } from './fiber' +import { Context } from './context.ts' +import { getTraceable, symbols, withProps } from './utils.ts' +import { Fiber, FiberState } from './fiber.ts' -declare module './context' { +declare module './context.ts' { interface Context { get(name: K, strict?: boolean): undefined | this[K] get(name: string, strict?: boolean): any diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index fae7712df9..9dfa10a06b 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -1,8 +1,8 @@ import { defineProperty, Dict } from 'cosmokit' import { StandardSchemaV1 } from '@standard-schema/spec' -import { Context } from './context' -import { Fiber } from './fiber' -import { buildOuterStack, DisposableList, symbols, withProps } from './utils' +import { Context } from './context.ts' +import { Fiber } from './fiber.ts' +import { buildOuterStack, DisposableList, symbols, withProps } from './utils.ts' function isApplicable(object: Plugin) { return object && typeof object === 'object' && typeof object.apply === 'function' @@ -140,7 +140,7 @@ type GetPluginConfig

= ? S : GetPluginParameters

[0] -declare module './context' { +declare module './context.ts' { export interface Context { inject(deps: Inject, callback: Plugin.Function): Fiber & PromiseLike plugin

(plugin: P, ...args: Spread>): Fiber & PromiseLike diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index 4cc9f307f2..30895247c1 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -1,6 +1,6 @@ import { defineProperty } from 'cosmokit' -import { Context } from './context' -import { createCallable, joinPrototype, symbols, Tracker } from './utils' +import { Context } from './context.ts' +import { createCallable, joinPrototype, symbols, Tracker } from './utils.ts' /** * Base class for services that expose a named API on `ctx`. diff --git a/vendor/cordis/src/utils.ts b/vendor/cordis/src/utils.ts index 46dd962c6f..2fd499bd0c 100644 --- a/vendor/cordis/src/utils.ts +++ b/vendor/cordis/src/utils.ts @@ -1,5 +1,5 @@ import { defineProperty } from 'cosmokit' -import type { Context, Service } from '.' +import type { Context, Service } from './index.ts' /** Ordered collection of disposable values with O(1) deletion by value. */ export class DisposableList { diff --git a/vendor/cordis/tsconfig.json b/vendor/cordis/tsconfig.json index b9829bf1df..c7357481fd 100644 --- a/vendor/cordis/tsconfig.json +++ b/vendor/cordis/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noImplicitAny": false, "noImplicitThis": false, "strictFunctionTypes": false, diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index 92fdf8e903..940fcdb539 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/cosmokit/src/array.ts b/vendor/cosmokit/src/array.ts index ccbc4b2752..18ed5e407f 100644 --- a/vendor/cosmokit/src/array.ts +++ b/vendor/cosmokit/src/array.ts @@ -1,4 +1,4 @@ -import { isNullable } from './misc' +import { isNullable } from './misc.ts' /** Return true when every item in `array2` is present in `array1`. */ export function contain(array1: readonly any[], array2: readonly any[]) { diff --git a/vendor/cosmokit/src/index.ts b/vendor/cosmokit/src/index.ts index 088e81c54f..9fe48de069 100644 --- a/vendor/cosmokit/src/index.ts +++ b/vendor/cosmokit/src/index.ts @@ -1,10 +1,10 @@ /** Array set and normalization helpers. */ -export * from './array' +export * from './array.ts' /** Runtime type, binary, clone, and equality helpers. */ -export * from './types' +export * from './types.ts' /** Shared utility types and object helpers. */ -export * from './misc' +export * from './misc.ts' /** String case, path, and property formatting helpers. */ -export * from './string' +export * from './string.ts' /** Time constants, parsing, and formatting helpers. */ -export * from './time' +export * from './time.ts' diff --git a/vendor/cosmokit/src/types.ts b/vendor/cosmokit/src/types.ts index b4d1e5bed8..499a46273a 100644 --- a/vendor/cosmokit/src/types.ts +++ b/vendor/cosmokit/src/types.ts @@ -1,4 +1,4 @@ -import { isNullable } from './misc' +import { isNullable } from './misc.ts' type GlobalConstructorNames = keyof { [K in keyof typeof globalThis as typeof globalThis[K] extends abstract new (...args: any) => any ? K : never]: K diff --git a/vendor/cosmokit/tsconfig.json b/vendor/cosmokit/tsconfig.json index 0db18e0f14..b7411f94f2 100644 --- a/vendor/cosmokit/tsconfig.json +++ b/vendor/cosmokit/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/group/package.json b/vendor/group/package.json index 86e5043a10..34a8f59ae2 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/group/tsconfig.json b/vendor/group/tsconfig.json index 137b02f7ac..e512d1d84c 100644 --- a/vendor/group/tsconfig.json +++ b/vendor/group/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 075968a3ba..28087d5fa8 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/hmr/tsconfig.json b/vendor/hmr/tsconfig.json index 033f83429f..8464912787 100644 --- a/vendor/hmr/tsconfig.json +++ b/vendor/hmr/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/include/package.json b/vendor/include/package.json index d42a1c0739..f9314d0c5e 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/include/tsconfig.json b/vendor/include/tsconfig.json index ae2c70f4bc..6fe5099b43 100644 --- a/vendor/include/tsconfig.json +++ b/vendor/include/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/loader/package.json b/vendor/loader/package.json index ee5dd088ff..fde6d01d27 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/loader/tsconfig.json b/vendor/loader/tsconfig.json index 84799662e0..2db62c7b63 100644 --- a/vendor/loader/tsconfig.json +++ b/vendor/loader/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noImplicitAny": false, "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index b4a4c9634e..8c0d8a0bda 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -5,10 +5,10 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/shared.d.ts", + "types": "lib/types/shared.d.ts", "exports": { ".": { - "types": "./lib/shared.d.ts", + "types": "./lib/types/shared.d.ts", "node": "./lib/index.js", "default": "./lib/browser.js" }, @@ -16,7 +16,10 @@ "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/browser.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/logger-console/src/browser.ts b/vendor/logger-console/src/browser.ts index bdbeaaf226..b45a15e228 100644 --- a/vendor/logger-console/src/browser.ts +++ b/vendor/logger-console/src/browser.ts @@ -1,8 +1,8 @@ import { Message } from 'cordis' -import { ConsoleExporter as Base } from './shared.js' +import { ConsoleExporter as Base } from './shared.ts' /** Re-export shared console exporter config and base implementation. */ -export * from './shared.js' +export * from './shared.ts' /** Browser console exporter that dispatches to native console methods. */ export class ConsoleExporter extends Base { diff --git a/vendor/logger-console/src/index.ts b/vendor/logger-console/src/index.ts index 87ab53d6dc..d46ac6413f 100644 --- a/vendor/logger-console/src/index.ts +++ b/vendor/logger-console/src/index.ts @@ -1,10 +1,10 @@ import { Formatter } from 'cordis' import { inspect } from 'node:util' import supportsColor from 'supports-color' -import { ConsoleExporter as Base } from './shared.js' +import { ConsoleExporter as Base } from './shared.ts' /** Re-export shared console exporter config and base implementation. */ -export * from './shared.js' +export * from './shared.ts' const inspectFormatter: Formatter = (value, target) => { return inspect(value, { colors: !!target.colors, depth: Infinity, compact: true, breakLength: Infinity }) diff --git a/vendor/logger-console/tsconfig.json b/vendor/logger-console/tsconfig.json index c632badb1b..cba4d151c7 100644 --- a/vendor/logger-console/tsconfig.json +++ b/vendor/logger-console/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vendor/logger-console/tsdown.config.ts b/vendor/logger-console/tsdown.config.ts index 3d9b213b6b..0df6d4bd0b 100644 --- a/vendor/logger-console/tsdown.config.ts +++ b/vendor/logger-console/tsdown.config.ts @@ -3,9 +3,10 @@ import { defineConfig } from 'tsdown' /** * logger-console ships two entries: the node exporter (index) and the * browser exporter (browser), selected via package.json `exports` - * conditions. They are built as two single-entry passes so the shared - * base class is inlined into each (matching upstream's published shape) - * instead of split into a hash-named chunk. + * conditions. The entries are JS emitted by tsc under lib/types and are + * bundled as two single-entry passes so the shared base class is inlined into + * each (matching upstream's published shape) instead of split into a hash-named + * chunk. */ const shared = { outDir: 'lib', @@ -18,6 +19,6 @@ const shared = { } as const export default defineConfig([ - { ...shared, entry: ['src/index.ts'] }, - { ...shared, entry: ['src/browser.ts'] }, + { ...shared, entry: ['lib/types/index.js'] }, + { ...shared, entry: ['lib/types/browser.js'] }, ]) diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 71f7744e5e..ec5791f3af 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -5,9 +5,12 @@ "private": true, "main": "lib/index.cjs", "module": "lib/index.mjs", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "files": [ - "lib", + "lib/index.mjs", + "lib/index.cjs", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/schemastery/tsconfig.json b/vendor/schemastery/tsconfig.json index 5797e8902b..b25fa05af7 100644 --- a/vendor/schemastery/tsconfig.json +++ b/vendor/schemastery/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "module": "preserve", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, diff --git a/vendor/schemastery/tsdown.config.ts b/vendor/schemastery/tsdown.config.ts index 43b4384a06..57f2f5f6c4 100644 --- a/vendor/schemastery/tsdown.config.ts +++ b/vendor/schemastery/tsdown.config.ts @@ -2,12 +2,12 @@ import { defineConfig } from 'tsdown' /** * schemastery has no `"type": "module"` and publishes dual-format output - * (package.json: main → lib/index.cjs, module → lib/index.mjs). Pin the - * extensions explicitly — the defaults for a CommonJS package would emit - * .mjs/.js instead. + * (package.json: main → lib/index.cjs, module → lib/index.mjs). The entry is + * the JS emitted by tsc under lib/types; pin the bundled extensions + * explicitly because the defaults for a CommonJS package would emit .mjs/.js. */ export default defineConfig({ - entry: ['src/index.ts'], + entry: ['lib/types/index.js'], outDir: 'lib', format: ['esm', 'cjs'], platform: 'node', diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 30bfe58280..07c41150e8 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -5,17 +5,19 @@ "private": true, "type": "module", "main": "lib/index.js", - "types": "lib/index.d.ts", + "types": "lib/types/index.d.ts", "exports": { ".": { - "types": "./lib/index.d.ts", + "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ - "lib", + "lib/index.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", "src" ], "author": "Shigma ", diff --git a/vendor/timer/tsconfig.json b/vendor/timer/tsconfig.json index 99c40177cd..843303e870 100644 --- a/vendor/timer/tsconfig.json +++ b/vendor/timer/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "rootDir": "src", - "outDir": "lib", + "outDir": "lib/types", "noUncheckedIndexedAccess": false, "exactOptionalPropertyTypes": false, "noImplicitOverride": false, diff --git a/vitest.config.ts b/vitest.config.ts index 55ec8477ed..fd37a67278 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,9 +5,9 @@ export default defineConfig({ // Vite ≥8 warns that this plugin can be replaced by the native (experimental) // `resolve.tsconfigPaths: true`. It cannot — keep the plugin. Tests run // unbuilt (see AGENTS.md): bare workspace names like `cordis` or - // `@deepseek-ai/dsh-llm` must resolve to src/, and the only place that - // mapping exists is the root tsconfig.json `paths` map inherited by - // tsconfig.test.json. The native option is a bare boolean: for each + // `@deepseek-ai/dsh-llm` must resolve to src/, and that mapping comes from + // the root tsconfig.json paths map. The native option is a bare boolean: + // for each // importing file it discovers the NEAREST tsconfig.json and applies that // file's own `paths`. Every workspace under packages/* and vendor/* has its // own tsconfig.json without `paths`, so native resolution maps nothing, @@ -17,7 +17,7 @@ export default defineConfig({ // 15 workspace tsconfigs — including vendor/* ones, which are pinned // upstream copies (vendor/README.md). The plugin's `projects` option // instead applies the one root map to every importer. - plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], coverage: { diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index f06806d763..63ccbb8a27 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -22,7 +22,7 @@ try { export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the root tsconfig paths map; the native option cannot do this. - plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['packages/*/*/tests/**/*.e2e.ts', 'examples/*/tests/**/*.e2e.ts'], // Real model calls: generous timeouts, and retries for transient flakes diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 6dc4144044..ecc8d911aa 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -24,7 +24,7 @@ if (process.env.DSH_SNAPSHOT === 'record') { export default defineConfig({ // Same resolution note as vitest.config.ts: bare workspace names resolve // through the root tsconfig paths map; the native option cannot do this. - plugins: [tsconfigPaths({ projects: ['./tsconfig.test.json'] })], + plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], test: { include: ['examples/*/tests/**/*.snapshot.ts'], // Each test boots a subprocess; give it room, and run files one at a time