From 5611f772b163169c71b29ba304e51547322e8cc8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:47:51 +0800 Subject: [PATCH 01/32] feat(scripts): generate the RFC index tables from the tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit docs/rfc/README.md's per-lifecycle tables are now generated between gen-rfc-index marker comments from each RFC's path (lifecycle/class), H1 title (optional 'RFC: ' prefix stripped), and filename date, sorted by date then filename — the one docs region every proposal wave edits and every concurrent branch conflicts on becomes derived state. scripts/rfc-index.ts owns the shared walker (closed lifecycle/class sets, structure rules, parseable-H1 requirement) and the renderer; gen-rfc-index.ts is the writer CLI; verify-rfc-classification.ts keeps the structure check and asserts the committed regions byte-match a fresh render (freshness subsumes the index-completeness check, since a generated-from-disk table is definitionally complete and correctly headed). A malformed H1 is a hard error in both directions, so the H1 is now load-bearing as the title source — the one nonconforming H1 (a status suffix duplicating the path) is normalized. Implements docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md (moved from proposed/ and amended to the shipped mechanics); the classification RFC's verify-only stance carries the supersession cross-link per implemented/AGENTS.md. --- AGENTS.md | 6 +- docs/rfc/README.md | 70 ++++---- .../process/2026-06-20-rfc-classification.md | 6 +- .../2026-07-04-generate-rfc-index-tables.md | 26 +++ .../2026-06-30-pre-tool-input-rewrite.md | 2 +- .../2026-07-04-generate-rfc-index-tables.md | 27 --- package.json | 1 + scripts/gen-rfc-index.ts | 30 ++++ scripts/rfc-index.ts | 141 ++++++++++++++++ scripts/verify-rfc-classification.ts | 158 +++--------------- 10 files changed, 269 insertions(+), 198 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md delete mode 100644 docs/rfc/proposed/process/2026-07-04-generate-rfc-index-tables.md create mode 100644 scripts/gen-rfc-index.ts create mode 100644 scripts/rfc-index.ts diff --git a/AGENTS.md b/AGENTS.md index ee5eb7d6f0..c5e7f5c701 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -188,9 +188,11 @@ pnpm run verify-doc-refs # assert every docs/*.md path cited in a packages|exam # TypeScript comment resolves (catches a moved/renamed doc) pnpm run verify-package-paths # assert every packages/ cited in Markdown or a # TypeScript comment resolves when it names a real (moved) package +pnpm run gen-rfc-index # regenerate the docs/rfc/README.md index tables from the + # RFC tree (marker-delimited; rows from path + H1 + filename date) 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) + # {lifecycle}/{class}/ folder and the generated README index + # regions are fresh (closed class set + index freshness) pnpm run verify-translation-pairing # assert the bilingual pairing contract # (docs/i18n/README.md): required docs have a complete pair # (foo.md + foo.zh.md + foo.i18n.yaml); every pair matches its diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 6124c94ffc..d53684bf6b 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -16,7 +16,7 @@ The date in the filename is when the topic was **first proposed** (per git histo ## Classification -Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/verify-rfc-classification.ts` rejects any folder outside the set and asserts this index lists every RFC under the heading matching its path. Adding a new class means amending that gate and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated. +Each RFC is filed under exactly one **class** — the kind of decision it records. The class is encoded in the path (the folder *is* the label, so a file's location declares its class) and the set is **closed**: `scripts/rfc-index.ts` owns the canonical set, `scripts/verify-rfc-classification.ts` rejects any folder outside it, and the index tables below are **generated** from the tree (`pnpm run gen-rfc-index` rewrites the marker-delimited regions from each RFC's path, H1 title, and filename date; the gate fails when they are stale). Adding a new class means amending that `const` and this section, not just dropping a new folder. See [the classification RFC](implemented/process/2026-06-20-rfc-classification.md) for why the taxonomy is path-encoded and gated, and [the index-generation RFC](implemented/process/2026-07-04-generate-rfc-index-tables.md) for why the tables are generated while this prose stays curated. | Class | What it covers | |---|---| @@ -37,11 +37,12 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r ## Proposed + ### Feature | Title | First proposed | |---|---| -| [Agent Client Protocol (ACP) support for external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | +| [Agent Client Protocol (ACP) support — drive the coding agent from external editors](proposed/feature/2026-06-14-acp-agent-client-protocol.md) | 2026-06-14 | | [Multiplex concurrent ACP sessions over one connection](proposed/feature/2026-06-14-acp-multi-session.md) | 2026-06-14 | | [Optional Code Mode — model writes TypeScript against an SDK of all tools](proposed/feature/2026-06-15-optional-code-mode.md) | 2026-06-15 | | [Pre-tool input rewrite — a consistent design](proposed/feature/2026-06-30-pre-tool-input-rewrite.md) | 2026-06-30 | @@ -51,17 +52,17 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | +| [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | | [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | -| [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | -| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | -| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | -| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | -| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | -| [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | -| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | +| [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | +| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | +| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | +| [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | +| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | +| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | +| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | ### Architecture @@ -74,47 +75,48 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| -| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 | | [API extractor reports](proposed/process/2026-06-11-api-extractor-reports.md) | 2026-06-11 | +| [Architectural conformance — dependency rules and the adapter kit](proposed/process/2026-06-11-architectural-conformance.md) | 2026-06-11 | | [Supply chain checks and vendor drift verification](proposed/process/2026-06-11-supply-chain-and-vendor-drift.md) | 2026-06-11 | | [Discover package inventories instead of maintaining static lists](proposed/process/2026-06-20-discover-package-inventory.md) | 2026-06-20 | -| [Generate the RFC index tables](proposed/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 | ### Testing | Title | First proposed | |---|---| -| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 | | [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 | +| [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 | | [Single-source the acp-agent replay config](proposed/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 | + ## Implemented + ### Feature | Title | First proposed | |---|---| | [Filesystem tool schemas — model-facing read/write/edit shapes](implemented/feature/2026-06-17-filesystem-tool-schemas.md) | 2026-06-17 | -| [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 | +| [Rich ACP bash rendering — the terminal card via the `_meta` convention](implemented/feature/2026-06-18-acp-terminal-and-tool-rendering.md) | 2026-06-18 | | [Compaction as a capability seam (abstract contract + basic backend)](implemented/feature/2026-06-18-compaction-capability-seam.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 | | [The `todo_write` tool — model task list as event-sourced session state](implemented/feature/2026-06-29-todo-write-tool.md) | 2026-06-29 | +| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | +| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | | [Interception seams — the typed-Decision surface a hook programs against](implemented/feature/2026-06-30-interception-seams.md) | 2026-06-30 | | [Subagent lifecycle enrichment — lastAssistantMessage (observe-only)](implemented/feature/2026-06-30-subagent-observe-enrich.md) | 2026-06-30 | -| [dsh-hook-protocol — the shared Claude Code / Codex hook wire-protocol core](implemented/feature/2026-06-30-hook-protocol-lib.md) | 2026-06-30 | -| [dsh-hooks-claude + dsh-hooks-codex — the Claude Code / Codex hook bridges](implemented/feature/2026-06-30-hook-bridges.md) | 2026-06-30 | ### Simplification | Title | First proposed | |---|---| | [Drop the mutable session summary](implemented/simplification/2026-06-19-drop-mutable-session-summary.md) | 2026-06-19 | -| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | +| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | | [Drop the unconsumed `llm/adapter-change` event](implemented/simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md) | 2026-06-20 | +| [Drop unconsumed assembled LLM convenience surfaces](implemented/simplification/2026-06-20-drop-unconsumed-llm-assembled-surfaces.md) | 2026-06-20 | | [Prune dead methods from the persistence seam](implemented/simplification/2026-06-20-prune-dead-seam-methods.md) | 2026-06-20 | | [Keep one public stop primitive](implemented/simplification/2026-06-20-public-agent-stop-surface.md) | 2026-06-20 | -| [Fold trace-only session facts into load-bearing events](implemented/simplification/2026-06-20-collapse-trace-only-session-events.md) | 2026-06-20 | | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | @@ -123,50 +125,51 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| -| [Microkernel: extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | -| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | | [Provider-neutral content-block vocabulary owned by dsh-llm](implemented/architecture/2026-06-11-content-block-vocabulary.md) | 2026-06-11 | | [Custom typed tool-schema DSL instead of schemastery](implemented/architecture/2026-06-11-custom-schema-dsl.md) | 2026-06-11 | -| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 | -| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | | [Dev-mode invariants over compile-time deep-readonly](implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md) | 2026-06-11 | +| [Event-sourced sessions with derived message history](implemented/architecture/2026-06-11-event-sourced-sessions.md) | 2026-06-11 | +| [Microkernel — extension via Cordis event taxonomy, one concrete loop](implemented/architecture/2026-06-11-microkernel-event-taxonomy.md) | 2026-06-11 | +| [Runtime arg validation at the model boundary](implemented/architecture/2026-06-11-runtime-arg-validation.md) | 2026-06-11 | | [Structured error taxonomy](implemented/architecture/2026-06-11-structured-error-taxonomy.md) | 2026-06-11 | +| [Tool schemas are part of the system-prompt assembly](implemented/architecture/2026-06-11-tool-schemas-in-prompt-assembly.md) | 2026-06-11 | | [Capability seams — interface / implementation / consumer split](implemented/architecture/2026-06-13-capability-seams.md) | 2026-06-13 | | [Two LLM adapters as a design-verification twin](implemented/architecture/2026-06-13-twin-llm-adapters.md) | 2026-06-13 | -| [Session persistence as an abstract service over `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | +| [Session persistence as an abstract service over the existing `SessionEvent`](implemented/architecture/2026-06-14-session-persistence.md) | 2026-06-14 | | [Every session event is enclosed in a turn](implemented/architecture/2026-06-15-turn-enclosure-invariant.md) | 2026-06-15 | | [Filesystem capability seam — ctx.fs, local backend, and model-facing filesystem tools](implemented/architecture/2026-06-17-filesystem-capability-seam.md) | 2026-06-17 | -| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Agent lifecycle and ownership seams](implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-seams.md) | 2026-06-18 | | [Session surface — a linked list over the event log for LLM message derivation](implemented/architecture/2026-06-18-session-surface.md) | 2026-06-18 | -| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Shared persistence write coordinator](implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md) | 2026-06-18 | | [Branded IDs everywhere they belong](implemented/architecture/2026-06-20-branded-ids.md) | 2026-06-20 | | [Extract example apps into packages](implemented/architecture/2026-06-20-extract-example-app-packages.md) | 2026-06-20 | -| [Web capability seam — provider registry and model-facing web tools](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | +| [Reorganize packages into a modular hierarchy](implemented/architecture/2026-06-20-package-hierarchy.md) | 2026-06-20 | +| [Web capability seam - stable tools over multiple providers](implemented/architecture/2026-06-24-web-capability-seam.md) | 2026-06-24 | | [Make `dsh-fs-policy` an event-gate plugin, not a method interface](implemented/architecture/2026-06-26-file-context-as-event-gate.md) | 2026-06-26 | -| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 | | [stdin + extra env on the bash seam](implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md) | 2026-06-30 | +| [Event-domain semantics — session is the fact log, agent is the live surface](implemented/architecture/2026-06-30-event-domain-semantics.md) | 2026-06-30 | | [Resolve filesystem paths against the caller's session cwd](implemented/architecture/2026-07-02-fs-per-session-cwd.md) | 2026-07-02 | -| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 | | [Result-time applied-hunk diffs for file mutations](implemented/architecture/2026-07-02-result-time-applied-hunk-diffs.md) | 2026-07-02 | +| [Tagged render-intent union for tool-call presentation](implemented/architecture/2026-07-02-tool-render-intent-union.md) | 2026-07-02 | | [Add direct directory listing to the filesystem seam](implemented/architecture/2026-07-03-filesystem-directory-listing-seam.md) | 2026-07-03 | ### Process | Title | First proposed | |---|---| -| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.md) | 2026-06-11 | +| [Doc-sync enforcement](implemented/process/2026-06-11-doc-sync-enforcement.md) | 2026-06-11 | | [Mechanical quality gates over prose guidelines](implemented/process/2026-06-11-quality-gates.md) | 2026-06-11 | | [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 | +| [Vendor Cordis as source, not npm dependencies](implemented/process/2026-06-11-vendor-cordis-as-source.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 | | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | -| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | | [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 | +| [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | +| [Generate the RFC index tables](implemented/process/2026-07-04-generate-rfc-index-tables.md) | 2026-07-04 | ### Testing @@ -176,13 +179,15 @@ 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 | +| [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | | [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 | + ## Rejected + ### Simplification | Title | First proposed | @@ -206,3 +211,4 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Deep-readonly public surfaces](rejected/architecture/2026-06-11-immutable-public-surfaces.md) | 2026-06-11 | | [Make the shared example base providerless](rejected/architecture/2026-06-20-providerless-example-base.md) | 2026-06-20 | + diff --git a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md index d3fc95399b..21d4129fac 100644 --- a/docs/rfc/implemented/process/2026-06-20-rfc-classification.md +++ b/docs/rfc/implemented/process/2026-06-20-rfc-classification.md @@ -29,18 +29,18 @@ The `architecture` / `process` line: **architecture** is about the source we shi Both are `doc-sync` members, in the `verify-md-wrap` style (tsx ESM, verify-don't-generate, exit non-zero on the first violation): -- **`scripts/verify-rfc-classification.ts`** — the closed set and index completeness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that `README.md` lists every RFC exactly once under the `###` heading matching its `{lifecycle}/{class}` path. The canonical class set lives as a `const` in this script — the machine source of truth — and [the index](../../README.md) documents it in prose; the two are kept in sync by hand (the README's completeness is gated, its class *descriptions* are not). This mirrors `verify-event-taxonomy`, which checks a doc table against source. +- **`scripts/verify-rfc-classification.ts`** — the closed set and index freshness. It asserts every file under a lifecycle folder lives in a class folder from the canonical set (a loose `.md` at a lifecycle root, or an unknown class folder, fails), and that the README's marker-delimited index regions byte-match a fresh render from the tree (see [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md)). The canonical class set lives as a `const` in `scripts/rfc-index.ts` — the machine source of truth shared with the generator — and [the index](../../README.md) documents it in prose; the README's class *descriptions* stay hand-written, its tables are generated. - **`scripts/verify-doc-refs.ts`** — source comments that cite docs. RFC paths are referenced not only from Markdown but from TypeScript doc comments (root-relative prose like `docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md`). `verify-md-links` never saw those, so the reorg could have silently orphaned them. This gate scans repo-authored `.ts` under `packages/**` and `examples/**` (excluding built `lib/` and `vendor/`) for `docs/….md` tokens, resolves each root-relative, and asserts it exists. It requires the `.md` extension so extensionless prose (`docs/postmortem/0001`, `docs/architecture.md § plugin checklist`) is left alone. ### Rejected alternatives - **A `Classification:` prose line** in each file (next to `Status:`), parsed by the gate. Workable, but it duplicates into the file a fact the path can already carry, and a line can disagree with its folder. Path-encoding makes the label and its storage the same thing — there is nothing to keep in sync. - **A `refactor` class.** It overlaps `simplification` almost entirely; the only discriminator anyone reached for was "does observable behavior change?", which `simplification` already encodes (it does not). One class, not two. -- **Auto-generating the README index** from the filesystem. Rejected to keep the index hand-written like every other doc here; the completeness gate gives the same drift-protection without generated Markdown in a curated file. +- **Auto-generating the README index** from the filesystem. Rejected here to keep the index hand-written; superseded by [generate the RFC index tables](2026-07-04-generate-rfc-index-tables.md) once stacked proposal waves made the hand-written tables the repo's most conflict-prone docs region — the tables are now generated between markers while the surrounding prose stays curated. ## Consequences - Every RFC now sits under a class folder, and the index groups by class within each lifecycle. A reader scans one heading to see all simplifications, or all testing decisions. - Two more fast tsx scripts in the `doc-sync` chain; no new dependency (the mdast/GFM stack was already present for `verify-md-wrap`/`verify-md-links`). -- Adding a class is a deliberate act: amend the `const` in `verify-rfc-classification.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. +- Adding a class is a deliberate act: amend the `const` in `scripts/rfc-index.ts` and the [Classification section](../../README.md#classification), not just `mkdir` a folder. The gate rejects an unknown folder, so an ad-hoc class can't slip in. - Source-comment doc references are now gated too — a moved or renamed doc that a `.ts` comment cites fails the pre-push hook, closing a drift class `verify-md-links` structurally could not see. diff --git a/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md new file mode 100644 index 0000000000..8039dd50f4 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md @@ -0,0 +1,26 @@ +# RFC: Generate the RFC index tables + +Status: implemented + +## Problem + +`docs/rfc/README.md`'s per-lifecycle/per-class tables list facts that are fully derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. A hand-maintained copy of those facts is also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](2026-06-20-rfc-classification.md) originally kept the index hand-written for curation's sake — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do. + +## Decision + +Keep the curated prose; generate the tables. [`scripts/rfc-index.ts`](../../../../scripts/rfc-index.ts) is the shared source of truth — the tree walker (owning the closed lifecycle/class sets and the structure rules, including a parseable-H1 requirement) and the renderer (rows from H1 title with any `RFC: ` prefix stripped, plus the filename date, sorted by date then filename, grouped as `### {Class}` sections in canonical class order). Two thin consumers share it: + +- [`scripts/gen-rfc-index.ts`](../../../../scripts/gen-rfc-index.ts) (`pnpm run gen-rfc-index`) rewrites the three marker-delimited regions in the README (`` … `end`), one per `## {Lifecycle}` section, leaving everything outside the markers untouched. +- [`scripts/verify-rfc-classification.ts`](../../../../scripts/verify-rfc-classification.ts) (a `doc-sync` member) checks structure and asserts the committed regions byte-match a fresh render — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern. Freshness subsumes the index-completeness check: a generated-from-disk table is definitionally complete and correctly headed. + +Adding, moving, or deleting an RFC means editing only the RFC file and running the generator; the classification RFC's rejected-alternatives record carries the supersession cross-link. + +## Why not the verifier-only model? + +It catches mistakes but still makes every proposal edit a shared hotspot, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](../../proposed/process/2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts. + +## Consequences + +- The generated regions are explicit: marker comments make script ownership obvious to reviewers, and the generator refuses to run on a structurally invalid tree. +- A malformed or missing H1 is a hard error in both the generator and the gate — the H1 is now load-bearing as the index title source. +- Concurrent RFC branches resolve index conflicts by rerunning the generator, never by hand-merging rows. diff --git a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md index 3e731af4b9..a3bb6d4718 100644 --- a/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md +++ b/docs/rfc/proposed/feature/2026-06-30-pre-tool-input-rewrite.md @@ -1,4 +1,4 @@ -# RFC: Pre-tool input rewrite — a consistent design (proposed) +# RFC: Pre-tool input rewrite — a consistent design Status: proposed (2026-06-30) diff --git a/docs/rfc/proposed/process/2026-07-04-generate-rfc-index-tables.md b/docs/rfc/proposed/process/2026-07-04-generate-rfc-index-tables.md deleted file mode 100644 index b3c88c47ad..0000000000 --- a/docs/rfc/proposed/process/2026-07-04-generate-rfc-index-tables.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Generate the RFC index tables - -Status: proposed - -## Problem - -`docs/rfc/README.md`'s per-lifecycle/per-class tables are hand-maintained even though every fact in them is derivable: an RFC's path encodes lifecycle and class, its filename encodes the first-proposed date, and its H1 carries the title. `scripts/verify-rfc-classification.ts` already walks the tree and cross-checks the index — the expensive parsing exists; it reports instead of writing. - -The tables are also the repo's highest-contention docs hotspot: every proposal wave appends rows to the same few lines, so concurrent RFC branches conflict precisely there while agreeing everywhere else, and each conflict is resolved by hand-merging rows whose content the filesystem already knows. [The classification RFC](../../implemented/process/2026-06-20-rfc-classification.md) records rejecting auto-generation to keep the file curated — but the curated part of the README is the prose, and the prose never conflicts; only the mechanical tables do. - -## Proposal - -Keep the curated prose; generate the tables. Add a `gen-rfc-index` mode (a `--write` flag on `verify-rfc-classification.ts`, or a sibling script sharing its walker) that scans the RFC tree, reads each H1, derives the date from the filename, and rewrites the table rows under stable generated markers per `## {Lifecycle}` / `### {Class}` section; `verify-rfc-classification` asserts freshness — the `gen-cordis-catalog`/`verify-cordis-catalog` pattern. The class and lifecycle sets stay closed in the script. The implementing PR amends the classification RFC's rejected-alternatives record per [implemented/AGENTS.md](../../implemented/AGENTS.md), since this supersedes that recorded choice. - -## Why not keep the verifier-only model? - -It catches mistakes but still makes every proposal edit a shared hotspot, and a failed verifier is strictly more annoying than a generator for a purely mechanical row: the author has already named and placed the file; the index copy adds no information. This is the same hand-list-versus-derivation judgment the [package-inventory proposal](2026-06-20-discover-package-inventory.md) applies to tsconfig references and knip stanzas — applied to the one list that demonstrably conflicts. - -## Acceptance criteria - -- `pnpm run gen-rfc-index` (or the chosen spelling) rewrites only the generated table regions; `verify-rfc-classification` fails when they are stale and passes after regeneration. -- Adding, moving, or deleting an RFC requires editing only the RFC file itself; the rows are produced from path + H1 + filename date. -- The prose outside the generated markers is untouched by the generator; `pnpm run doc-sync` passes. - -## Risks - -Generated regions inside a curated file need explicit markers so ownership is obvious to reviewers. Reading H1s makes a malformed header a generator error — useful pressure, and it should fail clearly. This supersedes an implemented process decision; amending that RFC's record is part of the change, not optional. diff --git a/package.json b/package.json index 893b0059f6..a9d9545b6c 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", + "gen-rfc-index": "tsx scripts/gen-rfc-index.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", "verify-tool-catalog": "tsx scripts/gen-tool-catalog.ts --check", diff --git a/scripts/gen-rfc-index.ts b/scripts/gen-rfc-index.ts new file mode 100644 index 0000000000..ad7aeb6588 --- /dev/null +++ b/scripts/gen-rfc-index.ts @@ -0,0 +1,30 @@ +/** + * Regenerate the RFC index tables in `docs/rfc/README.md` from the RFC tree + * (see [rfc-index.ts](./rfc-index.ts) for the layout contract and rendering + * rules). Rewrites ONLY the marker-delimited regions; the curated prose is + * untouched. Freshness is asserted by `verify-rfc-classification.ts` (a + * `doc-sync` member), so a stale committed index fails CI. + * + * Run: `pnpm run gen-rfc-index`. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts' + +const { rfcs, errors } = walkRfcTree() +if (errors.length > 0) { + console.error('gen-rfc-index: refusing to generate from a structurally invalid tree:') + for (const e of errors) console.error(` ${e}`) + process.exit(1) +} + +const readmePath = resolve(rfcRoot, 'README.md') +const readme = readFileSync(readmePath, 'utf8') +const next = spliceReadme(readme, rfcs) +if (next === readme) { + console.log(`gen-rfc-index: docs/rfc/README.md is up to date (${rfcs.length} RFCs).`) +} else { + writeFileSync(readmePath, next) + console.log(`gen-rfc-index: docs/rfc/README.md regenerated (${rfcs.length} RFCs).`) +} diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts new file mode 100644 index 0000000000..62d9cb0fcf --- /dev/null +++ b/scripts/rfc-index.ts @@ -0,0 +1,141 @@ +/** + * Shared source of truth for the RFC index: the tree walker (structure rules) + * and the README table renderer. `gen-rfc-index.ts` writes the generated + * regions; `verify-rfc-classification.ts` checks structure and asserts the + * committed regions are fresh. Pure module — no side effects on import. + * + * The layout contract ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)): + * every RFC lives at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`, the + * folder IS the label, and both sets are CLOSED — extending either means + * amending this module AND the README's Classification prose. + * + * The README's per-lifecycle tables are GENERATED between marker comments + * (`` … `end`): section headings and + * rows are derived from each RFC's path (lifecycle/class), H1 (title, with an + * optional `RFC: ` prefix stripped), and filename date, sorted by date then + * filename. Prose outside the markers is curated by hand and never touched. + */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { globSync } from 'node:fs' + +export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc') + +/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */ +export const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const + +/** + * The closed set of RFC classes (nested folder under each lifecycle). Adding a + * class is a deliberate act: extend this list AND the README's Classification + * section. The gate rejects any folder not listed here. + */ +export const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const + +/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */ +const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md']) + +/** Title-case a class/lifecycle folder name for a README heading. */ +export const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) + +/** One RFC file, as discovered by the walker. */ +export interface Rfc { + lifecycle: string + cls: string + base: string + /** Path relative to docs/rfc — the README link target. */ + rel: string + /** H1 text with any `RFC: ` prefix stripped — the README row title. */ + title: string + /** `yyyy-mm-dd` from the filename — the "First proposed" column. */ + date: string +} + +/** + * Walk the RFC tree, enforcing the structure rules. Returns every valid RFC + * plus one error string per violation (unknown class folder, bad depth, bad + * filename, missing/malformed H1). Callers treat a non-empty error list as + * fatal — the index is only generated from a structurally valid tree. + */ +export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } { + const rfcs: Rfc[] = [] + const errors: string[] = [] + for (const lifecycle of LIFECYCLES) { + for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) { + const segs = match.split('/') + // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). + if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue + // A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC, + // indexed via its English filename; the pairing gate owns its consistency. + if (match.endsWith('.zh.md')) continue + const cls = segs[1] + const base = segs[2] + if (segs.length !== 3 || cls === undefined || base === undefined) { + errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`) + continue + } + if (!(CLASSES as readonly string[]).includes(cls)) { + errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`) + continue + } + if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) { + errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`) + continue + } + const firstLine = readFileSync(resolve(rfcRoot, match), 'utf8').split('\n', 1)[0] ?? '' + const h1 = /^#\s+(?:RFC:\s+)?(.+?)\s*$/.exec(firstLine) + if (!h1?.[1]) { + errors.push(`title: ${match} — first line must be an H1 (\`# RFC: \` or \`# <title>\`), got: ${JSON.stringify(firstLine)}`) + continue + } + rfcs.push({ lifecycle, cls, base, rel: match, title: h1[1], date: base.slice(0, 10) }) + } + } + return { rfcs, errors } +} + +/** The begin/end marker lines that delimit one lifecycle's generated region. */ +export const markers = (lifecycle: string): { begin: string; end: string } => ({ + begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`, + end: `<!-- gen-rfc-index:end ${lifecycle} -->`, +}) + +/** + * Render one lifecycle's generated region body: a `### {Class}` heading plus a + * `| Title | First proposed |` table for every non-empty class, in CLASSES + * order, rows sorted by date then filename. + */ +export function renderLifecycle(rfcs: Rfc[], lifecycle: string): string { + const sections: string[] = [] + for (const cls of CLASSES) { + const rows = rfcs + .filter(r => r.lifecycle === lifecycle && r.cls === cls) + .sort((a, b) => a.date.localeCompare(b.date) || a.base.localeCompare(b.base)) + if (rows.length === 0) continue + const table = rows.map(r => `| [${r.title}](${r.rel}) | ${r.date} |`).join('\n') + sections.push(`### ${heading(cls)}\n\n| Title | First proposed |\n|---|---|\n${table}`) + } + return sections.join('\n\n') +} + +/** + * Splice freshly rendered regions into the README text. Throws when a marker + * pair is missing, duplicated, or out of order — the markers are part of the + * curated prose and must exist exactly once per lifecycle. + */ +export function spliceReadme(readme: string, rfcs: Rfc[]): string { + let out = readme + for (const lifecycle of LIFECYCLES) { + const { begin, end } = markers(lifecycle) + const beginAt = out.indexOf(begin) + const endAt = out.indexOf(end) + if (beginAt === -1 || endAt === -1 || endAt < beginAt) { + throw new Error(`README.md is missing the ${JSON.stringify(begin)} … ${JSON.stringify(end)} marker pair`) + } + if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) { + throw new Error(`README.md has a duplicated ${lifecycle} index marker`) + } + out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}` + } + return out +} diff --git a/scripts/verify-rfc-classification.ts b/scripts/verify-rfc-classification.ts index 4ff4264731..b6f1ed2eb5 100644 --- a/scripts/verify-rfc-classification.ts +++ b/scripts/verify-rfc-classification.ts @@ -1,158 +1,50 @@ /** * Doc-sync gate: enforce the RFC classification scheme - * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)). + * ([the classification RFC](../docs/rfc/implemented/process/2026-06-20-rfc-classification.md)) + * and the freshness of the generated index tables + * ([the index-generation RFC](../docs/rfc/implemented/process/2026-07-04-generate-rfc-index-tables.md)). * Every RFC is filed at `docs/rfc/{lifecycle}/{class}/yyyy-mm-dd-topic.md`; the * folder IS the label. This gate is the machine source of truth for the closed * class set and keeps the README index honest. * - * Two checks: + * Two checks (both against [rfc-index.ts](./rfc-index.ts), the shared walker + * and renderer): * * 1. STRUCTURE — every `.md` under a lifecycle folder lives in a class folder - * from CLASSES, named `yyyy-mm-dd-*.md`. A loose `.md` directly under a - * lifecycle root (other than the README/AGENTS allowlist) fails; an unknown - * class folder fails; a stray file at an unexpected depth fails. This is what - * makes the set CLOSED: a new class folder can't appear without amending - * CLASSES here (and the README's Classification section, per the RFC). + * from CLASSES, is named `yyyy-mm-dd-*.md`, and opens with a parseable H1. + * A loose `.md` directly under a lifecycle root (other than the + * README/AGENTS allowlist) fails; an unknown class folder fails; a stray + * file at an unexpected depth fails. This is what makes the set CLOSED: a + * new class folder can't appear without amending CLASSES (and the README's + * Classification section, per the RFC). * - * 2. COMPLETENESS — `docs/rfc/README.md` lists every RFC exactly once, under the - * `### {Class}` heading inside the `## {Lifecycle}` section that matches the - * file's path. A missing entry, a duplicate, or an entry under the wrong - * heading fails. This mirrors `verify-event-taxonomy`: a curated doc table - * checked against the on-disk source of truth, so the index can't drift. - * - * The class DESCRIPTIONS in the README prose are not checked (they are - * explanatory text); only the per-class index tables are. This is checker, not - * fixer: it reports and never rewrites. + * 2. FRESHNESS — the marker-delimited index regions in `docs/rfc/README.md` + * byte-match a fresh render from the tree, so every RFC is listed exactly + * once, under the heading matching its path, with its H1 title and filename + * date. The fix for a stale index is `pnpm run gen-rfc-index`, never a hand + * edit. This is checker, not fixer: it reports and never rewrites. * * Run: `tsx scripts/verify-rfc-classification.ts`. */ import { readFileSync } from 'node:fs' -import { relative, resolve } from 'node:path' -import { glob } from 'node:fs/promises' +import { resolve } from 'node:path' +import { rfcRoot, spliceReadme, walkRfcTree } from './rfc-index.ts' -const root = resolve(import.meta.dirname, '..') -const rfcRoot = resolve(root, 'docs/rfc') +const { rfcs, errors } = walkRfcTree() -/** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */ -const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const - -/** - * The closed set of RFC classes (nested folder under each lifecycle). Adding a - * class is a deliberate act: extend this list AND the README's Classification - * section. The gate rejects any folder not listed here. - */ -const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const - -/** Non-RFC Markdown allowed to sit directly at a lifecycle root. */ -const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md']) - -/** Title-case a class/lifecycle folder name for README heading comparison. */ -const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) - -const errors: string[] = [] - -// --- Check 1: structure ----------------------------------------------------- -// Every Markdown file anywhere under a lifecycle folder, at any depth. -interface Rfc { - lifecycle: string - cls: string - base: string - /** Path relative to docs/rfc, for the README link check. */ - rel: string -} -const rfcs: Rfc[] = [] - -for (const lifecycle of LIFECYCLES) { - for await (const match of glob(`${lifecycle}/**/*.md`, { cwd: rfcRoot })) { - const segs = match.split('/') - // Allowlisted file directly at the lifecycle root (e.g. implemented/AGENTS.md). - if (segs.length === 2 && ROOT_ALLOWLIST.has(segs[1] ?? '')) continue - // A Chinese counterpart (foo.zh.md, docs/i18n/README.md) is the SAME RFC, - // indexed via its English filename; the pairing gate owns its consistency. - if (match.endsWith('.zh.md')) continue - const cls = segs[1] - const base = segs[2] - if (segs.length !== 3 || cls === undefined || base === undefined) { - errors.push(`structure: ${match} — expected {lifecycle}/{class}/file.md (got depth ${segs.length})`) - continue - } - if (!(CLASSES as readonly string[]).includes(cls)) { - errors.push(`structure: ${match} — unknown class folder "${cls}" (allowed: ${CLASSES.join(', ')})`) - continue - } - if (!/^\d{4}-\d{2}-\d{2}-.+\.md$/.test(base)) { - errors.push(`structure: ${match} — filename must be yyyy-mm-dd-topic.md`) - continue - } - rfcs.push({ lifecycle, cls, base, rel: match }) - } -} - -// --- Check 2: README completeness ------------------------------------------- -// Parse the index into (lifecycle, class) -> set of linked rel paths, by -// tracking the current `## {Lifecycle}` and `### {Class}` headings and reading -// every `](path)` link target underneath. A link target is normalized to its -// path relative to docs/rfc. const readmePath = resolve(rfcRoot, 'README.md') const readme = readFileSync(readmePath, 'utf8') -const lifecycleByHeading = new Map(LIFECYCLES.map((l): [string, string] => [heading(l), l])) -const classByHeading = new Map(CLASSES.map((c): [string, string] => [heading(c), c])) - -/** README-listed RFC link targets, keyed `lifecycle/class` -> set of rel paths. */ -const listed = new Map<string, Set<string>>() -let curLifecycle: string | null = null -let curClass: string | null = null - -for (const line of readme.split('\n')) { - const h2 = /^##\s+(.+?)\s*$/.exec(line) - if (h2?.[1] !== undefined) { - curLifecycle = lifecycleByHeading.get(h2[1].trim()) ?? null - curClass = null - continue - } - const h3 = /^###\s+(.+?)\s*$/.exec(line) - if (h3?.[1] !== undefined) { - curClass = classByHeading.get(h3[1].trim()) ?? null - continue - } - if (!curLifecycle || !curClass) continue - // Collect every relative .md link target on this line. - for (const m of line.matchAll(/\]\(([^)]+\.md)[^)]*\)/g)) { - const target = m[1] - if (target === undefined) continue - // README links are relative to docs/rfc; normalize and key by location. - const rel = relative(rfcRoot, resolve(rfcRoot, target)) - const key = `${curLifecycle}/${curClass}` - const set = listed.get(key) ?? new Set<string>() - set.add(rel) - listed.set(key, set) - } -} - -// Every on-disk RFC must be listed under the heading matching its path. -const seenOnDisk = new Set<string>() -for (const rfc of rfcs) { - seenOnDisk.add(rfc.rel) - const key = `${rfc.lifecycle}/${rfc.cls}` - if (!listed.get(key)?.has(rfc.rel)) { - errors.push( - `index: ${rfc.rel} is not listed in README under "## ${heading(rfc.lifecycle)}" → "### ${heading(rfc.cls)}"`, - ) - } -} - -// Every README entry must point at a real RFC under that same heading (catches a -// misfiled or stale row). -for (const [key, targets] of listed) { - for (const rel of targets) { - if (!seenOnDisk.has(rel)) { - errors.push(`index: README lists "${rel}" under "${key}", but no such RFC exists`) +if (errors.length === 0) { + try { + if (spliceReadme(readme, rfcs) !== readme) { + errors.push('index: docs/rfc/README.md is stale — run `pnpm run gen-rfc-index` and commit the result') } + } catch (error) { + errors.push(`index: ${error instanceof Error ? error.message : String(error)}`) } } -// --- Report ----------------------------------------------------------------- if (errors.length === 0) { console.log(`verify-rfc-classification: ${rfcs.length} RFC(s) checked, structure and index consistent.`) process.exit(0) From 5477d5fb5b82e3f210f5c605b3bb0c18d0daeead Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 14:52:09 +0800 Subject: [PATCH 02/32] fix(scripts): keep rfc-index module internals unexported MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit knip: the walker/renderer internals (the closed sets, the heading and marker helpers, the per-lifecycle renderer) have no importer beyond this module — the public surface is rfcRoot, walkRfcTree, and spliceReadme, the three names the generator CLI and the gate consume. --- scripts/rfc-index.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts index 62d9cb0fcf..d9a4538b38 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -23,20 +23,20 @@ import { globSync } from 'node:fs' export const rfcRoot = resolve(import.meta.dirname, '../docs/rfc') /** The closed set of RFC lifecycles (top-level folders under docs/rfc/). */ -export const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const +const LIFECYCLES = ['proposed', 'implemented', 'rejected'] as const /** * The closed set of RFC classes (nested folder under each lifecycle). Adding a * class is a deliberate act: extend this list AND the README's Classification * section. The gate rejects any folder not listed here. */ -export const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const +const CLASSES = ['feature', 'bug-fix', 'simplification', 'architecture', 'process', 'testing'] as const /** Non-RFC Markdown allowed to sit directly at a lifecycle root. */ const ROOT_ALLOWLIST = new Set(['AGENTS.md', 'CLAUDE.md']) /** Title-case a class/lifecycle folder name for a README heading. */ -export const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) +const heading = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1) /** One RFC file, as discovered by the walker. */ export interface Rfc { @@ -95,7 +95,7 @@ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } { } /** The begin/end marker lines that delimit one lifecycle's generated region. */ -export const markers = (lifecycle: string): { begin: string; end: string } => ({ +const markers = (lifecycle: string): { begin: string; end: string } => ({ begin: `<!-- gen-rfc-index:begin ${lifecycle} -->`, end: `<!-- gen-rfc-index:end ${lifecycle} -->`, }) @@ -105,7 +105,7 @@ export const markers = (lifecycle: string): { begin: string; end: string } => ({ * `| Title | First proposed |` table for every non-empty class, in CLASSES * order, rows sorted by date then filename. */ -export function renderLifecycle(rfcs: Rfc[], lifecycle: string): string { +function renderLifecycle(rfcs: Rfc[], lifecycle: string): string { const sections: string[] = [] for (const cls of CLASSES) { const rows = rfcs From 226a8b5e4cc8872c9c438515498d376550440c77 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:10:22 +0800 Subject: [PATCH 03/32] fix(scripts): enforce the invariants the index gate claims Codex review found three gaps between the documented contract and what the gate enforced, each proven by a failing probe before this fix: - a generated region must sit directly under its own '## {Lifecycle}' heading (the last H2 above the begin marker), so a drifted heading can no longer leave the tables filed under the wrong section; - the lifecycle set is closed like the class set: an unknown directory under docs/rfc/ is a structure error, not an invisible subtree; - an index-shaped table row linking into a lifecycle folder OUTSIDE the generated regions is an error (prose links to RFCs stay legal), so 'listed exactly once' cannot be violated by a hand-added row. --- scripts/rfc-index.ts | 44 ++++++++++++++++++++++++++++++++++++++------ 1 file changed, 38 insertions(+), 6 deletions(-) diff --git a/scripts/rfc-index.ts b/scripts/rfc-index.ts index d9a4538b38..e14eea5c59 100644 --- a/scripts/rfc-index.ts +++ b/scripts/rfc-index.ts @@ -16,7 +16,7 @@ * filename. Prose outside the markers is curated by hand and never touched. */ -import { readFileSync } from 'node:fs' +import { readFileSync, readdirSync } from 'node:fs' import { resolve } from 'node:path' import { globSync } from 'node:fs' @@ -53,13 +53,20 @@ export interface Rfc { /** * Walk the RFC tree, enforcing the structure rules. Returns every valid RFC - * plus one error string per violation (unknown class folder, bad depth, bad - * filename, missing/malformed H1). Callers treat a non-empty error list as - * fatal — the index is only generated from a structurally valid tree. + * plus one error string per violation (unknown lifecycle or class folder, bad + * depth, bad filename, missing/malformed H1). Callers treat a non-empty error + * list as fatal — the index is only generated from a structurally valid tree. */ export function walkRfcTree(): { rfcs: Rfc[]; errors: string[] } { const rfcs: Rfc[] = [] const errors: string[] = [] + // The lifecycle set is closed too: any directory under docs/rfc/ that is not + // a known lifecycle would otherwise hold RFCs invisible to the walk below. + for (const entry of readdirSync(rfcRoot, { withFileTypes: true })) { + if (entry.isDirectory() && !(LIFECYCLES as readonly string[]).includes(entry.name)) { + errors.push(`structure: ${entry.name}/ — unknown lifecycle folder (allowed: ${LIFECYCLES.join(', ')})`) + } + } for (const lifecycle of LIFECYCLES) { for (const match of globSync(`${lifecycle}/**/*.md`, { cwd: rfcRoot }).sort()) { const segs = match.split('/') @@ -120,11 +127,16 @@ function renderLifecycle(rfcs: Rfc[], lifecycle: string): string { /** * Splice freshly rendered regions into the README text. Throws when a marker - * pair is missing, duplicated, or out of order — the markers are part of the - * curated prose and must exist exactly once per lifecycle. + * pair is missing, duplicated, or out of order, when a region does not sit + * under its own `## {Lifecycle}` heading, or when an index-shaped table row + * (a `| [title](lifecycle/…)` line) appears OUTSIDE the generated regions — + * the markers are part of the curated prose, the heading above each region is + * the one its lifecycle names, and index rows live only inside the regions + * (prose links to RFCs remain fine anywhere). */ export function spliceReadme(readme: string, rfcs: Rfc[]): string { let out = readme + const regions: Array<{ from: number; to: number }> = [] for (const lifecycle of LIFECYCLES) { const { begin, end } = markers(lifecycle) const beginAt = out.indexOf(begin) @@ -135,7 +147,27 @@ export function spliceReadme(readme: string, rfcs: Rfc[]): string { if (out.indexOf(begin, beginAt + 1) !== -1 || out.indexOf(end, endAt + 1) !== -1) { throw new Error(`README.md has a duplicated ${lifecycle} index marker`) } + // The region must sit directly under its own lifecycle heading: the last + // H2 above the begin marker is `## {Heading(lifecycle)}`, or the heading + // itself has drifted while the generated table stayed put. + const before = out.slice(0, beginAt) + const lastH2 = [...before.matchAll(/^##\s+(.+?)\s*$/gm)].at(-1)?.[1] + if (lastH2 !== heading(lifecycle)) { + throw new Error(`README.md: the ${lifecycle} index region is not under a "## ${heading(lifecycle)}" heading (found "## ${lastH2 ?? '<none>'}")`) + } out = `${out.slice(0, beginAt + begin.length)}\n${renderLifecycle(rfcs, lifecycle)}\n${out.slice(endAt)}` + regions.push({ from: out.indexOf(begin), to: out.indexOf(markers(lifecycle).end) + markers(lifecycle).end.length }) + } + // Index rows are generated state: a table row linking into a lifecycle + // folder anywhere OUTSIDE the regions is a hand-added index entry the + // generator would never reconcile. + let offset = 0 + for (const line of out.split('\n')) { + const inRegion = regions.some(r => offset >= r.from && offset < r.to) + if (!inRegion && /^\|\s*\[[^\]]+\]\((?:proposed|implemented|rejected)\//.test(line)) { + throw new Error(`README.md: index-shaped row outside the generated regions: ${JSON.stringify(line.slice(0, 80))}`) + } + offset += line.length + 1 } return out } From c6d2eeea6b1b510ca9a9ec66dd3f1722eae6acd3 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:36:40 +0800 Subject: [PATCH 04/32] refactor(events): remove the agent/steering mirror emit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit agent/steering was the last transient mirror of a durable session event: drainSteering appended the durable steering/message {turn, content, source} and emitted the identical fact one line later. Zero production listeners existed — every steering consumer (hook bridges, goldens, deriveMessages) reads the durable event — and the one regression test subscriber asserted a fact the log already records. Remove the declaration (dsh-agent types + JSDoc list + README row), the emit in drainSteering (its ctx parameter goes too), and the emit line in the loop-pseudocode blocks (loop.ts module doc, architecture.md); the cordis catalog is regenerated. The regression test now pins source preservation on the durable steering/message event. Live-notification needs keep their surviving homes: agent/queued at enqueue time, session/event at drain time. RFC: docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md (moved from proposed/, amended to shipped reality). The three implemented RFCs that stated the retention — the boundary-mirror removal, the stream-chunk removal, and event-domain-semantics — are amended to point at that RFC as the record of the removal, per implemented/AGENTS.md. The rejected retire-mid-turn-steering RFC keeps its frozen text (it records the declined proposal); the steering capability itself — steer(), the durable event, continuation forcing — is untouched. --- docs/architecture.md | 2 +- docs/cordis-catalog/events-and-services.md | 14 +-------- docs/rfc/README.md | 2 +- .../2026-06-30-event-domain-semantics.md | 4 +-- ...-20-remove-agent-boundary-mirror-events.md | 11 +++---- .../2026-07-02-remove-stream-chunk-mirror.md | 2 +- ...2026-07-04-remove-agent-steering-mirror.md | 30 +++++++++++++++++++ ...2026-07-04-remove-agent-steering-mirror.md | 28 ----------------- packages/core/agent-loop/src/loop.ts | 9 +++--- .../agent-loop/tests/review-fixes.spec.ts | 7 +++-- packages/core/agent/README.md | 3 +- packages/core/agent/src/types.ts | 9 ++---- 12 files changed, 53 insertions(+), 68 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-remove-agent-steering-mirror.md diff --git a/docs/architecture.md b/docs/architecture.md index 0266f1a2e9..23e6f53db4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -176,7 +176,7 @@ forever: session('tool/result') append buffered post-execute additionalContext → session('context/message')(s) ⟵ after ALL tool/results (adjacency) - drain steering → session('steering/message'); emit agent/steering + drain steering → session('steering/message') session('step/end') ⟵ durable step boundary (no agent/* mirror) cont = waterfall agent/turn-continuation(default = {action: hadToolCalls||steered ? 'continue' : 'stop'}) → ContinuationDecision diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index ac7ea069b9..d53f52cdbd 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -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:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:348`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -125,18 +125,6 @@ Types: [Agent](../core-data-structures/core.md) Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) -#### `agent/steering` — emit - -Steering content was injected into a running turn. - -```ts cordis-catalog -'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void -``` - -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:347`](../../packages/core/agent/src/types.ts) - #### `agent/step-result` — waterfall Waterfall: post-process the assembled assistant Message before tool dispatch (validation, content rewriting, …). diff --git a/docs/rfc/README.md b/docs/rfc/README.md index d53684bf6b..64f3aa6551 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -59,7 +59,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | | [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | -| [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | | [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Remove the `agent/steering` mirror emit](implemented/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md index 8f5be09b2b..9a9db6359f 100644 --- a/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md +++ b/docs/rfc/implemented/architecture/2026-06-30-event-domain-semantics.md @@ -19,7 +19,7 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab **Three domains, one job each, with a single boundary rule.** - **`session/*` — the durable, replayable FACT log.** Owns `SessionEventMap`; every entry is JSON-only (no live objects). One `session/event` emit per append, plus the `session/flush` parallel durability checkpoint. It is also the live transcript feed: a consumer that wants to render or react to what happened subscribes here, so live rendering and `session/load` replay share one path. -- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/steering`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so is the token stream (`assistant/chunk`). +- **`agent/*` — the LIVE runtime surface.** Always carries the live `Agent`. Two shapes: INTERCEPTION waterfalls (`agent/request`, `agent/step-result`, `agent/turn-continuation`) that mutate or veto, and TRANSIENT emits (`agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`) that notify with the `Agent` in hand. Turn and step BOUNDARIES are NOT here — they are durable session events read off `session/event`, and so are the token stream (`assistant/chunk`) and mid-turn steering (`steering/message`). - **`tools/*` — the tool registry + execution seam.** **The boundary rule:** a durable, replayable fact is a `SessionEvent`; a live interception or a transient/live-object signal is an `agent`/`tools` Cordis event. A turn or step boundary is a durable fact, so it lives in the session log and is read off the `session/event` feed — it is NOT mirrored as an `agent/*` emit. @@ -31,5 +31,5 @@ This is the foundational change in a stack that adds a Hooks subsystem; it estab - The loop no longer emits any boundary mirror; `closeStep` appends `step/end` only and `closeTurn` appends `turn/end` only. A throwing `step/end`/`turn/end` session-event listener is the surviving boundary-listener failure path (contained inside `closeStep`/`closeTurn` — `Session.append` pushes the event before notifying listeners, so the boundary is durable and the turn closes balanced regardless). - Tests that observed boundaries via the removed emits now observe the durable `turn/start`/`turn/end`/`step/start`/`step/end` session events — the behavior they pin (boundary ordering, step counting) is unchanged; only the feed they read moved to the canonical one. The tests that exercised a *throwing turn-boundary emit listener* were deleted, because that code path no longer exists (there is no emit to throw from). Per [AGENTS.md "tests document behavior, not golden truth"](../../../../AGENTS.md), the behavior and its test moved (or died) together. - The loop marks the step open (`stepOpen = true`) BEFORE appending `step/start`, because `Session.append` pushes the event to the log before notifying `session/event` listeners (validation throws happen earlier, before the push — see [the session append contract](../../../core-data-structures/session.md)). So a throwing `step/start` session-event listener runs with the step already open and the event already in the log: the loop's outer catch then calls `closeStep()`, which appends the balancing `step/end`, and the turn closes balanced with an error (`turn/start → step/start → step/end → turn/end` — verified by the invariants oracle in the regression test). Closing the open step is owed precisely because the marker is set first. -- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (a live control signal, not a boundary mirror) is retained; see that RFC's scope section. +- The full realization of this is [the simplification RFC "Stop mirroring durable boundaries as agent events"](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md): all four boundary mirrors are removed and every consumer reads boundaries off `session/event`. `agent/steering` (not a boundary mirror) stayed outside that RFC's scope and was removed by its own follow-up, [Remove the `agent/steering` mirror emit](../simplification/2026-07-04-remove-agent-steering-mirror.md) — it mirrored the durable `steering/message`. - The cordis catalog (`docs/cordis-catalog/events-and-services.md`) is regenerated to drop the mirror events. diff --git a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md index d8ff017d63..f198ff9ec6 100644 --- a/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md +++ b/docs/rfc/implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md @@ -5,10 +5,11 @@ Status: implemented (accepted 2026-07-01) <!-- Shipped in AMENDED, narrowed form: the four turn/step BOUNDARY mirrors are removed; `agent/steering` and `agent/stream-chunk` were RETAINED here (they are not durable-boundary mirrors — see "Scope: what is and isn't removed"). - The original proposal bundled `agent/steering` into the removal; validating - against the code showed it is a distinct live-only signal, so it stayed. - `agent/stream-chunk` was later removed by its own decision — see - [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). --> + The original proposal bundled `agent/steering` into the removal; keeping it + out kept this RFC's scope to boundaries. Each retained event was later + removed by its own decision — see + [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md) + and [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). --> ## Problem @@ -30,7 +31,7 @@ Removed (durable-boundary mirrors — the session log is authoritative for each) RETAINED — NOT durable-boundary mirrors, so out of scope for this decision: -- `agent/steering` — a live control signal, not a boundary. (The original proposal bundled it into the removal; validating against the code, it is not a duplicate of a durable boundary, so removing it here would have been scope creep. Its fate is a separate future decision.) +- `agent/steering` — not a boundary, so out of scope for THIS decision (the original proposal bundled it into the removal; that would have been scope creep here). It mirrors the durable `steering/message` control record rather than a boundary, and was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). - `agent/stream-chunk` — the live token stream. Out of scope for THIS decision (a mirror of the durable `assistant/chunk`, not a boundary), it was removed by its own follow-up: [Stop mirroring the token stream as an agent event](2026-07-02-remove-stream-chunk-mirror.md). - `agent/created`, `agent/disposed`, `agent/status`, `agent/error`, `agent/queued` — lifecycle/control events that are not transcript data. `agent/queued` in particular is an inbox acknowledgement that fires before any durable event exists (cancelled queued work may never enter the log), so it is deliberately live-only. diff --git a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md index b2ed1bc5d4..73c62a6864 100644 --- a/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md +++ b/docs/rfc/implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md @@ -33,7 +33,7 @@ Removed: `agent/stream-chunk`. Not touched: - `assistant/chunk` (the durable session event) — the authoritative token stream, kept exactly as-is. This RFC removes the LIVE MIRROR, not the persistence (the persistence-removal proposal was separately rejected — see above). -- `agent/steering` — a live control signal with no durable twin, retained (its fate remains a separate future decision, per the boundary RFC). +- `agent/steering` — not touched by THIS decision (a control signal, not the token stream). Its durable twin is `steering/message`, and the mirror emit was removed by its own follow-up: [Remove the `agent/steering` mirror emit](2026-07-04-remove-agent-steering-mirror.md). - `agent/status`, `agent/error`, `agent/created`/`agent/disposed`, `agent/queued`, `agent/session-start` — lifecycle/control events that are not transcript data and have no durable duplicate. ## What we give up diff --git a/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md new file mode 100644 index 0000000000..a8a5edd3a2 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-remove-agent-steering-mirror.md @@ -0,0 +1,30 @@ +# RFC: Remove the `agent/steering` mirror emit + +Status: implemented (accepted 2026-07-04) + +## Problem + +`agent/steering` was the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emitted `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It had zero production listeners: the only subscriber anywhere was a loop regression test asserting the emit carried `source` — the same fact the durable event already records one line above. + +Both mirror-removal RFCs retained it while explicitly deferring the decision this RFC makes. The [boundary-mirror removal](2026-06-20-remove-agent-boundary-mirror-events.md) kept it as a live control signal rather than a boundary; the [stream-chunk removal](2026-07-02-remove-stream-chunk-mirror.md) retained it on the reading that it had no durable twin. The second rationale did not survive the code: the durable twin is `steering/message`, appended immediately before the emit with the same payload. The mirrored-vs-live-only line the taxonomy actually draws puts it on the mirror side: `agent/queued` is genuinely live-only (it fires at enqueue time, before any durable event exists, and already carries a `steering: boolean` flag — cancelled queued work never enters the log), while `agent/steering` fired at the exact moment its durable twin landed, carrying nothing the log does not. + +Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix goldens pin — and every one of those consumers observes the durable event. Nothing observed the mirror. + +## Decision + +`agent/steering` is removed from the agent event taxonomy: the declaration in `packages/core/agent/src/types.ts` (and its mention in the live-events JSDoc list there), the emit in `drainSteering` (whose then-unused `ctx` parameter went with it), the row in `packages/core/agent/README.md`, and the emit line in the loop-pseudocode blocks (the `packages/core/agent-loop/src/loop.ts` module doc and [architecture.md](../../../architecture.md)); the cordis catalog is regenerated without it. The one regression test pins source preservation on the durable `steering/message` event — the fact it pins lives on the log. + +Three implemented RFCs stated the retention, and each is amended per [implemented/AGENTS.md](../AGENTS.md) to point here as the record of the removal: the [boundary RFC](2026-06-20-remove-agent-boundary-mirror-events.md)'s retained-list entry, the [stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)'s scope clause, and the [event-domain-semantics RFC](../architecture/2026-06-30-event-domain-semantics.md)'s transient-emit enumeration. + +## Why not keep it? + +"It is a control signal, not a boundary" — but the taxonomy's operative distinction is mirrored-vs-live-only, not control-vs-boundary, and this event mirrored. A consumer that wants enqueue-time notification has `agent/queued` (with its steering flag); a consumer that wants drain-time notification is by definition asking for the moment `steering/message` is appended, which `session/event` delivers with the same payload plus durability. The rejected [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) defended the steering *capability* — `steer()`, the durable event, continuation forcing — all of which this removal keeps untouched. + +## Acceptance criteria + +- The `agent/steering` spelling survives only in RFC prose (this RFC, the three amended RFCs above, and the frozen [rejected steering-capability RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md), whose text records the proposal it declined); the catalog is regenerated and fresh. +- The retargeted test pins source preservation on `steering/message`; the suite is green. + +## Risks + +None known: zero production listeners existed to migrate, and both live-notification needs (enqueue, drain) have surviving homes (`agent/queued`, `session/event`). diff --git a/docs/rfc/proposed/simplification/2026-07-04-remove-agent-steering-mirror.md b/docs/rfc/proposed/simplification/2026-07-04-remove-agent-steering-mirror.md deleted file mode 100644 index 579401cb75..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-remove-agent-steering-mirror.md +++ /dev/null @@ -1,28 +0,0 @@ -# RFC: Remove the `agent/steering` mirror emit - -Status: proposed - -## Problem - -`agent/steering` is the last remaining transient mirror of a durable session event. The loop's steering drain appends the durable `steering/message { turn, content, source }` and, on the very next line, emits `agent/steering(agent, turn, content, source)` — the identical fact as a fire-and-forget event (`packages/core/agent-loop/src/loop.ts`, `drainSteering`). It has zero production listeners: the only subscriber anywhere is a loop regression test asserting the emit carries `source` — the same fact the durable event already records one line above. - -Both mirror-removal RFCs retained it while explicitly deferring the decision this RFC now makes. The [boundary-mirror removal](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) kept it as "a live control signal, not a boundary"; the [stream-chunk removal](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) kept it as "a live control signal with no durable twin, retained (its fate is a separate future decision)". The second rationale does not survive the code: the durable twin is `steering/message`, appended immediately before the emit with the same payload. The mirrored-vs-live-only line the taxonomy actually draws puts it on the mirror side: `agent/queued` is genuinely live-only (it fires at enqueue time, before any durable event exists, and already carries a `steering: boolean` flag — cancelled queued work never enters the log), while `agent/steering` fires at the exact moment its durable twin lands, carrying nothing the log does not. - -Steering carries real production traffic — the hook bridges' turn-continuation decisions inject their reasons through `inbox.steer()`, landing as durable `steering/message` events that the hook-matrix goldens pin — and every one of those consumers observes the durable event. Nothing observes the mirror. - -## Proposal - -Remove the `agent/steering` declaration from `packages/core/agent/src/types.ts` (and its mention in the live-events JSDoc list there), the emit in `drainSteering` (whose `ctx` parameter becomes unused and goes too), the row in `packages/core/agent/README.md`, and the emit line in the loop-pseudocode blocks (`packages/core/agent-loop/src/loop.ts` module doc and [architecture.md](../../../architecture.md)); run `pnpm run gen-cordis-catalog`. Retarget the one regression test at the durable `steering/message` event — the source-preservation fact it pins lives on the log. The implementing PR amends the two retaining RFCs' scope lines per [implemented/AGENTS.md](../../implemented/AGENTS.md): the boundary RFC's retained-list entry and the stream-chunk RFC's "no durable twin" clause. - -## Why not keep it? - -"It is a control signal, not a boundary" — but the taxonomy's operative distinction is mirrored-vs-live-only, not control-vs-boundary, and this event mirrors. A consumer that wants enqueue-time notification has `agent/queued` (with its steering flag); a consumer that wants drain-time notification is by definition asking for the moment `steering/message` is appended, which `session/event` delivers with the same payload plus durability. The rejected [retire-mid-turn-steering RFC](../../rejected/simplification/2026-06-20-retire-mid-turn-steering.md) defended the steering *capability* — `steer()`, the durable event, continuation forcing — all of which this removal keeps untouched. - -## Acceptance criteria - -- No `agent/steering` spelling outside this RFC and the two amended RFCs; the catalog is regenerated and fresh. -- The retargeted test pins source preservation on `steering/message`; the suite is green. - -## Risks - -None known: zero production listeners exist to migrate, and both live-notification needs (enqueue, drain) have surviving homes (`agent/queued`, `session/event`). diff --git a/packages/core/agent-loop/src/loop.ts b/packages/core/agent-loop/src/loop.ts index 56c6cc1863..ef5c50b7ce 100644 --- a/packages/core/agent-loop/src/loop.ts +++ b/packages/core/agent-loop/src/loop.ts @@ -166,7 +166,7 @@ export interface LoopHandle { * → dispatch → tools/post-execute * session('tool/result') * append buffered post-execute additionalContext → session('context/message')(s) - * drain steering → session('steering/message'); emit agent/steering + * drain steering → session('steering/message') * session('step/end') ⟵ durable step boundary (no agent/* mirror) * cont = waterfall agent/turn-continuation ⟵ ContinuationDecision; default * {action: hadToolCalls||steered ? 'continue':'stop'}; a continue.reason is @@ -420,7 +420,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, // Steering from the previous round's continuation listeners joins before // the request. - drainSteering(ctx, agent, turn) + drainSteering(agent, turn) // The step's AbortController exists BEFORE any async pre-step work so a // dispose() or cancel() — in a synchronous turn-start listener or an @@ -529,7 +529,7 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, if (stepReason) reason = stepReason // Steering that arrived during streaming/tool execution. - const steered = drainSteering(ctx, agent, turn) + const steered = drainSteering(agent, turn) if (closeStep()) break @@ -635,11 +635,10 @@ async function runTurn(ctx: Context, agent: ReactLoopAgent, handle: LoopHandle, } /** Drain the steering queue into the session. Returns whether any arrived. */ -function drainSteering(ctx: Context, agent: ReactLoopAgent, turn: number): boolean { +function drainSteering(agent: ReactLoopAgent, turn: number): boolean { const messages = agent.inbox.drainSteering() for (const message of messages) { agent.session.append('steering/message', { turn, content: message.content, source: message.source }, { surfaceOp: 'append' }) - ctx.emit('agent/steering', agent, turn, message.content, message.source) } return messages.length > 0 } diff --git a/packages/core/agent-loop/tests/review-fixes.spec.ts b/packages/core/agent-loop/tests/review-fixes.spec.ts index eddc69a2e6..76c377fd61 100644 --- a/packages/core/agent-loop/tests/review-fixes.spec.ts +++ b/packages/core/agent-loop/tests/review-fixes.spec.ts @@ -410,7 +410,7 @@ describe('MEDIUM: misc registry and config fixes', () => { expect(agent.session.deriveMessages().at(-1)?.content).toEqual([{ type: 'text', text: 'routed' }]) }) - it('agent/queued carries the resolved source; agent/steering carries its source', async () => { + it('agent/queued carries the resolved source; steering/message records its source', async () => { const adapter = new MockAdapter([toolCallResponse('c1', 'noop', {}), textResponse('done')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) @@ -425,15 +425,16 @@ describe('MEDIUM: misc registry and config fixes', () => { })) const queuedSources: { source: MessageSource; steering: boolean }[] = [] - const steeringSources: MessageSource[] = [] ctx.on('agent/queued', (_agent, _content, info) => void queuedSources.push(info)) - ctx.on('agent/steering', (_agent, _turn, _content, source) => void steeringSources.push(source)) send(agent, 'go') // no explicit source → default {kind:'user'} must be visible await waitForIdle(ctx, agent) expect(queuedSources[0]).toEqual({ source: { kind: 'user' }, steering: false }) expect(queuedSources[1]).toEqual({ source: { kind: 'plugin', plugin: 'goal' }, steering: true }) + // The drain appends the durable steering/message with the caller's source + // intact — the log, not a transient emit, is where consumers read it. + const steeringSources = agent.session.events.flatMap(e => e.type === 'steering/message' ? [e.data.source] : []) expect(steeringSources).toEqual([{ kind: 'plugin', plugin: 'goal' }]) }) }) diff --git a/packages/core/agent/README.md b/packages/core/agent/README.md index f4eb61c2bd..a6446bf7d1 100644 --- a/packages/core/agent/README.md +++ b/packages/core/agent/README.md @@ -50,9 +50,8 @@ Turn and step boundaries are NOT mirrored as `agent/*` emits: a consumer that ne Tool interception is the `tools/pre-execute` / `tools/post-execute` pair in [`dsh-tools`](../tools/README.md) (`PreToolDecision` allow/deny/ask, `PostToolDecision` accept/block) — same typed-Decision idiom, owned there because it is the tool registry's seam. -#### Live control notifications (emit) +#### Error notifications (emit) -- `agent/steering` — steering content injected mid-turn - `agent/error` — step/turn error The model's token stream is NOT an `agent/*` event: read it off the durable `session/event` feed as `assistant/chunk` (the same feed persistence and the ACP bridge use). diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index dd01831130..e869103c7c 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -20,7 +20,7 @@ * `agent/request`/`agent/step-result`/`agent/turn-continuation` waterfalls and * the serial `agent/pre-step`) that mutate/veto, and TRANSIENT emits * (`agent/status`, `agent/error`, `agent/created`/ - * `agent/disposed`, `agent/queued`, `agent/steering`, `agent/session-start`) + * `agent/disposed`, `agent/queued`, `agent/session-start`) * that notify with the `Agent` in hand. Turn/step boundaries are NOT here — * they are durable `session/event` records. Answers "right now, with the agent * object — intercept or observe." @@ -339,12 +339,7 @@ declare module 'cordis' { */ 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision> - // ---- streaming + tool notifications (emit) ---- - /** - * Steering content was injected into a running turn. - * @mode emit - */ - 'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void + // ---- error notifications (emit) ---- /** * A step or turn errored. The loop reports a failure here (plus the logger) * even when the error has no in-turn position for a session `error` event. From e64623ebfd9482e7e3af624bf49a67fb3e70f6b1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:37:43 +0800 Subject: [PATCH 05/32] refactor(fs): prune write-only fields and the dead routing knob from the seam The fs seam split left four pieces of pre-split surface populated on every call and read by nobody: - STREAM_MIN_SIZE + FsIoInternals.streamMinSize in dsh-fs-local: the backend has no read routing (readWholeText/streamWholeText are separate primitives the caller picks), and the real 10 MiB routing constant lives in dsh-tool-fs's read tool. Delete the dead mirror and the knob whose JSDoc claimed an override that did not exist; the remaining FsIoInternals knobs stay (the atomic-write tests use them). - FsTarget.inputPath: a "diagnostics only" field every backend and test fake had to fabricate, with zero production readers (policy and error messages use targetKey/displayPath). listDir gave children the bare entry name, which was nobody's input. - FsEditOutcome.replacements/.replaceAll: replacements had no reader (the single-match policy is enforced by the FS_AMBIGUOUS_EDIT / FS_EDIT_NOT_FOUND throws, whose message keeps the internal count); replaceAll only echoed the replace_all argument back to formatEditOutput, which now takes it from the parsed args. The outcome shrinks to { version, before, after }, parallel to FsWriteOutcome's backend-discovered fields. Emitted text is unchanged for both branches (no snapshot churn). - FileReadOutcome.limit/.version: formatReadOutput renders offset/lines/totalLines/truncatedByBytes only, and the fs/observed emit uses info.version directly. Backends shed four fabrication obligations and gain none. Doc pastes (core-data-structures/filesystem.md), the dsh-fs README resolve row, and the test fakes shrink with the types. RFC moved to implemented/simplification and amended to the shipped shape (FsEditSpec -> FsEditRequest name fix; manifest rows needed no change). --- docs/core-data-structures/filesystem.md | 7 +------ docs/rfc/README.md | 2 +- .../2026-07-04-prune-write-only-fs-surface.md | 20 +++++++++---------- packages/fs/fs-local/src/fsio.ts | 11 +++------- packages/fs/fs-local/src/index.ts | 7 ++----- packages/fs/fs-local/tests/filesystem.spec.ts | 14 ++++--------- packages/fs/fs-policy/tests/policy.spec.ts | 2 +- packages/fs/fs/README.md | 2 +- packages/fs/fs/src/types.ts | 6 ------ packages/fs/fs/tests/service.spec.ts | 8 ++++---- packages/fs/tool-fs/src/edit.ts | 9 ++++----- packages/fs/tool-fs/src/read-render.ts | 5 ----- packages/fs/tool-fs/src/read.ts | 2 -- packages/fs/tool-fs/tests/tools.spec.ts | 6 +++--- 14 files changed, 34 insertions(+), 67 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-04-prune-write-only-fs-surface.md (59%) diff --git a/docs/core-data-structures/filesystem.md b/docs/core-data-structures/filesystem.md index e384ffcc92..e13322dc5a 100644 --- a/docs/core-data-structures/filesystem.md +++ b/docs/core-data-structures/filesystem.md @@ -12,7 +12,6 @@ Every operation resolves a user-supplied path to an opaque backend target first. ```ts type-equiv interface FsTarget { - inputPath: string targetKey: FsTargetKey displayPath: string } @@ -81,8 +80,6 @@ interface FsEditRequest { ```ts type-equiv interface FsEditOutcome { - replacements: number - replaceAll: boolean version: FsVersion before: string after: string @@ -109,16 +106,14 @@ interface FsPolicyExec { ## Read outcome (consumer / read rendering) -A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders carries the file's version at read time; there is no `full`/`partial` view — authorization is freshness-based, so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. +A text read is bounded by line window, byte cap, and backend limits. The outcome the model-facing `read` tool renders is purely presentational; there is no `full`/`partial` view — authorization is freshness-based (the tool emits `fs/observed` with the stat's version directly), so any windowed read can authorize a later write/edit when the file is unchanged. Read windowing and this outcome shape live in `dsh-tool-fs` (the executor that owns the read), not in the policy plugin. ```ts type-equiv interface FileReadOutcome { offset: number - limit: number lines: FileTextLine[] totalLines: number truncatedByBytes?: true - version: FsVersion } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index d53684bf6b..9ba9d5ccbc 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -58,7 +58,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | -| [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | | [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Prune write-only fields and a dead routing knob from the fs seam](implemented/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md similarity index 59% rename from docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md rename to docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md index 0a4bc14d89..1efb9c1ca3 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -1,27 +1,27 @@ # RFC: Prune write-only fields and a dead routing knob from the fs seam -Status: proposed +Status: implemented (proposed and accepted 2026-07-04) ## Problem -The [fs seam split](../../implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) moved read routing and policy out of the backend into `dsh-tool-fs` and `dsh-fs-policy`. Four pieces of surface kept the pre-split shape — populated on every call, read by nobody: +The [fs seam split](2026-06-26-fsspec-style-fs-seam.md) moved read routing and policy out of the backend into `dsh-tool-fs` and `dsh-fs-policy`. Four pieces of surface kept the pre-split shape — populated on every call, read by nobody: -1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's is dead, and the knob's JSDoc claims a "read routing" override that does not exist. -2. **`FsTarget.inputPath`** (`packages/fs/fs/src/types.ts`): every backend and every test fake must fabricate a "diagnostics only" value with zero production readers — the policy plugin and every error message use `targetKey`/`displayPath`. The `listDir` producer exposes the semantic wobble: directory children get the bare entry name, which was nobody's "input". -3. **`FsEditOutcome.replacements` + `.replaceAll`** (`packages/fs/fs/src/types.ts`): `replacements` has zero production readers (the single-match policy itself stays — it is enforced by the `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` throws inside the backend, whose error message keeps the internal count); `replaceAll` is read only by `formatEditOutput` in `packages/fs/tool-fs/src/edit.ts` — as an echo of the `replace_all` argument the tool already holds. Shrunk, `FsEditOutcome` becomes `{ version, before, after }`, parallel to `FsWriteOutcome`'s genuinely backend-discovered fields. -4. **`FileReadOutcome.limit` + `.version`** (`packages/fs/tool-fs/src/read-render.ts`): populated by the read tool, but `formatReadOutput` renders `offset`/`lines`/`totalLines`/`truncatedByBytes` only, and the `fs/observed` emit uses `info.version` directly rather than the outcome copy. +1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's was dead, and the knob's JSDoc claimed a "read routing" override that did not exist. +2. **`FsTarget.inputPath`** (`packages/fs/fs/src/types.ts`): every backend and every test fake had to fabricate a "diagnostics only" value with zero production readers — the policy plugin and every error message use `targetKey`/`displayPath`. The `listDir` producer exposed the semantic wobble: directory children got the bare entry name, which was nobody's "input". +3. **`FsEditOutcome.replacements` + `.replaceAll`** (`packages/fs/fs/src/types.ts`): `replacements` had zero production readers (the single-match policy itself stays — it is enforced by the `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` throws inside the backend, whose error message keeps the internal count); `replaceAll` was read only by `formatEditOutput` in `packages/fs/tool-fs/src/edit.ts` — as an echo of the `replace_all` argument the tool already holds. Shrunk, `FsEditOutcome` is `{ version, before, after }`, parallel to `FsWriteOutcome`'s genuinely backend-discovered fields. +4. **`FileReadOutcome.limit` + `.version`** (`packages/fs/tool-fs/src/read-render.ts`): populated by the read tool, but `formatReadOutput` renders `offset`/`lines`/`totalLines`/`truncatedByBytes` only, and the `fs/observed` emit uses `info.version` directly rather than an outcome copy. -## Proposal +## Decision -Delete the fs-local constant, its re-export, and the `streamMinSize` knob (the remaining `FsIoInternals` knobs are genuinely used by the atomic-write tests); drop `inputPath` from `FsTarget`; shrink `FsEditOutcome` to `{ version, before, after }` and pass `replaceAll` to `formatEditOutput` from the parsed args; drop `limit`/`version` from `FileReadOutcome`. Update the [filesystem.md](../../../core-data-structures/filesystem.md) pastes, the type-equiv manifest, `packages/fs/fs/README.md`, and the test fakes that currently must fabricate the removed fields. +Delete the fs-local constant, its re-export, and the `streamMinSize` knob (the remaining `FsIoInternals` knobs are genuinely used by the atomic-write tests); drop `inputPath` from `FsTarget`; shrink `FsEditOutcome` to `{ version, before, after }` and pass `replaceAll` to `formatEditOutput` from the parsed args; drop `limit`/`version` from `FileReadOutcome`. The [filesystem.md](../../../core-data-structures/filesystem.md) pastes, `packages/fs/fs/README.md`, and the test fakes that had to fabricate the removed fields shrink with the types. ## Why not keep them? -A future permission/containment layer might want the pre-resolution path for error text — but it would want the *request*, which every call site still holds. "N occurrences replaced" might become model-facing text — a behavior change to design when wanted, and the backend-internal count survives for its error message. A read footer might display `limit` — everything the footer shows already derives from `lines`/`totalLines`. Meanwhile every current and future backend (remote, native) must fabricate wire fields nobody consumes, and every test fake must satisfy them. +A future permission/containment layer might want the pre-resolution path for error text — but it would want the *request*, which every call site still holds. "N occurrences replaced" might become model-facing text — a behavior change to design when wanted, and the backend-internal count survives for its error message. A read footer might display `limit` — everything the footer shows already derives from `lines`/`totalLines`. Meanwhile every current and future backend (remote, native) would have to fabricate wire fields nobody consumes, and every test fake would have to satisfy them. ## Acceptance criteria -- The removed surfaces are gone — `STREAM_MIN_SIZE`/`streamMinSize` in `dsh-fs-local`, `FsTarget.inputPath`, `FsEditOutcome.replacements`/`.replaceAll`, and `FileReadOutcome.limit`/`.version` — while the request-side `replaceAll` (`FsEditSpec`) and the version fields on the other outcome types are untouched; doc pastes and the manifest in sync; the suite is green with the shrunk fakes. +- The removed surfaces are gone — `STREAM_MIN_SIZE`/`streamMinSize` in `dsh-fs-local`, `FsTarget.inputPath`, `FsEditOutcome.replacements`/`.replaceAll`, and `FileReadOutcome.limit`/`.version` — while the request-side `replaceAll` (`FsEditRequest`) and the version fields on the other outcome types are untouched; doc pastes and the manifest in sync; the suite is green with the shrunk fakes. - `formatEditOutput`'s emitted text is unchanged for both `replace_all` branches, so no snapshot golden churns. ## Risks diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 980cb4764d..b1b9c25397 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -26,9 +26,6 @@ import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -/** Files at or above this size stream their text; smaller files read whole. */ -export const STREAM_MIN_SIZE = 10 * 1024 * 1024 - const BINARY_SAMPLE_BYTES = 8192 function isENOENT(error: unknown): boolean { @@ -85,13 +82,11 @@ function versionOf(info: Stats): FsVersion { } /** - * Test seam: lets specs force the streaming read path (via a small - * `streamMinSize`) and pin the temp-file name (to prove exclusive-open - * behavior) without a 10 MB fixture or a name race. + * Test seam: lets specs pin the atomic-write temp names (to prove + * exclusive-open behavior without a name race) and observe the staged temp + * file before it is renamed over the target. */ export interface FsIoInternals { - /** Override {@link STREAM_MIN_SIZE} for read routing. */ - streamMinSize?: number /** Override the generated private staging-dir name (relative to the target dir). */ tempDirName?: (writePath: string) => string /** Override the generated temp-file name (relative to the private staging dir). */ diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index af74847ed1..0ad8365d4a 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -41,7 +41,6 @@ import { import type { FsIoInternals } from './fsio.ts' export { - STREAM_MIN_SIZE, applyLiteralEdit, listDirectory, probe, @@ -105,7 +104,7 @@ export class LocalFileSystem extends FileSystem { override async resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> { const local = await resolveLocalTarget(opts?.cwd ?? this.config.cwd, path) - return { inputPath: path, targetKey: local.targetKey, displayPath: local.displayPath } + return { targetKey: local.targetKey, displayPath: local.displayPath } } override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> { @@ -128,7 +127,7 @@ export class LocalFileSystem extends FileSystem { return entries.map(entry => ({ name: entry.name, type: entry.type, - target: { inputPath: entry.name, targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, + target: { targetKey: entry.target.targetKey, displayPath: entry.target.displayPath }, ...(entry.version !== undefined ? { version: entry.version } : {}), ...(entry.size !== undefined ? { size: entry.size } : {}), })) @@ -211,8 +210,6 @@ export class LocalFileSystem extends FileSystem { const after = await probe(target.targetKey) return { - replacements: edited.replacements, - replaceAll: edit.replaceAll, version: this.versionAfterWrite(after, target), // The LF-normalized before/after text (the applied-hunk diff basis); // line-ending restoration is a storage detail the diff ignores. diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index 212074ddae..997021d346 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -138,12 +138,6 @@ describe('listDir', () => { join(dir, 'skills', 'dir-skill'), join(dir, 'skills', 'zeta.md'), ]) - expect(entries.map(entry => entry.target.inputPath)).toEqual([ - 'alpha.md', - 'broken-link', - 'dir-skill', - 'zeta.md', - ]) const materializedEntries = entries.filter(entry => entry.version !== undefined) expect(materializedEntries.map(entry => entry.target.targetKey)) .toEqual(await Promise.all(materializedEntries.map(entry => realpath(entry.target.displayPath)))) @@ -335,7 +329,7 @@ describe('editText', () => { await writeFile(join(dir, 'a.txt'), 'hello world') const target = await fs.resolve('a.txt') const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }, { version: await versionOf(target) }) - expect(outcome.replacements).toBe(1) + expect(outcome.after).toBe('hello there') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) @@ -365,7 +359,7 @@ describe('editText', () => { const target = await fs.resolve('a.txt') // No version guard: any current content is edited, regardless of version. const outcome = await fs.editText(target, { oldString: 'world', newString: 'there', replaceAll: false }) - expect(outcome.replacements).toBe(1) + expect(outcome.after).toBe('hello there') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('hello there') }) @@ -411,7 +405,7 @@ describe('editText', () => { await writeFile(join(dir, 'a.txt'), 'a a a') const target = await fs.resolve('a.txt') const outcome = await fs.editText(target, { oldString: 'a', newString: 'b', replaceAll: true }, { version: await versionOf(target) }) - expect(outcome.replacements).toBe(3) + expect(outcome.after).toBe('b b b') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('b b b') }) @@ -457,7 +451,7 @@ describe('editText', () => { // The version the first edit returned is a valid guard for a second edit — // no intervening re-stat needed. const second = await fs.editText(target, { oldString: 'two', newString: 'TWO', replaceAll: false }, { version: first.version }) - expect(second.replacements).toBe(1) + expect(second.after).toBe('ONE TWO') expect(await readFile(join(dir, 'a.txt'), 'utf8')).toBe('ONE TWO') }) diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 2ea61ffcd1..c41cf45701 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -18,7 +18,7 @@ import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' import type { FsPolicyExec } from '@deepseek-ai/dsh-fs-policy' function target(path: string): FsTarget { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } + return { targetKey: FsTargetKey(path), displayPath: path } } const ownerExec = (session: object): FsPolicyExec => ({ agent: { session } }) diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 4bd152cab9..7e7c7250de 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -19,7 +19,7 @@ A backend subclasses `FileSystem` and implements seven primitives. | Member | Semantics | |---|---| -| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (`inputPath`, opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | +| `resolve(path, opts?)` | Resolve a path into a stable `FsTarget` (opaque `targetKey`, `displayPath`). `opts.cwd` is the base a relative `path` resolves against (a caller supplies its session workspace; absolute paths ignore it; omitted ⇒ the backend default). Async — a remote backend may need I/O. The same file via different paths must yield the same `targetKey`. | | `stat(target, signal?)` | Return `FsInfo` metadata (`version`, `type`, optional `size`), or `undefined` when the target is absent. Never content. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here). | diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index ed946389d2..9351a373db 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -52,8 +52,6 @@ export function FsVersion(v: string): FsVersion { * this; every other operation takes it. */ export interface FsTarget { - /** The original model/plugin-supplied path, for diagnostics only. */ - inputPath: string /** Opaque key for stale guards and target lookup. */ targetKey: FsTargetKey /** @@ -142,10 +140,6 @@ export interface FsEditRequest { /** Outcome of a literal edit. */ export interface FsEditOutcome { - /** Number of literal replacements applied. */ - replacements: number - /** Whether every match was replaced. */ - replaceAll: boolean /** Opaque version of the file after the edit. */ version: FsVersion /** diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index d4edd9260f..86ba782c96 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -23,7 +23,7 @@ class FakeFileSystem extends FileSystem { files = new Map<string, string>() override async resolve(path: string): Promise<FsTarget> { - return { inputPath: path, targetKey: FsTargetKey(path), displayPath: path } + return { targetKey: FsTargetKey(path), displayPath: path } } override async stat(target: FsTarget): Promise<FsInfo | undefined> { const content = this.files.get(target.targetKey) @@ -45,7 +45,7 @@ class FakeFileSystem extends FileSystem { { name: 'alpha.md', type: 'file', - target: { inputPath: 'skills/alpha.md', targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' }, + target: { targetKey: FsTargetKey('skills/alpha.md'), displayPath: 'skills/alpha.md' }, size: 2, version: FsVersion('v1'), }, @@ -60,7 +60,7 @@ class FakeFileSystem extends FileSystem { const content = this.files.get(target.targetKey) ?? '' const after = content.split(edit.oldString).join(edit.newString) this.files.set(target.targetKey, after) - return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after } + return { version: FsVersion('v3'), before: content, after } } } @@ -108,7 +108,7 @@ describe('FileSystem provider seam', () => { expect(entries).toEqual([{ name: 'alpha.md', type: 'file', - target: { inputPath: 'skills/alpha.md', targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' }, + target: { targetKey: 'skills/alpha.md', displayPath: 'skills/alpha.md' }, size: 2, version: 'v1', }]) diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index 1a39cb63dd..5ede545b03 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -16,7 +16,6 @@ import type { Context } from 'cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' -import type { FsEditOutcome } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { computeHunkDiffs, diffsFromMeta, type FsDiffMeta } from './diff.ts' @@ -43,9 +42,9 @@ export function parseEditArgs(args: { file_path: string; old_string: string; new } } -/** Format an edit outcome as a Claude-style model-facing success message. */ -export function formatEditOutput(displayPath: string, outcome: FsEditOutcome): string { - return outcome.replaceAll +/** Format an edit success (single-match or replace-all) as a Claude-style model-facing message. */ +export function formatEditOutput(displayPath: string, replaceAll: boolean): string { + return replaceAll ? `The file ${displayPath} has been updated. All occurrences were successfully replaced.` : `The file ${displayPath} has been updated successfully.` } @@ -91,7 +90,7 @@ export function applyEditTool(ctx: Context): void { // relativizes it). const diffs = computeHunkDiffs(input.filePath, outcome.before, outcome.after) return { - content: [{ type: 'text', text: formatEditOutput(target.displayPath, outcome) }], + content: [{ type: 'text', text: formatEditOutput(target.displayPath, input.replaceAll) }], meta: { diffs }, } }, diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 97a1384792..4dbbb6b77b 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -17,7 +17,6 @@ */ import { FsError } from '@deepseek-ai/dsh-fs' -import type { FsVersion } from '@deepseek-ai/dsh-fs' /** Maximum characters returned for a single line. */ export const READ_MAX_LINE_LENGTH = 2000 @@ -58,16 +57,12 @@ export interface WindowResult { export interface FileReadOutcome { /** 1-based first line requested. */ offset: number - /** Maximum number of lines requested. */ - limit: number /** Returned lines, already numbered. */ lines: FileTextLine[] /** Total line count in the file, unless `truncatedByBytes` stopped scanning early. */ totalLines: number /** Whether selected output hit the byte cap before EOF or the requested limit. */ truncatedByBytes?: true - /** Opaque version of the file at read time. */ - version: FsVersion } interface WindowAccumulator { diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index c984b53c9f..094a2abd27 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -90,10 +90,8 @@ export function applyReadTool(ctx: Context): void { const outcome: FileReadOutcome = { offset: input.offset, - limit: input.limit, lines: window.lines, totalLines: window.totalLines, - version: info.version, ...window.truncatedByBytes ? { truncatedByBytes: true } : {}, } // Record the observed version (a no-op when no policy plugin listens). The diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 6272ac5c9d..2049063549 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -40,7 +40,7 @@ class FakeFs extends FileSystem { } override async resolve(path: string): Promise<FsTarget> { - return { inputPath: path, targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } + return { targetKey: FsTargetKey(`key:${path}`), displayPath: `/abs/${path}` } } override async stat(target: FsTarget): Promise<FsInfo | undefined> { this.throwIfArmed() @@ -71,7 +71,7 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' const after = content.split(edit.oldString).join(edit.newString) this.files.set(target.targetKey, after) - return { replacements: 1, replaceAll: edit.replaceAll, version: FsVersion('v3'), before: content, after } + return { version: FsVersion('v3'), before: content, after } } } @@ -252,7 +252,7 @@ describe('read tool', () => { }) describe('formatReadOutput footer variants', () => { - const base: FileReadOutcome = { offset: 1, limit: 2000, lines: [{ number: 1, text: 'x' }], totalLines: 1, version: FsVersion('v') } + const base: FileReadOutcome = { offset: 1, lines: [{ number: 1, text: 'x' }], totalLines: 1 } it('reports a byte-capped read', () => { const out = formatReadOutput('/f', { ...base, totalLines: 99, truncatedByBytes: true }) From 205f7cd04d6c53aee2846e113e90e2dbf6ec1c98 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:38:03 +0800 Subject: [PATCH 06/32] refactor(ui): fold the stdio UI helper into the stdio app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readline UI lives inside @deepseek-ai/dsh-stdio-agent as the in-package stdio-chat module; the packages/support/ui-stdio package is gone. The app's front-door cluster always includes this UI and nothing else composes it, so the boundary bought manifest/tsconfig/module-graph/ README/publint surface for a helper that is not independently swappable — and a product app no longer depends on a support package documented as not-product-surface. createStdioChat, the StdioRuntime test seam, and both unit suites moved verbatim (imports rewired to the module path); the named name/inject/Config/apply export shape stays, being the contract the app's ctx.plugin mount consumes. Coverage stays per-file 100%; the built-bin smoke under plain node and both keyless Loader-path smokes prove the published artifact and the demos end-to-end. Implements docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md (moved from proposed/ and amended to the shipped shape). --- AGENTS.md | 2 - docs/module-graph.md | 7 +-- docs/rfc/README.md | 2 +- .../2026-07-04-fold-stdio-ui-helper.md | 24 ++++++++++ .../2026-07-04-fold-stdio-ui-helper.md | 27 ------------ .../coding-agent/tests/keyless-smoke.e2e.ts | 4 +- examples/echo-agent/tests/echo.e2e.ts | 2 +- packages/README.md | 4 +- packages/support/README.md | 3 +- packages/support/ui-stdio/README.md | 44 ------------------- packages/support/ui-stdio/package.json | 39 ---------------- packages/support/ui-stdio/tsconfig.json | 30 ------------- packages/todo/README.md | 2 +- packages/todo/tool-todo/README.md | 2 +- packages/ui/README.md | 2 +- packages/ui/stdio-agent/README.md | 2 +- packages/ui/stdio-agent/package.json | 2 - packages/ui/stdio-agent/src/index.ts | 9 ++-- .../stdio-agent/src/stdio-chat.ts} | 29 +++++------- .../ui/stdio-agent/tests/built-bin.e2e.ts | 2 +- .../stdio-agent}/tests/readline.spec.ts | 4 +- .../stdio-agent/tests/stdio-chat.spec.ts} | 2 +- packages/ui/stdio-agent/tsconfig.json | 3 -- pnpm-lock.yaml | 22 ---------- tsconfig.build.json | 1 - tsconfig.json | 1 - 26 files changed, 56 insertions(+), 215 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-fold-stdio-ui-helper.md delete mode 100644 packages/support/ui-stdio/README.md delete mode 100644 packages/support/ui-stdio/package.json delete mode 100644 packages/support/ui-stdio/tsconfig.json rename packages/{support/ui-stdio/src/index.ts => ui/stdio-agent/src/stdio-chat.ts} (88%) rename packages/{support/ui-stdio => ui/stdio-agent}/tests/readline.spec.ts (92%) rename packages/{support/ui-stdio/tests/ui-stdio.spec.ts => ui/stdio-agent/tests/stdio-chat.spec.ts} (99%) diff --git a/AGENTS.md b/AGENTS.md index c5e7f5c701..004fef4611 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,8 +114,6 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/. acp bridge, NO stdout logger + a bin (the demo:acp front door) support/ dev/test/example infrastructure (lower compat expectations) invariants/ dev-mode event-contract invariants + session-log freeze - ui-stdio/ minimal stdio (readline) UI plugin: renders agent/* events, - feeds stdin lines to the agent (shared by the demos) llm-replay/ record/replay adapter: short-circuits llm/stream from a recorded session JSONL (keyless snapshot tests) subagent-mock/ scripted SubagentProvider for deterministic seam/tool tests diff --git a/docs/module-graph.md b/docs/module-graph.md index 452639a90f..c2c2c97bf7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -48,9 +48,6 @@ graph TD tools --> agent tools --> llm tools --> system-prompt - ui-stdio --> agent - ui-stdio --> llm - ui-stdio --> session acp --> agent acp --> llm acp --> session @@ -121,7 +118,6 @@ graph TD stdio-agent --> agent-core stdio-agent --> session stdio-agent --> session-persistence-jsonl - stdio-agent --> ui-stdio subagent-fork --> agent subagent-fork --> session subagent-fork --> subagent @@ -158,7 +154,6 @@ graph TD | `session-persistence-jsonl` | `session`, `session-persistence` | | `session-persistence-sqlite` | `session`, `session-persistence` | | `tools` | `agent`, `llm`, `system-prompt` | -| `ui-stdio` | `agent`, `llm`, `session` | | `acp` | `agent`, `llm`, `session`, `session-persistence`, `tools` | | `agent-loop` | `agent`, `llm`, `session`, `session-persistence`, `system-prompt`, `tools` | | `hooks-codex` | `agent`, `hook-protocol`, `llm`, `session`, `tools` | @@ -174,6 +169,6 @@ graph TD | `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` | +| `stdio-agent` | `agent`, `agent-core`, `session`, `session-persistence-jsonl` | | `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 d53684bf6b..9a4aff345e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -55,7 +55,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | | [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | -| [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | | [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md new file mode 100644 index 0000000000..292e2dc541 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -0,0 +1,24 @@ +# RFC: Fold the stdio UI helper into the stdio app + +Status: implemented + +## Problem + +The readline UI was a whole package (`@deepseek-ai/dsh-ui-stdio` under `packages/support/`) whose only runtime importer was the app package `@deepseek-ai/dsh-stdio-agent`. The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference was mechanical or descriptive surface that existed BECAUSE the package boundary existed — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. The ui group README recorded the support placement rationale ("exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product"), which left a standing tension: a shipped product app depending on a support package documented as NOT product surface. + +The boundary bought package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. + +## Decision + +The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep guarding the app's export shape end-to-end. + +The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. + +## Why not promote it to `ui/` instead? + +Promotion would have resolved the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census said neither. The structured ACP bridge stays its own package because it is the product protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract. + +## Consequences + +- The stdio app owns its whole front door; a leaf `cordis.yml` still loads one app package and nothing changed shape for the demos. +- A future standalone terminal UI that wants the helper as a package reintroduces it with that second consumer, rather than the repo keeping a boundary for hypothetical reuse. diff --git a/docs/rfc/proposed/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/proposed/simplification/2026-07-04-fold-stdio-ui-helper.md deleted file mode 100644 index aba2faf4ee..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-fold-stdio-ui-helper.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Fold the stdio UI helper into the stdio app - -Status: proposed - -## Problem - -`@deepseek-ai/dsh-ui-stdio` is a whole package whose only runtime importer is the app package `@deepseek-ai/dsh-stdio-agent` (`packages/ui/stdio-agent/src/index.ts`). The examples reach the readline UI by loading the app, never by composing the helper themselves; every other repo reference is mechanical or descriptive surface that exists BECAUSE the package boundary exists — manifest and tsconfig entries, generated module-graph rows, dependency-graph and README rows, and doc comments naming the package. [The ui group README](../../../../packages/ui/README.md) records the placement rationale — the helper "exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product" — which leaves a standing tension: a shipped product app depends on a support package documented as NOT product surface. - -The boundary buys package metadata, workspace and tsconfig references, module-graph rows, README entries, and publint surface for a helper that is not independently swappable: the stdio app's front-door cluster always includes the readline UI, and nothing else can meaningfully consume it. - -## Proposal - -Fold the helper into `@deepseek-ai/dsh-stdio-agent`: move `createStdioChat`, its `StdioRuntime` test seam, and its unit tests into `packages/ui/stdio-agent`; delete the `packages/support/ui-stdio` package with its manifest, references, module-graph rows, and README rows; update every reference that names the package (the example e2e module docs, `packages/README.md`, the support and todo README rows, the stdio-agent README, the ui group README, tsconfig references, the generated module graph). Keep the runtime seam so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered without hijacking process globals; the keyless Loader-path smokes keep guarding the export shape end-to-end. - -## Why not promote it to `ui/` instead? - -Promotion would resolve the support-vs-product mismatch while keeping the boundary — the right call only if the readline UI were an independently swappable integration or had a second composer, and the consumer census says neither. The structured ACP bridge stays its own package because it is the product protocol surface with its own contract and snapshot tiers; the readline helper is scaffolding for one app's front door. Re-extraction stays cheap pre-release: if a second product app wants the readline UI, split it back out then, with that consumer shaping the package contract. - -## Acceptance criteria - -- `packages/support/ui-stdio` no longer exists; the helper and its tests live in `packages/ui/stdio-agent`; no reference to the deleted package remains outside RFC history. -- The stdio app still renders transcript events, handles stdin lines and EOF, renders todo checklists, and disposes readline listeners under HMR; the echo/coding keyless smokes still boot through the real Loader path and guard the export shape. -- Manifests, tsconfig references, the generated module graph, and docs are updated; `pnpm run test:coverage`, `pnpm run test:snapshot`, `pnpm run doc-sync`, `pnpm run build`, and `pnpm run hygiene` pass. - -## Risks - -A future standalone terminal UI may want the helper as a package again — reintroduce it with that second consumer rather than keeping the boundary for hypothetical reuse. Moving tests risks blurring app-composition tests with UI-rendering tests; keeping the runtime seam and the colocated unit tests avoids that. diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index 8448d09dda..e079aa014b 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -9,8 +9,8 @@ import { afterEach, describe, expect, it } from 'vitest' * Keyless Loader-path smoke for examples/coding-agent: boot the REAL example * through the `@deepseek-ai/dsh-stdio-agent` bin against its `cordis.yml` (the * cordis Loader, `unwrapExports`, the full plugin tree incl. the - * `@deepseek-ai/dsh-agent-core` bundle and the extracted - * `@deepseek-ai/dsh-ui-stdio`), then close stdin with no prompt and assert the + * `@deepseek-ai/dsh-agent-core` bundle and the app's in-package readline UI + * module), then close stdin with no prompt and assert the * ready banner + a clean exit. * * No prompt is ever sent, so the model is NEVER called — this is why it runs diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index 944a36e429..ec02e58f5b 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -13,7 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest' * * This is the guard the per-file unit suite structurally cannot be: it drives * the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core` - * bundle it loads, the extracted `@deepseek-ai/dsh-ui-stdio` plugin, AND the + * bundle it loads, the app's in-package readline UI module, AND the * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so * a broken plugin export shape (a stray `export default` that `unwrapExports` * would collapse, dropping `inject`/`Config`) fails here even though hand-mounted diff --git a/packages/README.md b/packages/README.md index 2233123772..da17075d36 100644 --- a/packages/README.md +++ b/packages/README.md @@ -53,7 +53,6 @@ dsh-llm-pi-ai ← dsh-llm (pi-ai-backed adapter) dsh-agent-loop ← dsh-llm, dsh-session, dsh-session-persistence, dsh-system-prompt, dsh-tools, dsh-agent dsh-invariants ← dsh-llm, dsh-session, dsh-agent (dev-mode contract checks) dsh-acp ← dsh-agent, dsh-llm, dsh-session, dsh-session-persistence, dsh-tools (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-inprocess ← dsh-subagent, dsh-agent, dsh-session, dsh-llm (shared in-process run driver) @@ -64,7 +63,7 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool) dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log) 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-stdio-agent ← dsh-agent-core, dsh-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + readline UI + bin) dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) ``` @@ -105,7 +104,6 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | -| `ui-stdio/` | `support` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | `support` | Record/replay adapter: short-circuits `llm/stream` with chunks from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent/` | `subagent` | Abstract subagent seam: named-provider registry for delegating to child agents | `ctx.subagents` | | `subagent-inprocess/` | `subagent` | Shared in-process subagent run driver used by spawn/fork; pure library, registers nothing | (none) | diff --git a/packages/support/README.md b/packages/support/README.md index 942734fc1e..233a32d77f 100644 --- a/packages/support/README.md +++ b/packages/support/README.md @@ -5,8 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than | Package | Role | ctx key | |---|---|---| | `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) | -| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) | | `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) | | `subagent-mock/` | Scripted `SubagentProvider` for deterministic seam/tool tests | (registers on `ctx.subagents`) | -`invariants` runs only in dev mode (contract checks, not runtime behavior). `ui-stdio` and `llm-replay` were extracted from the examples for reuse and to bring them under the per-file coverage gate; they back the demos and the snapshot test tier. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. +`invariants` runs only in dev mode (contract checks, not runtime behavior). `llm-replay` backs the demos and the snapshot test tier under the per-file coverage gate. `subagent-mock` exercises the real `ctx.subagents` load path without a model or child agent. A package graduates OUT of `support/` into a product group only when it gains documented product consumers. diff --git a/packages/support/ui-stdio/README.md b/packages/support/ui-stdio/README.md deleted file mode 100644 index 922202695d..0000000000 --- a/packages/support/ui-stdio/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# @deepseek-ai/dsh-ui-stdio - -A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it consumes the `session/event` transcript feed plus a few `agent/*` control events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface. - -This is a **convenience REPL for local testing and the demos, not a product surface** — its observable behavior is free to change. It is deliberately NOT treated as a load-bearing consumer when weighing whether a live event/API must exist: the boundary mirror events were removed precisely because "ui-stdio renders from them" is not a product constraint (it was migrated to `session/event`). The real product surfaces are the ACP bridge (`dsh-acp`) and the app packages. - -This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`. - -## Config - -| Key | Type | Default | Notes | -|---|---|---|---| -| `welcome` | string | `'ready.'` | Banner printed once on start, before the first `> ` prompt. | -| `agent` | string | `'main'` | Id of the agent that stdin **drives** (`send`/`steer`) and whose `agent/status` gates the EOF exit. Rendering is **not** scoped by it — see below. | - -```yaml -- id: ui-stdio - name: '@deepseek-ai/dsh-ui-stdio' - config: - welcome: 'agent REPL ready. Give it a coding task.' -``` - -## Rendering - -Rendering is **global** — every agent's events are written to stdout, not just `config.agent`'s. `config.agent` scopes only *input* (which agent stdin drives) and the EOF-exit gate; the single-agent demos this serves have just one agent, so the distinction is moot for them. (A multi-agent UI that needs per-agent panes would filter these handlers by the agent argument — deliberately out of scope here.) - -- `session/event` — the durable transcript feed drives ALL rendering, from a single listener so `inReasoning` transitions stay deterministic in append order: `assistant/chunk` writes the model's `text-delta` verbatim and wraps `reasoning-delta` in the dim SGR (`\x1B[2m … \x1B[0m`) so the chain-of-thought is visually subordinate to the answer (inert when no `reasoning-delta` chunks arrive, e.g. a mock model); `turn/start` prints a `[<agent> turn N]` header (the short agent label comes from a session-id→agent-id map seeded from `ctx.agents.list()` at install and kept live via `agent/created`/`agent/disposed`, since the turn event carries only the turn number); `turn/end` prints the trailing `> ` prompt; `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`; and `todo/write` renders a glyphed checklist. - -## The I/O seam - -The production entry point `apply(ctx, config)` binds the real `process` streams. The testable core is `createStdioChat(ctx, config, runtime)`, where `runtime: StdioRuntime` supplies `input` / `output` / `exit`. This seam is deliberately **not** part of the serializable `Config` (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals. - -## Piped-stdin exit - -On stdin EOF the plugin exits the process, but carefully: - -- **No work submitted** (empty stdin, blank-only lines): exit immediately — no turn will ever start, so there is nothing to wait for. Gating on an observed `running` here would hang forever. -- **Work submitted**: exit the next time the agent settles to `idle` *after* having been observed `running`. `agent.send()` does not synchronously flip status to `running`, so requiring an observed `running` first (`sawRunning`) avoids exiting in the gap before the turn starts and dropping work; and the loop batches several queued messages into one turn, so the exit keys off the idle transition rather than counting sends. - -Disposal (HMR or fiber teardown) closes the readline interface, which also fires `close` — a `disposed` guard ensures teardown never calls `process.exit`. - -## 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)). The keyless Loader-path e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end. diff --git a/packages/support/ui-stdio/package.json b/packages/support/ui-stdio/package.json deleted file mode 100644 index 5d65356e79..0000000000 --- a/packages/support/ui-stdio/package.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-ui-stdio", - "description": "Minimal stdio (readline) UI plugin: renders agent/* events to stdout and feeds stdin lines to the agent", - "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", - "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-session": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/support/ui-stdio/tsconfig.json b/packages/support/ui-stdio/tsconfig.json deleted file mode 100644 index b333de0302..0000000000 --- a/packages/support/ui-stdio/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/todo/README.md b/packages/todo/README.md index df258fab0c..3f427295c1 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -6,4 +6,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio UI](../support/ui-stdio) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs render off `session/event`: the [stdio app's readline UI](../ui/stdio-agent) prints the list, the [ACP bridge](../ui/acp) maps it to a `plan` sessionUpdate. diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index fc6dc94860..b27cc65227 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -18,7 +18,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio UI](../../support/ui-stdio) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). +The tool writes only the session event; it does not render. UIs subscribe to `session/event` and render the `todo/write` data themselves: the [stdio app's readline UI](../../ui/stdio-agent) prints a glyphed checklist, and the [ACP bridge](../../ui/acp) maps the list to a `plan` sessionUpdate (synthesizing the `priority` ACP requires). ## Export shape diff --git a/packages/ui/README.md b/packages/ui/README.md index 659519407c..1a3c45727d 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -8,6 +8,6 @@ Integrations that expose the agent to an external editor or client. These are ** | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | -A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline `ui-stdio` plugin is the unstructured analogue but lives in `support/` because it exists chiefly for the examples and the coverage gate — `ui/` is reserved for surfaces shipped as product. +A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. `stdio-agent` and `acp-agent` are the two **app packages**: each composes the [`core/agent-core`](../core/agent-core/README.md) spine with its coupled front-door cluster (and owns the boot `bin`), so a leaf `cordis.yml` is the swappable backends plus one app entry plus any optional product tools. They live in `ui/` because each IS a user-facing front door; the stdout-purity coupling (logger vs. no logger) becomes a property of the artifact rather than a leaf convention. diff --git a/packages/ui/stdio-agent/README.md b/packages/ui/stdio-agent/README.md index 550b52c1ea..c75fee966a 100644 --- a/packages/ui/stdio-agent/README.md +++ b/packages/ui/stdio-agent/README.md @@ -13,7 +13,7 @@ A terminal chat always wants the same cluster, so the package owns it rather tha | `@cordisjs/plugin-logger-console` | the console logger — stdout is just the terminal here, so logging to it is correct (the ACP app must NOT have this) | | `@deepseek-ai/dsh-agent-core` | the spine, pre-creating a `main` agent from this app's `model`/`systemPrompt` | | `@deepseek-ai/dsh-session-persistence-jsonl` | durable JSONL session log under `persistenceRoot` | -| `@deepseek-ai/dsh-ui-stdio` | the readline UI, bound to the `main` agent | +| `stdio-chat` (in-package module) | the readline UI, bound to the `main` agent | `@cordisjs/plugin-hmr` (the dev/demo edit-reload loop) is deliberately a **leaf** entry, NOT baked in here: it is a Loader-only, subprocess-only dev plugin — its constructor throws without `node --expose-internals` + a live `loader`, and the in-process test tier cannot even import it (so a package whose `apply` statically pulled it in could never carry the per-file coverage gate). Unlike the console logger, a stray `hmr` is not a stdout-purity footgun, so leaving it at the leaf costs no safety. The `demo:echo` / `demo:repl` leaves load it and pass `--expose-internals`. diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index bc9c98a411..f6db23a232 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-ui-stdio": "^0.0.1", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" }, @@ -49,7 +48,6 @@ "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-ui-stdio": "workspace:^", "cordis": "^4.0.0-rc.6", "schemastery": "^3.17.0" } diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index df42b3115b..6a854dfe29 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -1,12 +1,13 @@ /** * The stdio chat app: the providerless agent spine ({@link * @deepseek-ai/dsh-agent-core}) plus the coupled front-door cluster a terminal - * chat needs — a console logger, the readline `ui-stdio` UI, JSONL session + * chat needs — a console logger, the readline UI (the in-package `stdio-chat` + * module), JSONL session * persistence, and a pre-created `main` agent the UI drives. * * The cluster is BAKED IN, not left to the leaf: a stdio app always logs to the * console (stdout is just the terminal) and always pre-creates the `main` agent - * `ui-stdio` sends to. The leaf supplies the swappable backends (the LLM + * the readline UI sends to. The leaf supplies the swappable backends (the LLM * adapter, the bash executor), optional product tools, the optional `hmr` * dev-reload plugin, and this app's {@link Config} (model, prompt, persistence * root, welcome banner). @@ -42,7 +43,7 @@ import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import * as agentCore from '@deepseek-ai/dsh-agent-core' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import * as uiStdio from '@deepseek-ai/dsh-ui-stdio' +import * as uiStdio from './stdio-chat.ts' export const name = 'stdio-agent' @@ -81,7 +82,7 @@ export const Config: z<Config> = z.object({ * Compose the spine with the stdio front door. The console logger comes first * (infra), then the agent-core bundle pre-creating the `main` agent from this * app's `model`/`systemPrompt`/`resumeSessionId`, then the JSONL backend, then - * the `ui-stdio` UI bound to `main`. The `hmr` dev-reload plugin is a leaf + * the readline UI bound to `main`. The `hmr` dev-reload plugin is a leaf * concern (see the module doc), so it is not mounted here. */ export function apply(ctx: Context, config: Config): void { diff --git a/packages/support/ui-stdio/src/index.ts b/packages/ui/stdio-agent/src/stdio-chat.ts similarity index 88% rename from packages/support/ui-stdio/src/index.ts rename to packages/ui/stdio-agent/src/stdio-chat.ts index 8bee2e4baa..2996e11e89 100644 --- a/packages/support/ui-stdio/src/index.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -1,23 +1,18 @@ /** - * Minimal stdio UI plugin: reads lines from stdin → `agent.send()`/`steer()`, - * and renders the durable transcript to stdout. A UI is "just a plugin" — it - * consumes the `session/event` feed (the assistant token stream, turn/step - * boundaries, tool activity, todos) plus a few `agent/*` control events - * (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` service, - * so the same plugin drives any example or product surface. + * The stdio app's readline UI: reads lines from stdin → `agent.send()`/ + * `steer()`, and renders the durable transcript to stdout. A UI is "just a + * plugin" — it consumes the `session/event` feed (the assistant token stream, + * turn/step boundaries, tool activity, todos) plus a few `agent/*` control + * events (`agent/status`, `agent/created`/`agent/disposed`) and the `agents` + * service. Dimmed chain-of-thought rendering plus robust piped-stdin EOF→idle + * exit handling, configured via {@link Config}. * - * Consolidates what were two near-identical copies under `examples/echo-agent` - * and `examples/coding-agent` (the latter a superset). This package IS that - * superset: dimmed chain-of-thought rendering plus the robust piped-stdin - * EOF→idle exit handling, configured per consumer via {@link Config}. + * An internal module of the stdio app, not a package of its own: the app's + * front-door cluster always includes this UI, and nothing else composes it. + * The export shape stays named `name`/`inject`/`Config`/`apply` — the plugin + * contract the app's `ctx.plugin(uiStdio, …)` mount consumes. * - * 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 collapse the module to the bare function and drop - * the `inject` namespace (see docs/postmortem/0001). The keyless Loader-path - * e2e smokes in `examples/{echo,coding}-agent` guard this end-to-end. - * - * @module @deepseek-ai/dsh-ui-stdio + * @module @deepseek-ai/dsh-stdio-agent/stdio-chat */ import { createInterface } from 'node:readline' diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 7605b35bb7..30bc952673 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'support/invariants', 'support/ui-stdio', + 'bash/tool-bash', 'support/invariants', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', ] diff --git a/packages/support/ui-stdio/tests/readline.spec.ts b/packages/ui/stdio-agent/tests/readline.spec.ts similarity index 92% rename from packages/support/ui-stdio/tests/readline.spec.ts rename to packages/ui/stdio-agent/tests/readline.spec.ts index 5e092fb913..ae3c480756 100644 --- a/packages/support/ui-stdio/tests/readline.spec.ts +++ b/packages/ui/stdio-agent/tests/readline.spec.ts @@ -2,7 +2,7 @@ import { EventEmitter } from 'node:events' import type { Readable, Writable } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import type { Context } from 'cordis' -import type { StdioRuntime } from '../src/index.ts' +import type { StdioRuntime } from '../src/stdio-chat.ts' const createInterface = vi.hoisted(() => vi.fn(() => { const reader = new EventEmitter() as EventEmitter & { close(): void } @@ -32,7 +32,7 @@ function fakeRuntime(inputIsTTY: boolean, outputIsTTY: boolean): StdioRuntime { describe('createStdioChat readline mode', () => { it('enables terminal editing only when both stdio streams are TTYs', async () => { - const { createStdioChat } = await import('../src/index.ts') + const { createStdioChat } = await import('../src/stdio-chat.ts') const tty = fakeRuntime(true, true) createStdioChat(fakeContext(), {}, tty) diff --git a/packages/support/ui-stdio/tests/ui-stdio.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts similarity index 99% rename from packages/support/ui-stdio/tests/ui-stdio.spec.ts rename to packages/ui/stdio-agent/tests/stdio-chat.spec.ts index e991370c39..090b77ac64 100644 --- a/packages/support/ui-stdio/tests/ui-stdio.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -5,7 +5,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts' +import { createStdioChat, type Config, type StdioRuntime } from '../src/stdio-chat.ts' /** * Unit tests for the stdio UI plugin. They drive the REAL plugin body diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 58b492a549..948813c370 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -31,9 +31,6 @@ }, { "path": "../../session-persistence/session-persistence-jsonl" - }, - { - "path": "../../support/ui-stdio" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8045aa9cc3..8950475e0c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -774,25 +774,6 @@ 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/ui-stdio: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - 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/todo/tool-todo: devDependencies: '@deepseek-ai/dsh-agent': @@ -922,9 +903,6 @@ importers: '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-ui-stdio': - specifier: workspace:^ - version: link:../../support/ui-stdio cordis: specifier: ^4.0.0-rc.6 version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) diff --git a/tsconfig.build.json b/tsconfig.build.json index d1a369036e..477c68d31e 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -42,7 +42,6 @@ { "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" }, diff --git a/tsconfig.json b/tsconfig.json index c43e6690c6..7d1209b42e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -53,7 +53,6 @@ { "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" }, From de79553d4f841f247ac0abaf3ce3ae87938720e8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:42:56 +0800 Subject: [PATCH 07/32] refactor(web): drop the unconsumed observation surface WebService exposed an observation surface nothing in production observes: the web/providers-change event (declared, emitted on every provider registration/disposal, rollback-yield ordered before the emit solely so a throwing change listener unwinds the registration) and the aggregated searchStatus()/fetchStatus() query with its WebCapabilityStatus union. dsh-tool-web executes through ctx.web.search()/fetch() and routes on the structured WebError codes selection throws at execution time; tool registration follows product enablement, not provider availability. The only listeners/callers were the web packages' own tests, and the tool-web README / architecture.md prose claiming the tool 'reads only the aggregated searchStatus()/fetchStatus()' had drifted from the call sites. Remove the event declaration, both emits, and the rollback-before-emit machinery (the plain ctx.effect disposer keeps HMR cleanup, matching LlmService.registerAdapter). Remove searchStatus()/fetchStatus(), resolveStatus(), and WebCapabilityStatus; the provider-private status() stays as the execution-time selection input. Delete the listener-throw rollback test, and rewrite every event/status assertion across the web packages' tests onto caller-observable behavior: a successful search()/fetch() or the structured WEB_PROVIDER_* codes. Regenerate the cordis catalog; update the web/tool-web READMEs, the architecture.md web paragraph, core-data-structures/web.md, and the type-equiv manifest; amend the web capability seam RFC's facts to the shipped surface. This follows the llm/adapter-change precedent: a boot-time backend-registry signal and an availability probe distinct from executing both sit on the cut side of its keep/cut criterion. RFC: docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md --- docs/architecture.md | 2 +- docs/cordis-catalog/events-and-services.md | 26 +--- docs/core-data-structures/web.md | 16 +-- docs/rfc/README.md | 2 +- .../2026-06-24-web-capability-seam.md | 57 ++++---- ...drop-unconsumed-web-observation-surface.md | 6 +- packages/web/tool-web/README.md | 2 +- packages/web/tool-web/tests/tool-web.spec.ts | 7 +- .../web-fetch-local/tests/fetch-local.spec.ts | 9 +- .../tests/deepseek.spec.ts | 13 +- packages/web/web-search-exa/tests/exa.spec.ts | 10 +- .../tests/perplexity.spec.ts | 10 +- packages/web/web/README.md | 23 ++-- packages/web/web/src/index.ts | 100 +++----------- packages/web/web/src/types.ts | 22 +--- packages/web/web/tests/web.spec.ts | 124 ++++++------------ scripts/type-equiv.manifest.json | 3 +- 17 files changed, 146 insertions(+), 286 deletions(-) rename docs/rfc/{proposed => implemented}/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md (79%) diff --git a/docs/architecture.md b/docs/architecture.md index 0266f1a2e9..3db26e4840 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -87,7 +87,7 @@ The LLM seam has the same topology folded differently: `dsh-llm` carries the int The filesystem capability follows the bash topology with a fourth layer, but the policy is contributed through an **event gate**, not a method service: `dsh-fs` owns the abstract `ctx.fs` provider seam (text IO + atomic mutation primitives whose version guard is optional) and the `fs/*` policy event vocabulary, `dsh-fs-local` provides the local backend, `dsh-tool-fs` is the model-facing `read`/`write`/`edit` tools AND the executor (it reads/writes/edits through `ctx.fs` directly, owns read windowing, dispatches the `fs/*` events), and `dsh-fs-policy` is a policy PLUGIN (no service) that decides the `fs/write-intent`/`fs/edit-intent` waterfalls and records on `fs/observed` to add observed-state + read-before-edit + version-guarded write/edit. Because the tool is not method-coupled to the policy, dropping `dsh-fs-policy` gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool at a service-injection boundary. The demo agents (`coding-agent`, `acp-agent`) wire the full stack — `dsh-fs-local` + `dsh-fs-policy` + `dsh-tool-fs` — so `read`/`write`/`edit` are the default file surface (bash stays for shell/tests/search); the tools resolve a relative path against the caller's session cwd, matching bash ([the per-session cwd RFC](rfc/implemented/architecture/2026-07-02-fs-per-session-cwd.md)). See [the fs-policy event-gate RFC](rfc/implemented/architecture/2026-06-26-file-context-as-event-gate.md). -The web capability uses the same three-package split but folds two capabilities onto one seam: `dsh-web` owns the abstract `ctx.web` service, which is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection, the `WebError` taxonomy) rather than a single backend. Providers register capabilities, not tools — `dsh-web-search-exa`, `dsh-web-search-perplexity`, `dsh-web-search-deepseek`, and `dsh-web-fetch-local` each register into `ctx.web` the way an `LlmAdapter` registers into `ctx.llm`, so they are namespace plugins (`inject: ['web']`), not key-owning services. `dsh-tool-web` is the single consumer that owns the model-facing `web_search`/`web_fetch` schemas, prompt sections, and presentation; it reads only the aggregated `ctx.web.searchStatus()`/`fetchStatus()` and executes through `ctx.web.search()`/`fetch()`, so provider selection has one owner. Search and fetch are deliberately one seam (one thing to inject and configure, one selection policy, one abort/error vocabulary) despite sharing no request schema — see the [web capability seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.md). +The web capability uses the same three-package split but folds two capabilities onto one seam: `dsh-web` owns the abstract `ctx.web` service, which is a provider REGISTRY (`registerSearchProvider`/`registerFetchProvider`, registration-order-independent selection, the `WebError` taxonomy) rather than a single backend. Providers register capabilities, not tools — `dsh-web-search-exa`, `dsh-web-search-perplexity`, `dsh-web-search-deepseek`, and `dsh-web-fetch-local` each register into `ctx.web` the way an `LlmAdapter` registers into `ctx.llm`, so they are namespace plugins (`inject: ['web']`), not key-owning services. `dsh-tool-web` is the single consumer that owns the model-facing `web_search`/`web_fetch` schemas, prompt sections, and presentation; it executes through `ctx.web.search()`/`fetch()`, which resolve the provider at execution time and throw the structured `WebError` codes the tool surfaces, so provider selection has one owner. Search and fetch are deliberately one seam (one thing to inject and configure, one selection policy, one abort/error vocabulary) despite sharing no request schema — see the [web capability seam RFC](rfc/implemented/architecture/2026-06-24-web-capability-seam.md). > **"Capability" — two unrelated meanings.** (1) The *seam pattern* above ("one plugin provides a capability, another needs it") is realized by plain Cordis **services + `inject`**: a provider registers a service (`ctx.bash`, declared in `interface Context`); a consumer declares `inject: ['bash']` and its fiber stays pending until the service exists, tearing down via HMR if it later vanishes. No extra library is needed. (2) `@cordisjs/plugin-capability` is a different axis entirely — a **permission/capability-security** service (named permissions with inheritance/dependency, tested against a session via `ctx.capability.test`). It is a candidate for the deferred permissions/sandbox work (the `tools/pre-execute` deny/ask gate), NOT a mechanism for swapping implementations. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index ac7ea069b9..fdaee10bac 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -327,18 +327,6 @@ Types: [ToolExecution](../core-data-structures/tools.md) Source: [`packages/core/tools/src/index.ts:65`](../../packages/core/tools/src/index.ts) -### `web/*` - -#### `web/providers-change` — emit - -Fired after the provider registry changes — a search or fetch provider was registered or disposed. Carries no payload and no capability graph: it means only "the provider registry changed; observers may recompute status from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not stored. - -```ts cordis-catalog -'web/providers-change'(this: WebService): void -``` - -Source: [`packages/web/web/src/index.ts:65`](../../packages/web/web/src/index.ts) - ## Services The `ctx.<key>` services the harness provides. An abstract seam (e.g. `ctx.bash`) is implemented by a separate package; the interface is what consumers code against. @@ -543,25 +531,23 @@ Source: [`packages/core/tools/src/index.ts:265`](../../packages/core/tools/src/i The web access service. Registered as `ctx.web` (one instance per context). -Selection semantics (identical for status and execution, never order- dependent): +Selection semantics (resolved at execution time, never order-dependent): - A configured id that is registered and `status().available` → that provider. -- A configured id not registered → `configured-missing` / `WEB_PROVIDER_CONFIGURED_MISSING`. -- A configured id registered but unavailable → `configured-unavailable` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. +- A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. +- A configured id registered but unavailable → `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. - No id configured, exactly one registered usable provider → that provider. -- No id configured, multiple usable providers → `ambiguous` / `WEB_PROVIDER_AMBIGUOUS`. -- No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`. +- No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`. +- No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. ```ts cordis-catalog registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void -searchStatus(): WebCapabilityStatus -fetchStatus(): WebCapabilityStatus async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> ``` -Source: [`packages/web/web/src/index.ts:105`](../../packages/web/web/src/index.ts) +Source: [`packages/web/web/src/index.ts:87`](../../packages/web/web/src/index.ts) ## Inherited tier (cordis core + loader/hmr/timer) diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 1adde3bd75..cbce5bd523 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -73,9 +73,9 @@ type WebFetchBody = | { readonly kind: 'text'; readonly content: string } ``` -## Provider and capability status +## Provider status -A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to selection, not a health system. +A provider's `status()` is a cheap LOCAL check (credential presence, parseable config) and **must not make network calls**. It is an input to execution-time selection, not a health system: `search()`/`fetch()` read it to pick a usable provider, and a selection failure surfaces as the structured `WebError` the caller routes on — which carries the branchable detail (the missing id, the ambiguous candidate set) in its code and message. ```ts type-equiv type WebProviderStatus = @@ -83,15 +83,7 @@ type WebProviderStatus = | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } ``` -The service aggregates provider status into a `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category in which selection fails. It carries the winning `providerId` on the available branch but NOT the per-reason payload (the missing id, the ambiguous set) — that branchable detail lives in the thrown `WebError`, the surface callers route on, so the same fact never gets two homes that can disagree. - -```ts type-equiv -type WebCapabilityStatus = - | { readonly available: true; readonly providerId: string } - | { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' } -``` - -Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `ambiguous`, not first-wins. +Selection never depends on registration, config, or HMR order: a capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or the matching env var feeding the same field), or auto-selects when exactly one usable provider is registered; multiple usable providers with no configured id is `WEB_PROVIDER_AMBIGUOUS`, not first-wins. ## Errors @@ -99,4 +91,4 @@ Selection never depends on registration, config, or HMR order: a capability has ## The service -`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers, emit `web/providers-change`), `searchStatus`/`fetchStatus` (derived, never stored), and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. +`WebService` (`ctx.web`, defined in [`packages/web/web/src/index.ts`](../../packages/web/web/src/index.ts)) is a provider registry plus a provider-selecting execution surface, close to `LlmService`'s shape: `registerSearchProvider`/`registerFetchProvider` (duplicate ids throw `WEB_DUPLICATE_PROVIDER`, return disposers) and `search`/`fetch` (resolve the provider at call time, throw a structured `WebError` when the capability cannot run). Providers issue requests with the platform-native `fetch` (Node 24), mirroring `dsh-llm-deepseek`; the `dsh-web-fetch-local` provider owns safe retrieval (http/https-only, credential rejection, byte/char/timeout/redirect caps, same-origin-only redirects with per-hop re-validation, charset decoding) while `dsh-tool-web` owns presentation (HTML→markdown). SSRF / private-network blocking is deferred (see the RFC) — until it lands, `web_fetch` must not be enabled where it can reach sensitive internal targets. diff --git a/docs/rfc/README.md b/docs/rfc/README.md index d53684bf6b..6ea49aaf67 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -54,7 +54,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | | [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | | [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | -| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 55ada9d576..d0ca9c8b9a 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -22,7 +22,7 @@ Introduce web access as a first-class capability seam following [the capability- Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation. -Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/status/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below. +Search and fetch are separate capabilities and separate model-facing tools, but they are deliberately one seam. `ctx.web` is a single web-access middle layer between provider packages on one side and the tool consumer on the other: one service to inject, one provider-selection policy owner, one abort/error vocabulary, one place a product configures "how this harness reaches the web." The two halves do not share a request schema and have no shared business logic — search normalizes provider-backed discovery into a portable result with optional answer text and citeable sources, while fetch retrieves a concrete public HTTP(S) URL and returns a status code plus bounded decoded content — but they are parallel registries on one capability surface, not two surfaces. The cost is a `WebService` whose registry/exec methods come in `Search`/`Fetch` pairs; that parallelism is intentional, not a missed extraction. Splitting into `dsh-search` and `dsh-fetch` is the rejected alternative below. `dsh-tool-web` should register model-facing web tools when the product has enabled those tools and the `ctx.web` seam is present. Backend availability is an execution-time concern, not a schema-registration concern: @@ -33,7 +33,7 @@ Search and fetch are separate capabilities and separate model-facing tools, but This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`. -The first version's provider-change signal is intentionally small. `web/providers-change` has no payload, carries no capability graph, and does not expose provider metadata. It means only "the provider registry changed; observers may recompute status from `ctx.web`." `searchStatus()` and `fetchStatus()` remain derived, not stored, and they are diagnostics plus execution-resolution inputs rather than tool-schema visibility switches. +The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface RFC](../simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. ## Package topology @@ -52,7 +52,7 @@ The dependency direction mirrors bash and filesystem: implementation ``` -At runtime, provider packages register capabilities with `ctx.web`; `tool-web` reads capability status and registers stable tools with `ctx.tools`: +At runtime, provider packages register capabilities with `ctx.web`; `tool-web` registers stable tools with `ctx.tools` and executes through the seam: ```mermaid flowchart LR @@ -60,12 +60,12 @@ flowchart LR perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web fetchLocal["@deepseek-ai/dsh-web-fetch-local"] -->|registerFetchProvider| web - toolWeb["@deepseek-ai/dsh-tool-web"] -->|searchStatus/fetchStatus| web + toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web toolWeb -->|ctx.tools.register| webSearch["tool: web_search"] toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"] ``` -`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, status types, and error codes. It does not import tool, agent, session, LLM, or provider packages. +`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider status type, and error codes. It does not import tool, agent, session, LLM, or provider packages. Provider packages depend on `@deepseek-ai/dsh-web` and Cordis. They own credentials, endpoint config, provider-specific request mapping, provider-specific response parsing, and provider-specific error translation into `WebError`. They issue network requests with the platform-native `fetch` (Node 24), mirroring `@deepseek-ai/dsh-llm-deepseek`'s adapter, NOT a cordis HTTP-client service (`ctx.http`/`@cordisjs/plugin-http`) — even where a Perplexity provider's request is shaped like an OpenAI-compatible chat completion, that wire shape is a provider-private detail and does not make the provider depend on `ctx.llm`. A provider does NOT own the `ctx.web` key (two search providers cannot both own it): like `dsh-llm-deepseek`, each provider package is a function/namespace plugin (`inject: ['web']`) whose `apply` constructs the backend and calls `ctx.web.registerSearchProvider` / `registerFetchProvider`. `@deepseek-ai/dsh-web` is the `export default` service that owns the key. @@ -92,9 +92,6 @@ interface WebService { registerSearchProvider(provider: WebSearchProvider): () => void registerFetchProvider(provider: WebFetchProvider): () => void - searchStatus(): WebCapabilityStatus - fetchStatus(): WebCapabilityStatus - search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> } @@ -106,39 +103,33 @@ interface WebExecContext { `WebExecContext` is execution control, not business input. The first version should carry only `signal` so `tool-web` can propagate turn cancellation, tool timeout, and agent disposal into provider network requests, SSE readers, and expensive decoding. It should not pass `ToolExecution` through the seam, because that would make `dsh-web` depend on `dsh-tools`. -`@deepseek-ai/dsh-web` should also declare a Cordis event named `web/providers-change`. Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer, emits `web/providers-change` after successful registration, and emits it again when the provider is disposed. The registry should follow the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()`, install the rollback disposer before emitting `web/providers-change`, and let a throwing registration-time change listener roll back the just-added provider instead of leaking it into the registry. +Provider ids are stable strings and unique within their capability kind. Registering a duplicate search provider id or duplicate fetch provider id should fail rather than silently replace the old provider. Provider registration returns a disposer and follows the existing `ctx.tools.register()` / `ctx.systemPrompt.section()` pattern: wrap the mutation in `ctx.effect()` so the registration is torn down with the contributing fiber. ## Provider status and selection -Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. The service reports whether the capability has a selected usable provider, or why execution would fail. +Provider status and capability selection are separate concepts, but both stay minimal. A provider reports only whether that concrete implementation is usable by cheap local checks such as credential presence or parseable endpoint config. A provider `status()` must not make network calls. -`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` needs a small status answer because product apps, diagnostics, tests, and execution can report precise provider-selection failures without probing individual providers from the tool layer. Status must be derived from the configured provider id, registered providers, and each provider's cheap local `status()` on each call; it must not be stored as mutable service state. +`LlmService` has no status type at all: availability is expressed as registry membership plus a resolution-time throw. `ctx.web` follows the same discipline. The seam exposes no aggregated capability-status query — `search()` / `fetch()` derive the selection on each call from the configured provider id, the registered providers, and each provider's cheap local `status()`, and a selection failure is the structured `WebError` thrown at execution time, whose code answers "in which broad category does this capability fail" and whose message answers "exactly which provider/ids/reason." A caller that needs to know whether a capability can run executes and routes that error; nothing is stored as mutable service state. -`WebCapabilityStatus` stays intentionally small: `available` plus a `reason` discriminant, and the selected `providerId` on the available branch so diagnostics can report which provider won. It does NOT carry the per-reason payload (the unavailable provider id, the ambiguous candidate set, the underlying provider-unavailable reason). That branchable detail lives in the structured `WebError` thrown at execution time, which is the surface callers route on; duplicating it into the status union would give the same fact two homes that can disagree. `searchStatus()` / `fetchStatus()` answer "can this capability run, and if not, in which broad category does it fail" — enough for startup diagnostics and the execution-resolution decision — and the thrown error answers "exactly which provider/ids/reason." - -`WebProviderStatus` is an input to selection, not a health system. `tool-web` reads only the aggregated `searchStatus()` / `fetchStatus()`, never each provider's `status()` directly, so selection policy has one owner. +`WebProviderStatus` is an input to selection, not a health system. `tool-web` never calls a provider's `status()` directly — its only path into the seam is `search()` / `fetch()` — so selection policy has one owner. ```ts type WebProviderStatus = | { readonly available: true } | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } - -type WebCapabilityStatus = - | { readonly available: true; readonly providerId: string } - | { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' } ``` Selection must not depend on registration order. Cordis load order, config ordering, and HMR timing are not product semantics. -| Situation | Status / behavior | +| Situation | Execution behavior | |---|---| -| A configured provider id is registered and `status().available === true` | `available: true` for that provider | -| A configured provider id is not registered | `configured-missing`; execution fails with `WEB_PROVIDER_CONFIGURED_MISSING` | -| A configured provider id is registered but unavailable | `configured-unavailable`; execution fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | -| No provider id is configured and exactly one provider for that kind is registered and available | `available: true` for that single provider | -| No provider id is configured and no provider for that kind is registered | `none`; execution fails with `WEB_PROVIDER_UNAVAILABLE` | -| No provider id is configured and multiple usable providers for that kind are registered | `ambiguous`; execution fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order | -| No provider id is configured and providers exist but none are usable | `none`; execution fails with `WEB_PROVIDER_UNAVAILABLE` | +| A configured provider id is registered and `status().available === true` | runs that provider | +| A configured provider id is not registered | fails with `WEB_PROVIDER_CONFIGURED_MISSING` | +| A configured provider id is registered but unavailable | fails with `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| No provider id is configured and exactly one provider for that kind is registered and available | runs that single provider | +| No provider id is configured and no provider for that kind is registered | fails with `WEB_PROVIDER_UNAVAILABLE` | +| No provider id is configured and multiple usable providers for that kind are registered | fails with `WEB_PROVIDER_AMBIGUOUS` rather than choosing by registration order | +| No provider id is configured and providers exist but none are usable | fails with `WEB_PROVIDER_UNAVAILABLE` | The "single provider auto-selects" rule is for tests, demos, and simple deployments. Product configs should set explicit provider ids: @@ -167,7 +158,7 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme Operational overrides such as environment variables may exist, but they must feed the same explicit selection path. For example, `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`; it is not a hidden priority chain inside `dsh-tool-web`. -`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the same rules as the status query. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the status and execution error are both the generic `none` / `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider. +`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; the first version should not add a diagnostic summary of every unavailable provider. ## Search request and result schema @@ -269,14 +260,14 @@ SSRF / private-network protection (blocking private, loopback, link-local, multi `dsh-tool-web` owns two `ToolDefinition`s: `web_search` and `web_fetch`. It owns model-facing JSON schemas, snake_case argument names, prompt sections, result rendering to `ContentBlock[]`, `presentCall`, and `presentResult`. -`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its execution path is `ctx.web.search()` / `ctx.web.fetch()`, and any optional startup diagnostics should read only `ctx.web.searchStatus()` / `ctx.web.fetchStatus()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. +`dsh-tool-web` must not enumerate providers or call provider `status()` directly. Its only path into the seam is `ctx.web.search()` / `ctx.web.fetch()`. That keeps provider selection in one layer; otherwise the tool package could decide one provider is usable while execution resolves a different state. Tool registration in the first version is a minimal stable sync: 1. On plugin startup, read the `dsh-tool-web` `Config` (`search?: boolean`, `fetch?: boolean`, both default `true`) that enables or disables each web tool. 2. If web search is enabled, register `web_search` (its disposer is fiber-scoped via the effect-based registry). 3. If web fetch is enabled, register `web_fetch` (likewise fiber-scoped). -4. Do not dispose either tool merely because `ctx.web.searchStatus()` or `ctx.web.fetchStatus()` is unavailable. +4. Do not dispose either tool merely because its selected provider is missing, unusable, or ambiguous. 5. Disposing the `tool-web` fiber tears down its registrations automatically. Provider status changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. @@ -311,7 +302,7 @@ Tool execution should let these errors flow through `ToolRegistry.execute()`, wh Tests should prove the seam contract without turning this RFC into an implementation checklist. -`dsh-web` tests cover provider registration and disposal, duplicate provider ids, `web/providers-change` emission, rollback when a registration-time `web/providers-change` listener throws, `searchStatus()` and `fetchStatus()` for the selection table above, execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes. +`dsh-web` tests cover provider registration and disposal (proved through execution behavior — a registered provider serves `search()`/`fetch()`, a disposed one no longer resolves), duplicate provider ids, the selection table above exercised through execution-time provider resolution, `maxResults` truncation of `sources[]` with `truncated` set when a provider over-returns, abort propagation through `WebExecContext.signal`, and structured `WebError` codes. Search provider tests cover request mapping, response parsing into `content` plus `sources[]`, missing credentials, provider errors, timeout/abort, truncation, and a self-skipping with-key smoke test for each real provider. Perplexity fixtures must include URL-only citations so the optional source fields stay honest. @@ -329,7 +320,7 @@ This is new capability work, so no compatibility migration is required while the Land the work in seam order: -1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, capability status, selection, request/result/error types, and contract tests. +1. Add `packages/web/web` with `ctx.web`, provider registration, provider status, selection, request/result/error types, and contract tests. 2. Add `packages/web/web-search-exa` with parser/unit tests and a self-skipping real-provider smoke test. 3. Add `packages/web/web-search-perplexity` with parser/unit tests and a self-skipping real-provider smoke test. 4. Add `packages/web/web-search-deepseek` with parser/unit tests and a self-skipping real-provider smoke test. @@ -370,7 +361,7 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p **Perplexity citations may be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` must render useful fallback labels. -**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface `configured-missing`, `configured-unavailable`, and `ambiguous` loudly during startup diagnostics so users do not discover setup problems only after the model calls the tool. +**Stable tool registration can defer misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool. **Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path must resolve again and fail with a structured error. @@ -388,5 +379,5 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p ## Open questions -- Should product app packages treat `configured-missing`, `configured-unavailable`, and `ambiguous` as fatal startup errors when web is explicitly configured, or should `dsh-web` only report status and let apps decide? +- Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution? - Where should permission policy for public web access live once the deferred permission system lands: a dedicated web permission plugin on `tools/execute`, provider config, or both? diff --git a/docs/rfc/proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md similarity index 79% rename from docs/rfc/proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md rename to docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md index e3516d974f..7c5881b106 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md @@ -1,13 +1,13 @@ # RFC: Drop the unconsumed web observation surface — the `providers-change` event and the status methods -Status: proposed +Status: implemented (proposed and accepted 2026-07-04) ## Problem `WebService` exposes an observation surface no production code observes: - **`web/providers-change`** (`packages/web/web/src/index.ts`) is declared and emitted on every provider registration and disposal, and each registration effect's rollback yield is ordered BEFORE the emit solely so a throwing change listener unwinds the registration. No listener exists outside the package's own two unit tests (one of which exists to pin that rollback ordering). -- **`searchStatus()` / `fetchStatus()` and the `WebCapabilityStatus` union** (same package) have zero production callers: `dsh-tool-web` executes directly through `ctx.web.search()`/`fetch()` and surfaces unavailability as the structured `WebError` codes the seam throws at execution time (`packages/web/tool-web/src/search.ts`, `packages/web/tool-web/src/fetch.ts`); the only status callers are the web packages' own tests. The prose in `packages/web/tool-web/README.md` and [architecture.md](../../../architecture.md) still claims the tool "reads only the aggregated `searchStatus()`/`fetchStatus()`" — drift that survives only because nothing checks prose against call sites. +- **`searchStatus()` / `fetchStatus()` and the `WebCapabilityStatus` union** (same package) have zero production callers: `dsh-tool-web` executes directly through `ctx.web.search()`/`fetch()` and surfaces unavailability as the structured `WebError` codes the seam throws at execution time (`packages/web/tool-web/src/search.ts`, `packages/web/tool-web/src/fetch.ts`); the only status callers are the web packages' own tests. The prose in `packages/web/tool-web/README.md` and [architecture.md](../../../architecture.md) claims the tool "reads only the aggregated `searchStatus()`/`fetchStatus()`" — drift that survives only because nothing checks prose against call sites. The seam's own design starves both surfaces of consumers: tool registration follows product ENABLEMENT, not provider availability (`packages/web/tool-web/src/index.ts`), and provider selection resolves at execution time, never cached — so there is no cache to invalidate, no registration set to recompute, and no caller that needs an availability probe distinct from executing and routing the structured error. HMR cleanup is carried by the effect disposers themselves. @@ -15,7 +15,7 @@ This mirrors [drop the unconsumed `llm/adapter-change` event](../../implemented/ ## Proposal -Delete the event declaration, both emits, and the rollback-before-emit ordering (the plain `ctx.effect` disposer keeps HMR cleanup). Delete `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` — the provider-private `status()` stays, since it feeds execution-time selection. Delete the two event tests and rewrite the status-based test assertions onto the behavior a real caller observes (a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets). Run `pnpm run gen-cordis-catalog`; update `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md). The implementing PR amends the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specifies the event and the status aggregation) per [implemented/AGENTS.md](../../implemented/AGENTS.md). +Delete the event declaration, both emits, and the rollback-before-emit ordering (the plain `ctx.effect` disposer keeps HMR cleanup). Delete `searchStatus()`/`fetchStatus()`/`WebCapabilityStatus` — the provider-private `status()` stays, since it feeds execution-time selection. Delete the listener-throw rollback test that exists solely for the removed event, and rewrite the emission assertions and every status-based assertion onto the behavior a real caller observes (a successful `search()`/`fetch()`, or the structured `WebError` codes for unavailable/ambiguous/misconfigured provider sets). Run `pnpm run gen-cordis-catalog`; update `packages/web/web/README.md`, `packages/web/tool-web/README.md` (the drifted reads-status sentence), [web.md](../../../core-data-structures/web.md), and the web paragraph in [architecture.md](../../../architecture.md). Amend the [web capability seam RFC](../../implemented/architecture/2026-06-24-web-capability-seam.md)'s facts (it specified the event and the status aggregation) per [implemented/AGENTS.md](../AGENTS.md). ## Why not keep it? diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index f57f38d0d5..1e0a2e3336 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -27,4 +27,4 @@ Each tool is registered independently; a product that wants only one disables th Tool registration follows product **enablement**, not backend availability. A tool stays visible even when its selected provider is missing, misconfigured, ambiguous, or temporarily unavailable; the seam resolves the provider at execution time and execution fails with a structured `WebError` (e.g. `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_AMBIGUOUS`), which `ToolRegistry.execute()` turns into an error tool result the model can read and hooks/UI can route on. This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. To remove a web tool entirely, disable it here in config. -The tool reads only the aggregated `ctx.web.searchStatus()` / `fetchStatus()` for diagnostics — never each provider's `status()` directly — so provider selection has one owner. +The tool never calls a provider's `status()` and never enumerates providers — its only execution path is `ctx.web.search()` / `ctx.web.fetch()`, and provider unavailability reaches it as the structured `WebError` codes selection throws at execution time. Provider selection stays entirely inside the seam, with one owner. diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 7af1ce7c36..7b3b965c46 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -187,9 +187,12 @@ describe('tool-web registration', () => { }) it('registers web_search even when no provider is available (schema follows enablement, not availability)', async () => { - const { fiber, ctx } = await mountTools() + const { fiber, ctx, call } = await mountTools() expect(ctx.tools.schemas().map(s => s.name)).toContain('web_search') - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'none' }) + // No provider is registered: the schema stays visible and execution reports + // the structured unavailability instead. + const out = await call('web_search', { query: 'q' }) + expect(out.error?.code).toBe('WEB_PROVIDER_UNAVAILABLE') await fiber.dispose() }) diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 27ed991c08..7d4c4e683b 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -377,9 +377,11 @@ describe('web-fetch-local plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) const fiber = await ctx.plugin(fetchPlugin, {}) - expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.web.fetch({ url: `${base}/` })) + .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) await fiber.dispose() - expect(ctx.web.fetchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + await expect(ctx.web.fetch({ url: `${base}/` })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) }) it('has no default export (namespace plugin export shape)', () => { @@ -418,7 +420,8 @@ describe('web-fetch-local plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID }) const fiber = await ctx.plugin(fetchPlugin, { maxRedirects: 0 }) - expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID }) + await expect(ctx.web.fetch({ url: `${base}/` })) + .resolves.toMatchObject({ providerId: LOCAL_FETCH_PROVIDER_ID, statusCode: 200 }) await fiber.dispose() }) }) diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index ef688b7ad2..0faab1f35a 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -264,12 +264,14 @@ describe('DeepSeekSearchProvider error handling', () => { describe('web-search-deepseek plugin registration', () => { it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse()))) const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) const fiber = await ctx.plugin(deepseekPlugin, { apiKey: 'ds-key' }) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) await fiber.dispose() - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) }) it('rejects maxTokens: 0 at plugin construction', async () => { @@ -315,13 +317,14 @@ describe('web-search-deepseek plugin registration', () => { }) it('boots over ctx.web through the unwrapped module without an inject error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse(searchResponse()))) const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) const loader = Object.create(Loader.prototype) as Loader const unwrapped = loader.unwrapExports(deepseekPlugin) as Parameters<Context['plugin']>[0] // A collapsed export shape (dropped inject) would throw "without inject" here. const fiber = await ctx.plugin(unwrapped, { apiKey: 'ds-key' }) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: DEEPSEEK_PROVIDER_ID }) await fiber.dispose() }) @@ -334,7 +337,6 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) const fiber = await ctx.plugin(deepseekPlugin, {}) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: DEEPSEEK_PROVIDER_ID }) await ctx.web.search({ query: 'q' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.deepseek.com/anthropic/v1/messages') @@ -354,7 +356,8 @@ describe('web-search-deepseek plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: DEEPSEEK_PROVIDER_ID }) await ctx.plugin(deepseekPlugin, {}) - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) } finally { if (prev !== undefined) process.env.DEEPSEEK_API_KEY = prev } diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 9cf31332f5..204b18f702 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -205,12 +205,14 @@ describe('ExaSearchProvider error handling', () => { describe('web-search-exa plugin registration', () => { it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ results: [] }))) const ctx = new Context() await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) const fiber = await ctx.plugin(exaPlugin, { apiKey: 'exa-key' }) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: EXA_PROVIDER_ID }) await fiber.dispose() - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) }) it('has no default export (namespace plugin export shape)', () => { @@ -238,7 +240,6 @@ describe('web-search-exa plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) const fiber = await ctx.plugin(exaPlugin, {}) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: EXA_PROVIDER_ID }) await ctx.web.search({ query: 'q' }) const [url] = fetchMock.mock.calls[0] as unknown as [string] expect(url).toBe('https://api.exa.ai/search') @@ -256,7 +257,8 @@ describe('web-search-exa plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: EXA_PROVIDER_ID }) await ctx.plugin(exaPlugin, {}) - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) } finally { if (prev !== undefined) process.env.EXA_API_KEY = prev } diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index 70a9a4c98b..df8a98f003 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -186,12 +186,14 @@ describe('PerplexitySearchProvider error handling', () => { describe('web-search-perplexity plugin registration', () => { it('registers the provider into ctx.web (HMR-safe)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ choices: [{ message: { content: 'a' } }], citations: [] }))) const ctx = new Context() await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) const fiber = await ctx.plugin(perplexityPlugin, { apiKey: 'pplx-key' }) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID }) + await expect(ctx.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: PERPLEXITY_PROVIDER_ID }) await fiber.dispose() - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_MISSING' })) }) it('has no default export (namespace plugin export shape)', () => { @@ -219,7 +221,6 @@ describe('web-search-perplexity plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) const fiber = await ctx.plugin(perplexityPlugin, {}) - expect(ctx.web.searchStatus()).toEqual({ available: true, providerId: PERPLEXITY_PROVIDER_ID }) await ctx.web.search({ query: 'q' }) const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit] expect(url).toBe('https://api.perplexity.ai/chat/completions') @@ -238,7 +239,8 @@ describe('web-search-perplexity plugin registration', () => { const ctx = new Context() await ctx.plugin(WebService, { searchProvider: PERPLEXITY_PROVIDER_ID }) await ctx.plugin(perplexityPlugin, {}) - expect(ctx.web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) + await expect(ctx.web.search({ query: 'q' })) + .rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_CONFIGURED_UNAVAILABLE' })) } finally { if (prev !== undefined) process.env.PERPLEXITY_API_KEY = prev } diff --git a/packages/web/web/README.md b/packages/web/web/README.md index 9b9e2ca333..fe5f1e650e 100644 --- a/packages/web/web/README.md +++ b/packages/web/web/README.md @@ -18,8 +18,7 @@ Search and fetch share no request schema and no business logic, but they are del | Member | Semantics | |---|---| -| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer; emits `web/providers-change` on register and on dispose. Disposed with the calling fiber. | -| `searchStatus()` / `fetchStatus()` | Derived (never stored) `WebCapabilityStatus`: whether the capability has a selected usable provider, or the broad category it fails in. Diagnostics + execution-resolution input. | +| `registerSearchProvider(provider)` / `registerFetchProvider(provider)` | Register a backend. Throws `WebError` `WEB_DUPLICATE_PROVIDER` on a duplicate id within that capability kind. Returns a disposer. Disposed with the calling fiber. | | `search(request, exec?)` | Resolve the search provider and run one search. Enforces `request.maxResults` on the result (truncates `sources[]`, sets `truncated`). Throws `WebError` when the capability cannot run. | | `fetch(request, exec?)` | Resolve the fetch provider and retrieve one URL. A non-2xx response is a result, not a throw. Throws `WebError` for failures to safely retrieve or represent the resource. | @@ -27,18 +26,18 @@ Providers register **capabilities**, not tools. `dsh-tool-web` is the only owner ## Selection -Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered: +Selection never depends on registration, config, or HMR order. A capability has an explicit provider id (config `searchProvider`/`fetchProvider`, or env `$DSH_WEB_SEARCH_PROVIDER`/`$DSH_WEB_FETCH_PROVIDER` feeding the same fields), or auto-selects when exactly one usable provider is registered. `search()`/`fetch()` resolve the provider at execution time: -| Situation | `WebCapabilityStatus` | Execution | -|---|---|---| -| configured id registered and `status().available` | `available` for it | runs | -| configured id not registered | `configured-missing` | `WEB_PROVIDER_CONFIGURED_MISSING` | -| configured id registered but unavailable | `configured-unavailable` | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | -| no id, exactly one registered usable provider | `available` for it | runs | -| no id, no usable provider | `none` | `WEB_PROVIDER_UNAVAILABLE` | -| no id, multiple usable providers | `ambiguous` | `WEB_PROVIDER_AMBIGUOUS` | +| Situation | Execution | +|---|---| +| configured id registered and `status().available` | runs that provider | +| configured id not registered | `WEB_PROVIDER_CONFIGURED_MISSING` | +| configured id registered but unavailable | `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` | +| no id, exactly one registered usable provider | runs it | +| no id, no usable provider | `WEB_PROVIDER_UNAVAILABLE` | +| no id, multiple usable providers | `WEB_PROVIDER_AMBIGUOUS` | -`WebCapabilityStatus` carries only `available` + a `reason` discriminant (plus the winning `providerId` on the available branch). The branchable per-reason detail lives in the thrown `WebError`, which is the surface callers route on — so the same fact never gets two homes that can disagree. A provider's own `status()` is a cheap local check (credential presence, parseable config) and **must not make network calls**; `dsh-tool-web` reads only the aggregated `searchStatus()`/`fetchStatus()`, never each provider's `status()` directly. +The failure branches throw `WebError`, whose structured code (plus message detail — the missing id, the ambiguous candidate set) is the surface callers route on. A provider's own `status()` is a cheap local check (credential presence, parseable config) that feeds this execution-time selection and **must not make network calls**; `dsh-tool-web` never calls a provider's `status()` — it executes through `ctx.web.search()`/`fetch()` and routes on the thrown codes, so provider selection has one owner. ## Vocabulary diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 50150f6961..73dddf5e6e 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -3,15 +3,14 @@ * execution surface for two capabilities — search and fetch. Provider packages * register concrete backends with `registerSearchProvider` / * `registerFetchProvider`; the model-facing consumer - * (`@deepseek-ai/dsh-tool-web`) reads capability status and executes through - * `search()` / `fetch()`. + * (`@deepseek-ai/dsh-tool-web`) executes through `search()` / `fetch()` and + * routes on the structured {@link WebError} codes selection throws. * * The registry half stays close to `LlmService`: a `Map<id, provider>` per * capability kind, register methods that return disposers, duplicate ids that * throw, and execution-time resolution that throws when the selected provider is - * absent or unusable. On top of that sits one small selection-status layer so - * diagnostics and execution can explain why a capability can or cannot run, - * independent of registration order. + * absent or unusable — with selection rules that never depend on registration + * order. * * @module @deepseek-ai/dsh-web */ @@ -19,7 +18,6 @@ import { Context, Service } from 'cordis' import z from 'schemastery' import type { - WebCapabilityStatus, WebExecContext, WebFetchProvider, WebFetchRequest, @@ -35,7 +33,6 @@ export { WebError, } from './types.ts' export type { - WebCapabilityStatus, WebExecContext, WebFetchBody, WebFetchProvider, @@ -52,21 +49,9 @@ declare module 'cordis' { interface Context { web: WebService } - - interface Events { - /** - * Fired after the provider registry changes — a search or fetch provider was - * registered or disposed. Carries no payload and no capability graph: it - * means only "the provider registry changed; observers may recompute status - * from `ctx.web`". `searchStatus()` / `fetchStatus()` stay derived, not - * stored. - * @mode emit - */ - 'web/providers-change'(this: WebService): void - } } -/** Selection inputs shared by the status query and execution resolution. */ +/** Selection inputs for execution-time provider resolution. */ interface Selection<P> { /** The configured provider id for this capability, if any. */ readonly configuredId?: string @@ -90,17 +75,14 @@ export interface WebServiceConfig { /** * The web access service. Registered as `ctx.web` (one instance per context). * - * Selection semantics (identical for status and execution, never order- - * dependent): + * Selection semantics (resolved at execution time, never order-dependent): * - A configured id that is registered and `status().available` → that provider. - * - A configured id not registered → `configured-missing` / - * `WEB_PROVIDER_CONFIGURED_MISSING`. - * - A configured id registered but unavailable → `configured-unavailable` / + * - A configured id not registered → `WEB_PROVIDER_CONFIGURED_MISSING`. + * - A configured id registered but unavailable → * `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. * - No id configured, exactly one registered usable provider → that provider. - * - No id configured, multiple usable providers → `ambiguous` / - * `WEB_PROVIDER_AMBIGUOUS`. - * - No id configured, no usable provider → `none` / `WEB_PROVIDER_UNAVAILABLE`. + * - No id configured, multiple usable providers → `WEB_PROVIDER_AMBIGUOUS`. + * - No id configured, no usable provider → `WEB_PROVIDER_UNAVAILABLE`. */ export class WebService extends Service { /** @@ -126,9 +108,8 @@ export class WebService extends Service { /** * Register a search provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for search. Returns a disposer; emits - * `web/providers-change` after a successful register and again on dispose. - * Disposed with the calling fiber. + * if its id is already registered for search. Returns a disposer; disposed + * with the calling fiber. */ registerSearchProvider(provider: WebSearchProvider): () => void { return this.registerProvider(this.searchProviders, provider) @@ -136,9 +117,8 @@ export class WebService extends Service { /** * Register a fetch provider. Throws {@link WebError} `WEB_DUPLICATE_PROVIDER` - * if its id is already registered for fetch. Returns a disposer; emits - * `web/providers-change` after a successful register and again on dispose. - * Disposed with the calling fiber. + * if its id is already registered for fetch. Returns a disposer; disposed + * with the calling fiber. */ registerFetchProvider(provider: WebFetchProvider): () => void { return this.registerProvider(this.fetchProviders, provider) @@ -148,39 +128,15 @@ export class WebService extends Service { if (store.has(provider.id)) { throw new WebError(`a web provider with id "${provider.id}" is already registered`, 'WEB_DUPLICATE_PROVIDER') } - const dispose = this.ctx.effect(function* (this: WebService) { + const dispose = this.ctx.effect(function* () { store.set(provider.id, provider) - // Yield the rollback BEFORE emitting `web/providers-change`: the generator - // effect collects each yielded disposer before the next step runs, so a - // throwing change listener removes the just-added provider instead of - // leaking it into the registry. - yield () => { - store.delete(provider.id) - this.ctx.emit('web/providers-change') - } - this.ctx.emit('web/providers-change') - }.bind(this), 'web.registerProvider()') + yield () => store.delete(provider.id) + }, 'web.registerProvider()') // ctx.effect's disposer returns Promise<void>; our disposer API is // synchronous fire-and-forget — discard the (always-resolved) promise. return () => void dispose() } - /** Search-capability selection status, derived live (never stored). */ - searchStatus(): WebCapabilityStatus { - return resolveStatus({ - providers: this.searchProviders, - ...this.searchProviderId !== undefined ? { configuredId: this.searchProviderId } : {}, - }) - } - - /** Fetch-capability selection status, derived live (never stored). */ - fetchStatus(): WebCapabilityStatus { - return resolveStatus({ - providers: this.fetchProviders, - ...this.fetchProviderId !== undefined ? { configuredId: this.fetchProviderId } : {}, - }) - } - /** * Run one search through the selected provider. Resolves the provider at call * time with the selection rules above; throws {@link WebError} when the @@ -215,27 +171,7 @@ interface ResolvableProvider { status(): WebProviderStatus } -/** Compute the capability status from configured id + registered providers. */ -function resolveStatus<P extends ResolvableProvider>(selection: Selection<P>): WebCapabilityStatus { - const { configuredId, providers } = selection - if (configuredId !== undefined) { - const provider = providers.get(configuredId) - if (!provider) return { available: false, reason: 'configured-missing' } - if (!provider.status().available) return { available: false, reason: 'configured-unavailable' } - return { available: true, providerId: configuredId } - } - const usable = [...providers.values()].filter(provider => provider.status().available) - const [single] = usable - if (single === undefined) return { available: false, reason: 'none' } - if (usable.length > 1) return { available: false, reason: 'ambiguous' } - return { available: true, providerId: single.id } -} - -/** - * Resolve the selected provider or throw the matching {@link WebError}. Shares - * the selection rules with {@link resolveStatus} so status and execution can - * never disagree. - */ +/** Resolve the selected provider or throw the matching {@link WebError}. */ function resolveProvider<P extends ResolvableProvider>(selection: Selection<P>): P { const { configuredId, providers } = selection if (configuredId !== undefined) { diff --git a/packages/web/web/src/types.ts b/packages/web/web/src/types.ts index 6f85787d1b..f4cda691b8 100644 --- a/packages/web/web/src/types.ts +++ b/packages/web/web/src/types.ts @@ -1,8 +1,8 @@ /** * Vocabulary for the web capability seam (`ctx.web`): the search/fetch * request/result shapes providers produce and consumers format, the provider - * and capability status discriminants selection reports, the execution-control - * context, and the typed error taxonomy. + * status discriminant selection reads, the execution-control context, and the + * typed error taxonomy. * * These types are shared by every provider backend * (`@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, @@ -128,25 +128,15 @@ export type WebFetchBody = /** * Whether one concrete provider implementation is usable, by cheap local checks * only (credential presence, parseable endpoint config). A provider `status()` - * must NOT make network calls. It is an input to selection, not a health system. + * must NOT make network calls. It is an input to execution-time selection, not + * a health system: `WebService.search()`/`fetch()` read it to pick a usable + * provider, and selection failure surfaces as the structured {@link WebError} + * codes callers route on. */ export type WebProviderStatus = | { readonly available: true } | { readonly available: false; readonly reason: 'missing-credential' | 'misconfigured' } -/** - * Whether a capability (search or fetch) has a selected usable provider, or the - * broad category in which selection fails. Intentionally small: it carries the - * winning `providerId` on the available branch (so diagnostics can report which - * provider won) but NOT the per-reason payload (the missing id, the ambiguous - * candidate set). That branchable detail lives in the {@link WebError} thrown at - * execution time — the surface callers route on — so the same fact does not get - * two homes that can disagree. - */ -export type WebCapabilityStatus = - | { readonly available: true; readonly providerId: string } - | { readonly available: false; readonly reason: 'none' | 'configured-missing' | 'configured-unavailable' | 'ambiguous' } - /** * A search-capable backend. Registered with `ctx.web.registerSearchProvider`. * `id` is a stable string, unique within the search capability kind. diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts index e97630ebab..8189e342da 100644 --- a/packages/web/web/tests/web.spec.ts +++ b/packages/web/web/tests/web.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' import { Context } from 'cordis' import WebService, { WebError, @@ -42,18 +42,14 @@ async function mountWeb(config: ConstructorParameters<typeof WebService>[1] = {} } describe('WebService registration', () => { - it('registers and disposes a search provider, emitting providers-change each way', async () => { - const { ctx, web } = await mountWeb() - const changed = vi.fn() - ctx.on('web/providers-change', changed) + it('registers a search provider and unregisters it via the returned disposer', async () => { + const { web } = await mountWeb() const dispose = web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - expect(changed).toHaveBeenCalledTimes(1) - expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) dispose() - expect(changed).toHaveBeenCalledTimes(2) - expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) }) it('throws WEB_DUPLICATE_PROVIDER on a duplicate search id', async () => { @@ -69,88 +65,14 @@ describe('WebService registration', () => { expect(() => web.registerFetchProvider(makeFetchProvider('shared', available, fetchResult('shared')))).not.toThrow() }) - it('rolls back a registration when a providers-change listener throws', async () => { - const { ctx, web } = await mountWeb() - ctx.on('web/providers-change', () => { throw new Error('listener boom') }) - expect(() => web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa'))))) - .toThrow('listener boom') - // The throwing listener must not leave the provider in the registry. - expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) - }) - it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => { const { ctx, web } = await mountWeb() const fiber = await ctx.plugin(Object.assign((inner: Context) => { inner.web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) }, { inject: ['web'] })) - expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) await fiber.dispose() - expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) - }) -}) - -describe('WebService selection status', () => { - it('reports none when nothing is registered', async () => { - const { web } = await mountWeb() - expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) - expect(web.fetchStatus()).toEqual({ available: false, reason: 'none' }) - }) - - it('auto-selects the single usable provider when no id is configured', async () => { - const { web } = await mountWeb() - web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) - }) - - it('reports ambiguous when multiple usable providers exist and none is configured', async () => { - const { web } = await mountWeb() - web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) - expect(web.searchStatus()).toEqual({ available: false, reason: 'ambiguous' }) - }) - - it('ignores unusable providers when auto-selecting', async () => { - const { web } = await mountWeb() - web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity')))) - expect(web.searchStatus()).toEqual({ available: true, providerId: 'exa' }) - }) - - it('reports none when providers exist but none are usable', async () => { - const { web } = await mountWeb() - web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) - expect(web.searchStatus()).toEqual({ available: false, reason: 'none' }) - }) - - it('honors a configured id over a different registered provider', async () => { - const { web } = await mountWeb({ searchProvider: 'perplexity' }) - web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) - expect(web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' }) - }) - - it('reports configured-missing when the configured id is not registered', async () => { - const { web } = await mountWeb({ searchProvider: 'perplexity' }) - web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) - expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-missing' }) - }) - - it('reports configured-unavailable when the configured id is registered but unusable', async () => { - const { web } = await mountWeb({ searchProvider: 'exa' }) - web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) - expect(web.searchStatus()).toEqual({ available: false, reason: 'configured-unavailable' }) - }) - - it('does not let registration order change auto-selection', async () => { - const a = await mountWeb() - a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) - a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) - expect(a.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' }) - - const b = await mountWeb() - b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) - b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) - expect(b.web.searchStatus()).toEqual({ available: true, providerId: 'perplexity' }) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) }) }) @@ -160,6 +82,12 @@ describe('WebService execution resolution', () => { await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) }) + it('throws WEB_PROVIDER_UNAVAILABLE when providers exist but none are usable', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_UNAVAILABLE' })) + }) + it('throws WEB_PROVIDER_CONFIGURED_MISSING for an unregistered configured id', async () => { const { web } = await mountWeb({ searchProvider: 'perplexity' }) web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) @@ -179,6 +107,32 @@ describe('WebService execution resolution', () => { await expect(web.search({ query: 'q' })).rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_AMBIGUOUS' })) }) + it('runs the configured provider even when another usable provider is registered', async () => { + const { web } = await mountWeb({ searchProvider: 'perplexity' }) + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + }) + + it('ignores unusable providers when auto-selecting', async () => { + const { web } = await mountWeb() + web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve(searchResult('exa')))) + web.registerSearchProvider(makeSearchProvider('perplexity', unavailable, () => Promise.resolve(searchResult('perplexity')))) + await expect(web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'exa' }) + }) + + it('does not let registration order change auto-selection', async () => { + const a = await mountWeb() + a.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + a.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + await expect(a.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + + const b = await mountWeb() + b.web.registerSearchProvider(makeSearchProvider('perplexity', available, () => Promise.resolve(searchResult('perplexity')))) + b.web.registerSearchProvider(makeSearchProvider('exa', unavailable, () => Promise.resolve(searchResult('exa')))) + await expect(b.web.search({ query: 'q' })).resolves.toMatchObject({ providerId: 'perplexity' }) + }) + it('runs the selected provider and returns its result', async () => { const { web } = await mountWeb() web.registerSearchProvider(makeSearchProvider('exa', available, () => Promise.resolve( diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 0aca732e03..8b13097e04 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -76,7 +76,6 @@ { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchRequest", "source": "packages/web/web/src/types.ts" }, { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchResult", "source": "packages/web/web/src/types.ts" }, { "doc": "docs/core-data-structures/web.md", "symbol": "WebFetchBody", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" }, - { "doc": "docs/core-data-structures/web.md", "symbol": "WebCapabilityStatus", "source": "packages/web/web/src/types.ts" } + { "doc": "docs/core-data-structures/web.md", "symbol": "WebProviderStatus", "source": "packages/web/web/src/types.ts" } ] } From cd49670f4e2eb261c714f9ced63312525f73cbe1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:44:26 +0800 Subject: [PATCH 08/32] refactor(hooks): tighten the hook-protocol contract surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the tighten-hook-protocol-contract RFC (moved to implemented/): - HookDialect narrows to 'claude' | 'codex': the 'native' variant had zero producers (native plugins on the seams write no hook/* provenance), and the dialect is defined as the bridge that ran the hook. - HookOutput.suppressOutput is gone: the codec parsed it and every path discarded it with no warn and no deferral — hook stdout never enters a transcript, so there is nothing to suppress. - hook/result.durationMs is gone: durable timing telemetry with no reader that the snapshot normalizer had to scrub as replay noise. With no duration to measure, runHook loses its injected now clock and the single-field RunHookResult wrapper — it returns the HookOutput directly. The committed hook fixtures had the field stripped mechanically (field-only diff); the stdout goldens never carried it. - The bridges' double-defaulted defaultTimeoutMs config knob is replaced by one reference-default constant, DEFAULT_HOOK_TIMEOUT_MS, exported from the lib's runner and applied inside runHook; per-hook timeoutSec stays the override surface. - The hook/result semantics move into the lib that declares the event: HookResultRecord now carries the decoded HookOutput and appendHookResult derives the decision string (decision ?? stop-on-continue:false ?? pass) and the 500-char stderrSummary truncation; both bridges delete their byte-identical private copies. The snapshot suite passes against the existing goldens, proving the derived values are unchanged. - Rider: BLOCKING_EXIT_CODE is codec-internal again (zero importers). Amend the hook-protocol-lib and hook-snapshot-matrix RFCs to the new facts, update the lib/bridge READMEs and the session.md event tables, and retarget the affected unit tests (including new lib-level coverage of the derivation rules). --- docs/core-data-structures/session.md | 4 +- docs/rfc/README.md | 2 +- .../feature/2026-06-30-hook-protocol-lib.md | 6 +- ...26-07-04-tighten-hook-protocol-contract.md | 32 ++++++++++ .../2026-07-04-hook-snapshot-matrix.md | 2 +- ...26-07-04-tighten-hook-protocol-contract.md | 32 ---------- .../tests/snapshot-normalize.spec.ts | 16 ++--- .../acp-agent/tests/snapshot-normalize.ts | 10 +-- .../hook-cc-posttool-block/session.jsonl | 6 +- .../hook-cc-posttool-context/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 2 +- .../hook-cc-pretool-deny/session.jsonl | 2 +- .../hook-cc-promptsubmit-block/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-cc-stop-continue/session.jsonl | 4 +- .../hook-codex-posttool-block/session.jsonl | 10 +-- .../hook-codex-posttool-context/session.jsonl | 2 +- .../hook-codex-pretool-block/session.jsonl | 2 +- .../session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-codex-stop-continue/session.jsonl | 4 +- packages/hooks/hook-protocol/README.md | 10 +-- packages/hooks/hook-protocol/src/codec.ts | 6 +- packages/hooks/hook-protocol/src/events.ts | 43 ++++++++----- packages/hooks/hook-protocol/src/index.ts | 10 +-- packages/hooks/hook-protocol/src/runner.ts | 46 ++++++-------- packages/hooks/hook-protocol/src/types.ts | 25 ++++---- .../hooks/hook-protocol/tests/codec.spec.ts | 5 +- .../hooks/hook-protocol/tests/events.spec.ts | 63 ++++++++++++++++--- .../hooks/hook-protocol/tests/runner.spec.ts | 45 +++++++------ packages/hooks/hooks-claude/README.md | 3 +- packages/hooks/hooks-claude/src/index.ts | 26 +------- .../hooks/hooks-claude/tests/coverage.spec.ts | 9 +-- packages/hooks/hooks-codex/README.md | 3 +- packages/hooks/hooks-codex/src/index.ts | 24 +------ .../hooks/hooks-codex/tests/coverage.spec.ts | 4 +- 36 files changed, 233 insertions(+), 235 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 900fdc54fa..fc2f40e1b8 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -228,8 +228,8 @@ A plugin may declaration-merge extra `SessionEventMap` types. These are **log-on | Event | Payload | Role | |---|---|---| -| `hook/invoked` | `{ turn, point, dialect, matcher?, handlerId }` | A hook command was invoked at a hook `point` (`PreToolUse`, `Stop`, …). `dialect` is the bridge (`claude`/`codex`/`native`); `matcher` the matcher-group pattern that selected it (absent for match-all); `handlerId` correlates with the result. | -| `hook/result` | `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }` | The decided outcome, paired by `handlerId`. `decision` is the resolved neutral outcome (`deny`/`allow`/`block`/`stop`/`pass`/…); `exitCode` absent when the hook could not run; `stderrSummary` the truncated block-reason source. | +| `hook/invoked` | `{ turn, point, dialect, matcher?, handlerId }` | A hook command was invoked at a hook `point` (`PreToolUse`, `Stop`, …). `dialect` is the bridge (`claude`/`codex`); `matcher` the matcher-group pattern that selected it (absent for match-all); `handlerId` correlates with the result. | +| `hook/result` | `{ turn, point, handlerId, decision, exitCode?, stderrSummary? }` | The decided outcome, paired by `handlerId`. `decision` is the neutral outcome `appendHookResult` derives from the parsed output (the hook's decision, else `'stop'` on `continue:false`, else `'pass'`); `exitCode` absent when the hook could not run; `stderrSummary` the trimmed stderr truncated to 500 chars (the block-reason source on exit 2). | The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see the hooks RFC). diff --git a/docs/rfc/README.md b/docs/rfc/README.md index d53684bf6b..5b2e4e90a9 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -61,7 +61,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | | [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | -| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | ### Architecture @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index 848eeec219..419931345e 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -16,10 +16,10 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo **Shared (here):** - **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop). -- **Execution** — `runHook(bash, hook, options, now)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec`, and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). -- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the full CC superset (`continue`/`stopReason`/`suppressOutput`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. +- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). +- **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. -- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. +- **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. **Per-dialect (the bridge plugins):** building each event's stdin payload (CC's base+per-event field sets vs Codex's snake_case with `turn_id`/`model` extras), the dialect's env + `${CLAUDE_PLUGIN_ROOT}` substitution (CC) vs none (Codex), and mapping the neutral `HookOutput`/`MergedHookOutcome` onto the harness's seam-specific typed Decisions (`PreToolDecision`, `PromptDecision`, `ContinuationDecision`, `PostToolDecision`). diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md new file mode 100644 index 0000000000..90be3c790b --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -0,0 +1,32 @@ +# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +Five pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test: + +1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) had zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere was the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all). +2. **`HookOutput.suppressOutput`** (same file) was parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` got silent nothing with no warn. +3. **`hook/result.durationMs`** was durable timing telemetry with no reader. Both bridges wrote it, and the ACP snapshot normalizer scrubbed it to `0` because wall-clock hook runtime is replay noise (`examples/acp-agent/tests/snapshot-normalize.ts`); the remaining consumers were tests and the goldens that existed because the field existed. Deterministic provenance fields (`point`, `matcher`, `turn`, `handlerId`) earn their durability as audit facts; a nondeterministic field that replay must erase and nothing reads earns neither its bytes nor its special-case scrub. +4. **`defaultTimeoutMs` was double-defaulted in both bridge configs** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`) — the same two-homes-for-one-literal shape the ACP bridge's `TODO(double-default)` flags, for a knob no shipped config set; the per-hook `timeoutSec` is the real timeout surface. +5. **The `hook/result` semantics lived in the bridges, twice, not in the lib that owns the event.** `summarize()` — the 500-character stderr truncation rule — was byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so was the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declared `hook/result`, documented `stderrSummary` as "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently. + +## What shipped + +`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `durationMs` is gone from the `hook/result` event, the bridge appends, the docs, and the snapshot normalizer's special-case scrub; with no duration to measure, `runHook`'s injected `now` clock and its single-purpose `RunHookResult` wrapper went too — `runHook` returns the `HookOutput` directly. The committed hook fixtures (`session.jsonl`, which double as the expected-log goldens) had the field stripped mechanically; the stdout goldens never carried it. The bridges' `defaultTimeoutMs` config knob is replaced by one reference-default constant, `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms), exported from the lib's runner and applied inside `runHook`; `RunHookOptions` lost the field entirely, and the per-hook `timeoutSec` stays the override surface. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput`, and `appendHookResult` derives `stderrSummary` (500-character truncation) and the decision string from it; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`). + +## Why not keep them? + +The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; and durable telemetry that replay must scrub is a cost with no buyer. Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events; a trace viewer that reads timings — as live diagnostics or a deliberately durable telemetry event designed for it). On item 5, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. + +## Acceptance criteria + +- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns nothing. +- `suppressOutput` and `durationMs` appear nowhere in source, parsed-field doc lists, or the normalizer; the hook fixtures carry no `durationMs` (the refresh was a mechanical field-strip, not a re-record). +- Both bridge configs lost `defaultTimeoutMs`; the reference default lives once, in the lib (`DEFAULT_HOOK_TIMEOUT_MS`); per-hook `timeoutSec` still overrides it. +- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites. + +## Risks + +The `dialect`, `suppressOutput`, `defaultTimeoutMs`, and semantics changes are invisible on the wire and in the goldens; the `durationMs` removal churned the hook fixtures once (a mechanical field-strip — the field was already normalized to a constant). The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index f8ec029d22..0a7c0fd4ec 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -27,7 +27,7 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook-<di - **Authored, no model turn** (keyless, no sidecar — the derived replay script is empty; the `rejected` turn carrying `hook/*` events is compared): `hook-cc-promptsubmit-block`, `hook-codex-promptsubmit-block`. - **Recorded against the real API, hook active during recording** (the model's reaction to the decision is part of the captured transcript, replayed keyless thereafter): `hook-{cc,codex}-promptsubmit-context` (allow + additionalContext fold), `hook-cc-pretool-deny` / `hook-codex-pretool-block` (deny → `isError` tool result), `hook-cc-pretool-ask` (ask → degrades to deny with the approval-required reason), `hook-{cc,codex}-posttool-block` (block with feedback), `hook-{cc,codex}-posttool-context` (accept + additionalContext), `hook-{cc,codex}-stop-continue` (a blocking Stop hook forces one extra step via steering). -Each hook command emits only FIXED LITERAL strings (no timestamps/pids/`$RANDOM`/cwd echoes); the snapshot normalizer scrubs the one volatile field a `hook/result` carries (`durationMs`). The `Stop` scenarios self-limit with a marker file (`.stop_fired`) so the force-continue does not loop — the `stop_hook_active` loop-guard is still a bridge `TODO`, so an unconditional Stop hook would force-continue every step. +Each hook command emits only FIXED LITERAL strings (no timestamps/pids/`$RANDOM`/cwd echoes), and a `hook/result` carries only deterministic fields (`decision`/`exitCode`/`stderrSummary`), so the hook events need no scrubbing beyond the normalizer's generic `time` zeroing. The `Stop` scenarios self-limit with a marker file (`.stop_fired`) so the force-continue does not loop — the `stop_hook_active` loop-guard is still a bridge `TODO`, so an unconditional Stop hook would force-continue every step. ### Three hook points are deliberately NOT snapshotted diff --git a/docs/rfc/proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md deleted file mode 100644 index 42d222c6be..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ /dev/null @@ -1,32 +0,0 @@ -# RFC: Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics - -Status: proposed - -## Problem - -Five pieces of the `dsh-hook-protocol`/bridge contract miss the discipline the [subagent-observe-enrich RFC](../../implemented/feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these fail the same test: - -1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) has zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere is the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../../implemented/feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all). -2. **`HookOutput.suppressOutput`** (same file) is parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` gets silent nothing with no warn. -3. **`hook/result.durationMs`** is durable timing telemetry with no reader. Both bridges write it, and the ACP snapshot normalizer scrubs it to `0` because wall-clock hook runtime is replay noise (`examples/acp-agent/tests/snapshot-normalize.ts`); the remaining consumers are tests and the goldens that exist because the field exists. Deterministic provenance fields (`point`, `matcher`, `turn`, `handlerId`) earn their durability as audit facts; a nondeterministic field that replay must erase and nothing reads earns neither its bytes nor its special-case scrub. -4. **`defaultTimeoutMs` is double-defaulted in both bridge configs** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`) — the same two-homes-for-one-literal shape the ACP bridge's `TODO(double-default)` flags, for a knob no shipped config sets; the per-hook `timeoutSec` is the real timeout surface. -5. **The `hook/result` semantics live in the bridges, twice, not in the lib that owns the event.** `summarize()` — the 500-character stderr truncation rule — is byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so is the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declares `hook/result`, documents `stderrSummary` as "truncated" without owning the truncation, and documents the decision values without owning the mapping. If one bridge drifts (a different cap, a different fallback), the shared durable event's semantics fork silently. - -## Proposal - -Narrow `HookDialect` to `'claude' | 'codex'` and fix its JSDoc; retarget the lib's one `'native'` test. Drop `suppressOutput` from `HookOutput`, the codec's parse lines, its codec-test assertions, and the parsed-superset lists in the lib README and [hook-protocol-lib RFC](../../implemented/feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../../implemented/AGENTS.md)). Drop `durationMs` from `HookResultRecord`, `RunHookResult`, the `hook/result` event, the bridge appends, the docs/catalog, and the snapshot normalizer's special-case scrub (retiring `runHook`'s injected clock if nothing else needs it); the hook goldens refresh mechanically as the scrubbed field disappears. Replace the bridges' `defaultTimeoutMs` config knob with one shared reference-default constant in `dsh-hook-protocol` (per-hook `timeoutSec` stays the override surface). Move the `hook/result` semantics into the lib: `appendHookResult` (or a helper it exposes) derives `stderrSummary` and the decision string from the `HookOutput` + exit outcome, and both bridges delete their private copies. Rider: un-export `BLOCKING_EXIT_CODE` (zero importers; even the codec tests spell the literal `2`). - -## Why not keep them? - -The [hook-protocol-lib RFC](../../implemented/feature/2026-06-30-hook-protocol-lib.md) deliberately records "parses the full CC superset" — the strongest counterargument is that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; and durable telemetry that replay must scrub is a cost with no buyer. Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events; a trace viewer that reads timings — as live diagnostics or a deliberately durable telemetry event designed for it). On item 5, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. - -## Acceptance criteria - -- `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns only this RFC's amended references. -- `suppressOutput` and `durationMs` appear nowhere in source, parsed-field doc lists, the catalog, or the normalizer; the hook goldens are re-recorded or refreshed without the field. -- Both bridge configs lose `defaultTimeoutMs`; the reference default lives once, in the lib; per-hook `timeoutSec` still overrides it. -- One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`, exercised by both bridges' suites. - -## Risks - -The `dialect`, `suppressOutput`, `defaultTimeoutMs`, and semantics changes are invisible on the wire and in the goldens; the `durationMs` removal churns the hook goldens once (a mechanical refresh — the field was already normalized to a constant). The cost is churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index bfe29af8a5..be540c9857 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -91,20 +91,14 @@ describe('normalizeSessionLog', () => { expect(out).toContain('{{sessionId}}') }) - it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => { + it('leaves event data fields untouched beyond the time zeroing (no per-event field scrubs)', () => { const ev = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, - data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 }, + data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2 }, }) const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) - expect(out).toContain('"durationMs":0') - expect(out).not.toContain('37') - expect(out).toContain('"decision":"block"') // the decision is the behavior — kept - }) - - it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => { - const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } }) - const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) - expect(out).toContain('"durationMs":88') + expect(out).toContain('"decision":"block"') + expect(out).toContain('"exitCode":2') + expect(out).toContain('"time":0') }) }) diff --git a/examples/acp-agent/tests/snapshot-normalize.ts b/examples/acp-agent/tests/snapshot-normalize.ts index 8150057fa4..db0d493535 100644 --- a/examples/acp-agent/tests/snapshot-normalize.ts +++ b/examples/acp-agent/tests/snapshot-normalize.ts @@ -8,8 +8,7 @@ * Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp` * cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header); * JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event - * `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's - * `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq` + * `time` (epoch ms) and header `createdAt` → 0. NOT scrubbed: the log's `seq` * (deterministic — `seq = log.length`, part of the event-log contract). * * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. @@ -98,13 +97,6 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri } else if ('time' in record) { // Event line: zero the epoch-ms timestamp; keep seq (deterministic). record.time = 0 - // A hook/result carries the hook's wall-clock runtime (`data.durationMs`), - // which is run-to-run noise like `time` — zero it so the golden reflects - // the hook's decision/exit, not how long the shell took. - if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') { - const data = record.data as Record<string, unknown> - if ('durationMs' in data) data.durationMs = 0 - } } return scrubValue(record, ctx) as Record<string, unknown> }) diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index a9bcac1b03..1cf98ff718 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -61,7 +61,7 @@ {"type":"assistant/message","seq":59,"time":1783095159852,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the command `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":91,"cacheReadTokens":1664,"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,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} {"type":"tool/call","seq":60,"time":1783095159852,"data":{"turn":1,"step":1,"callId":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":61,"time":1783095159867,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":62,"time":1783095159875,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.870597999999973}} +{"type":"hook/result","seq":62,"time":1783095159875,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead"}} {"type":"tool/result","seq":63,"time":1783095159875,"data":{"turn":1,"step":1,"callId":"call_00_e9zAlNQhIVFKzoStUuWI7161","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[60],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783095159875,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783095159876,"data":{"turn":1,"step":2}} @@ -166,7 +166,7 @@ {"type":"assistant/message","seq":164,"time":1783095161879,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by policy. I need to rerun with a description/summary. Let me try again - I already included a description. Let me check if maybe the description needs to be more descriptive, or if there's another issue.\n\nLet me try again with a slightly different approach to see what happens."},{"type":"tool-call","id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}],"usage":{"inputTokens":187,"outputTokens":133,"cacheReadTokens":1664,"reasoningTokens":65}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":165,"time":1783095161879,"data":{"turn":1,"step":2,"callId":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}} {"type":"hook/invoked","seq":166,"time":1783095161893,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":167,"time":1783095161902,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.773494000000028}} +{"type":"hook/result","seq":167,"time":1783095161902,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead"}} {"type":"tool/result","seq":168,"time":1783095161902,"data":{"turn":1,"step":2,"callId":"call_00_SFRulyHdhTsyfllc6Gro3716","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[165],"surfaceOp":"append"} {"type":"step/end","seq":169,"time":1783095161903,"data":{"turn":1,"step":2}} {"type":"step/start","seq":170,"time":1783095161903,"data":{"turn":1,"step":3}} @@ -282,7 +282,7 @@ {"type":"assistant/message","seq":280,"time":1783095163935,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"It seems the tool output is being rejected by policy repeatedly. Let me try a different command or approach - perhaps the issue is unrelated to my command. Let me try with a longer description or a different format.\n\nActually, I think the policy rejection might be about the content/output being shown. Let me try running it and just report what I know.\n\nLet me try one more time."},{"type":"tool-call","id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":216,"outputTokens":144,"cacheReadTokens":1792,"reasoningTokens":77}},"sourceEventSeqs":[171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,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],"surfaceOp":"append"} {"type":"tool/call","seq":281,"time":1783095163935,"data":{"turn":1,"step":3,"callId":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} {"type":"hook/invoked","seq":282,"time":1783095163944,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:3","matcher":"bash"}} -{"type":"hook/result","seq":283,"time":1783095163951,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.553152000000409}} +{"type":"hook/result","seq":283,"time":1783095163951,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead"}} {"type":"tool/result","seq":284,"time":1783095163951,"data":{"turn":1,"step":3,"callId":"call_00_9CQs2NzrhnwYjdvmjsR10424","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[281],"surfaceOp":"append"} {"type":"step/end","seq":285,"time":1783095163951,"data":{"turn":1,"step":3}} {"type":"step/start","seq":286,"time":1783095163951,"data":{"turn":1,"step":4}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index 053ce1c251..aa28dfdeb9 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095113150,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095113150,"data":{"turn":1,"step":1,"callId":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095113166,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095113175,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":8.75206100000014}} +{"type":"hook/result","seq":60,"time":1783095113175,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0}} {"type":"tool/result","seq":61,"time":1783095113175,"data":{"turn":1,"step":1,"callId":"call_00_upvgqMKJ4hJck9LxQn0p5500","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"context/message","seq":62,"time":1783095113176,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/end","seq":63,"time":1783095113176,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index b0e7a5f00f..727e349208 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095043785,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095043786,"data":{"turn":1,"step":1,"callId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095043786,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095043800,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":12.982377999999699}} +{"type":"hook/result","seq":60,"time":1783095043800,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0}} {"type":"tool/result","seq":61,"time":1783095043800,"data":{"turn":1,"step":1,"callId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","content":[{"type":"text","text":"Error: bash requires manual approval in this session"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"step/end","seq":62,"time":1783095043800,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1783095043801,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index 501f3a8594..1aab4b5493 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095009899,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":1736,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095009899,"data":{"turn":1,"step":1,"callId":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095009900,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095009915,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":14.778092000000015}} +{"type":"hook/result","seq":60,"time":1783095009915,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session"}} {"type":"tool/result","seq":61,"time":1783095009915,"data":{"turn":1,"step":1,"callId":"call_00_NQfQgkyjofpjsaiEUcsX0103","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"step/end","seq":62,"time":1783095009916,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1783095009916,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl index b5f81fdaea..6205340c2a 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook","durationMs":0}} +{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook"}} {"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by policy hook"}} {"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index 33cf2c8ba0..d3b341eeee 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"b0c9d2c7-f95b-4750-be8a-10121253b006","createdAt":1783095036603,"cwd":"/tmp/acp-snap-cwd-LW2rSZ"} {"type":"turn/start","seq":0,"time":1783095036609,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":1783095036610,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":1783095036623,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":11.874454999999898}} +{"type":"hook/result","seq":2,"time":1783095036623,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0}} {"type":"user/message","seq":3,"time":1783095036623,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":4,"time":1783095036623,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783095036624,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index 7e584998e7..f70d047033 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -30,7 +30,7 @@ {"type":"assistant/message","seq":28,"time":1783095185826,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}},"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],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1783095185826,"data":{"turn":1,"step":1}} {"type":"hook/invoked","seq":30,"time":1783095185827,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} -{"type":"hook/result","seq":31,"time":1783095185846,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":19.14465199999995}} +{"type":"hook/result","seq":31,"time":1783095185846,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop."}} {"type":"steering/message","seq":32,"time":1783095185846,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/start","seq":33,"time":1783095185847,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":34,"time":1783095186554,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -234,5 +234,5 @@ {"type":"assistant/message","seq":232,"time":1783095187944,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user initially asked me to reply with \"FIRST\" and stop, which I did. Now there's a plugin steering message asking me to also reply with \"SECOND\" and stop. However, the user's original instruction said \"Reply with the single word FIRST and stop.\" I already did that. The plugin is trying to add an additional instruction, but I should follow the original user instruction which said to stop after \"FIRST\".\n\nActually, looking more carefully - I already responded with \"FIRST\" in my previous turn. Now the user is sending a new message with a plugin steering. Let me consider what to do.\n\nThe plugin says \"Also reply with the single word SECOND, then stop.\" But the user's original request was to reply with \"FIRST\" and stop. I already did that. Now I have a new instruction from the plugin. I think I should follow this new instruction and reply with \"SECOND\"."},{"type":"text","text":"SECOND"}],"usage":{"inputTokens":82,"outputTokens":193,"cacheReadTokens":1664,"reasoningTokens":190}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231],"surfaceOp":"append"} {"type":"step/end","seq":233,"time":1783095187944,"data":{"turn":1,"step":2}} {"type":"hook/invoked","seq":234,"time":1783095187944,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} -{"type":"hook/result","seq":235,"time":1783095187954,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":9.226673000000119}} +{"type":"hook/result","seq":235,"time":1783095187954,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0}} {"type":"turn/end","seq":236,"time":1783095187954,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index ff774c4643..6dfbd767cb 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095423411,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095423412,"data":{"turn":1,"step":1,"callId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095423425,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095423432,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":6.750618000000031}} +{"type":"hook/result","seq":60,"time":1783095423432,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} {"type":"tool/result","seq":61,"time":1783095423432,"data":{"turn":1,"step":1,"callId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"step/end","seq":62,"time":1783095423432,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1783095423433,"data":{"turn":1,"step":2}} @@ -117,7 +117,7 @@ {"type":"assistant/message","seq":115,"time":1783095424733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output was rejected by codex policy. Let me try again with the exact command as specified."},{"type":"tool-call","id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}],"usage":{"inputTokens":55,"outputTokens":86,"cacheReadTokens":1792,"reasoningTokens":20}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114],"surfaceOp":"append"} {"type":"tool/call","seq":116,"time":1783095424733,"data":{"turn":1,"step":2,"callId":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}} {"type":"hook/invoked","seq":117,"time":1783095424745,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":118,"time":1783095424753,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":7.9194109999998545}} +{"type":"hook/result","seq":118,"time":1783095424753,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} {"type":"tool/result","seq":119,"time":1783095424753,"data":{"turn":1,"step":2,"callId":"call_00_vYk35aagabBlYGHTvJqn7418","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[116],"surfaceOp":"append"} {"type":"step/end","seq":120,"time":1783095424753,"data":{"turn":1,"step":2}} {"type":"step/start","seq":121,"time":1783095424753,"data":{"turn":1,"step":3}} @@ -175,7 +175,7 @@ {"type":"assistant/message","seq":173,"time":1783095426479,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The tool output is being rejected by codex policy. Let me try a slightly different approach - maybe I need to be more careful with the description or command."},{"type":"tool-call","id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}],"usage":{"inputTokens":163,"outputTokens":78,"cacheReadTokens":1792,"reasoningTokens":32}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":174,"time":1783095426479,"data":{"turn":1,"step":3,"callId":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}} {"type":"hook/invoked","seq":175,"time":1783095426479,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:3","matcher":"bash"}} -{"type":"hook/result","seq":176,"time":1783095426488,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":8.644037999999455}} +{"type":"hook/result","seq":176,"time":1783095426488,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} {"type":"tool/result","seq":177,"time":1783095426488,"data":{"turn":1,"step":3,"callId":"call_00_XLxXjqVljRIC2KwFZCKX3369","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[174],"surfaceOp":"append"} {"type":"step/end","seq":178,"time":1783095426488,"data":{"turn":1,"step":3}} {"type":"step/start","seq":179,"time":1783095426489,"data":{"turn":1,"step":4}} @@ -222,7 +222,7 @@ {"type":"assistant/message","seq":220,"time":1783095427668,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"The tool is consistently rejecting the output. Let me try a different way to invoke it."},{"type":"tool-call","id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}],"usage":{"inputTokens":135,"outputTokens":67,"cacheReadTokens":1920,"reasoningTokens":18}},"sourceEventSeqs":[180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219],"surfaceOp":"append"} {"type":"tool/call","seq":221,"time":1783095427668,"data":{"turn":1,"step":4,"callId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}} {"type":"hook/invoked","seq":222,"time":1783095427668,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:4","matcher":"bash"}} -{"type":"hook/result","seq":223,"time":1783095427677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":8.75036799999998}} +{"type":"hook/result","seq":223,"time":1783095427677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} {"type":"tool/result","seq":224,"time":1783095427677,"data":{"turn":1,"step":4,"callId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[221],"surfaceOp":"append"} {"type":"step/end","seq":225,"time":1783095427678,"data":{"turn":1,"step":4}} {"type":"step/start","seq":226,"time":1783095427678,"data":{"turn":1,"step":5}} @@ -286,7 +286,7 @@ {"type":"assistant/message","seq":284,"time":1783095429025,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Every attempt is being rejected. Let me try to see what's happening by running a different command first, to check if bash works at all."},{"type":"text","text":"Let me check if bash itself is working:"},{"type":"tool-call","id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}],"usage":{"inputTokens":96,"outputTokens":81,"cacheReadTokens":2048,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":285,"time":1783095429025,"data":{"turn":1,"step":5,"callId":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}} {"type":"hook/invoked","seq":286,"time":1783095429025,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:5","matcher":"bash"}} -{"type":"hook/result","seq":287,"time":1783095429046,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":21.25910400000066}} +{"type":"hook/result","seq":287,"time":1783095429046,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} {"type":"tool/result","seq":288,"time":1783095429047,"data":{"turn":1,"step":5,"callId":"call_00_xOnTckie072jHuPhX0Mz5115","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[285],"surfaceOp":"append"} {"type":"step/end","seq":289,"time":1783095429047,"data":{"turn":1,"step":5}} {"type":"step/start","seq":290,"time":1783095429047,"data":{"turn":1,"step":6}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 1a93694456..2af2c979ab 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095440719,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095440719,"data":{"turn":1,"step":1,"callId":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095440731,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095440739,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.645890999999665}} +{"type":"hook/result","seq":60,"time":1783095440739,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0}} {"type":"tool/result","seq":61,"time":1783095440739,"data":{"turn":1,"step":1,"callId":"call_00_TArPQZJxir9dawrAg0Fb9098","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"context/message","seq":62,"time":1783095440739,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/end","seq":63,"time":1783095440739,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index 0e6d3ac407..1f08a5ec27 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -54,7 +54,7 @@ {"type":"assistant/message","seq":52,"time":1783095409582,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":72,"outputTokens":84,"cacheReadTokens":1664,"reasoningTokens":17}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1783095409582,"data":{"turn":1,"step":1,"callId":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} {"type":"hook/invoked","seq":54,"time":1783095409583,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":55,"time":1783095409598,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":15.254155000000083}} +{"type":"hook/result","seq":55,"time":1783095409598,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session"}} {"type":"tool/result","seq":56,"time":1783095409599,"data":{"turn":1,"step":1,"callId":"call_00_d1KxP9oXmTPECtwtxuVc9576","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1783095409599,"data":{"turn":1,"step":1}} {"type":"step/start","seq":58,"time":1783095409599,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl index bc9144f980..e85fc3595a 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook","durationMs":0}} +{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook"}} {"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by codex policy hook"}} {"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by codex policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 2e37a148e9..487e3d3769 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"09a657ad-72c0-4fd3-aae1-00b66dfdb3d5","createdAt":1783095399158,"cwd":"/tmp/acp-snap-cwd-rBmbYp"} {"type":"turn/start","seq":0,"time":1783095399163,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":1783095399164,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":1783095399180,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":15.786223999999947}} +{"type":"hook/result","seq":2,"time":1783095399180,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0}} {"type":"user/message","seq":3,"time":1783095399180,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":4,"time":1783095399180,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783095399181,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index 09801dd4d9..3e9107d32b 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -30,7 +30,7 @@ {"type":"assistant/message","seq":28,"time":1783095446572,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}},"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],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1783095446573,"data":{"turn":1,"step":1}} {"type":"hook/invoked","seq":30,"time":1783095446573,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} -{"type":"hook/result","seq":31,"time":1783095446588,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":14.944511000000148}} +{"type":"hook/result","seq":31,"time":1783095446588,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop."}} {"type":"steering/message","seq":32,"time":1783095446588,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/start","seq":33,"time":1783095446589,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":34,"time":1783095447244,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -243,5 +243,5 @@ {"type":"assistant/message","seq":241,"time":1783095449029,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to reply with the single word \"FIRST\" and stop, which I already did. Now there's a steering instruction telling me to reply with \"SECOND\" and stop. However, the user's original instruction was to reply with \"FIRST\" and stop - I've already complied with that. \n\nBut the steering instruction is injected via a plugin mechanism. I should follow the latest instruction which tells me to reply with \"SECOND\" and stop.\n\nActually, let me reconsider. The original user message said \"Reply with the single word FIRST and stop.\" I already responded with \"FIRST\". Now there's a steering instruction telling me to also reply with \"SECOND\" and stop. Since the original interaction is already complete (I already said FIRST and stopped), I think the steering is trying to get me to say something additional. But the user's original instruction said to stop after FIRST.\n\nI'll follow the steering instruction since it's the most recent directive."},{"type":"text","text":"SECOND"}],"usage":{"inputTokens":82,"outputTokens":202,"cacheReadTokens":1664,"reasoningTokens":199}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240],"surfaceOp":"append"} {"type":"step/end","seq":242,"time":1783095449029,"data":{"turn":1,"step":2}} {"type":"hook/invoked","seq":243,"time":1783095449029,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} -{"type":"hook/result","seq":244,"time":1783095449039,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":9.63256199999978}} +{"type":"hook/result","seq":244,"time":1783095449039,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0}} {"type":"turn/end","seq":245,"time":1783095449039,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 8478f8aa74..c3591a61fa 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -9,16 +9,16 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud | Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) | |---|---|---| | Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) | -| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | +| Run a hook | `runHook(bash, hook, opts)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | | Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision | | Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — | -| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events) | calls them around each invocation | +| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation | ## Primitives - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). -- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `defaultTimeoutMs`), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. -- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason`/`suppressOutput` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total. +- **`runHook(bash, hook, options)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). +- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. ## `hook/*` session events @@ -26,7 +26,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): - `hook/invoked` — `{ turn, point, dialect, matcher?, handlerId }`: a hook command ran. -- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`. +- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary? }`: its outcome, paired by `handlerId`. `appendHookResult` owns the semantics: `decision` is the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`; `stderrSummary` is the trimmed stderr truncated to 500 characters (omitted when empty). Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC. diff --git a/packages/hooks/hook-protocol/src/codec.ts b/packages/hooks/hook-protocol/src/codec.ts index b5170028c2..7cf97feca9 100644 --- a/packages/hooks/hook-protocol/src/codec.ts +++ b/packages/hooks/hook-protocol/src/codec.ts @@ -21,7 +21,7 @@ import type { HookOutput } from './types.ts' /** The exit code a hook uses to signal a blocking error (stderr → model). */ -export const BLOCKING_EXIT_CODE = 2 +const BLOCKING_EXIT_CODE = 2 /** Read a string field from a parsed object, or `undefined` if absent/wrong type. */ function str(obj: Record<string, unknown>, key: string): string | undefined { @@ -72,7 +72,7 @@ function permissionDecisionOf(value: string | undefined): HookOutput['decision'] * `additionalContext`/`updatedInput`) are DISCARDED — a `PreToolUse` block on a * `Stop` hook must not deny the `Stop`. The block's `hookEventName` is still * surfaced (for the log/diagnostics), and the event-agnostic top-level fields - * (`decision`/`reason`/`continue`/`stopReason`/`suppressOutput`/`systemMessage`) + * (`decision`/`reason`/`continue`/`stopReason`/`systemMessage`) * are unaffected. Omit `expectedEventName` (or pass a matching one) to apply the * block as-is — a caller that doesn't key by event opts out of the check. */ @@ -125,8 +125,6 @@ function applyStructured(output: HookOutput, parsed: Record<string, unknown>, ex if (cont !== undefined) output.continue = cont const stopReason = str(parsed, 'stopReason') if (stopReason !== undefined) output.stopReason = stopReason - const suppress = bool(parsed, 'suppressOutput') - if (suppress !== undefined) output.suppressOutput = suppress const sysMsg = str(parsed, 'systemMessage') if (sysMsg !== undefined) output.systemMessage = sysMsg diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index 0db75995c9..169fe59950 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -16,7 +16,7 @@ */ import type { Session } from '@deepseek-ai/dsh-session' -import type { HookDialect } from './types.ts' +import type { HookDialect, HookOutput } from './types.ts' /** What identifies a hook invocation across its invoked/result pair. */ export interface HookInvocation { @@ -37,14 +37,22 @@ export interface HookResultRecord { turn: number point: string handlerId: string - /** The dialect-neutral decision the bridge resolved (`deny`/`allow`/`block`/…). */ - decision: string - /** The process exit code (absent when the hook could not run). */ - exitCode?: number - /** A truncated stderr summary (the block-reason source on exit 2). */ - stderrSummary?: string - /** Wall-clock duration of the run. */ - durationMs: number + /** + * The decoded outcome the run produced. {@link appendHookResult} derives the + * durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared + * event's semantics live here, in the lib that declares it, not per-bridge. + */ + output: HookOutput +} + +/** How many characters of stderr the `hook/result.stderrSummary` field keeps. */ +const STDERR_SUMMARY_MAX = 500 + +/** Truncate a stderr blob for the `hook/result.stderrSummary` field (`undefined` when empty). */ +function summarizeStderr(stderr: string): string | undefined { + const t = stderr.trim() + if (t.length === 0) return undefined + return t.length > STDERR_SUMMARY_MAX ? t.slice(0, STDERR_SUMMARY_MAX) + '…' : t } /** Append a `hook/invoked` provenance event to `session`. */ @@ -58,15 +66,22 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation): }) } -/** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */ +/** + * Append a `hook/result` outcome event to `session` (pairs with a prior + * `hook/invoked`). Owns the durable event's semantics: `decision` is the hook's + * parsed decision, else `'stop'` when it asked to halt (`continue: false`), + * else `'pass'`; `stderrSummary` is the trimmed stderr truncated to 500 + * characters (omitted when empty); `exitCode` is omitted when the hook never ran. + */ export function appendHookResult(session: Session, record: HookResultRecord): void { + const { output } = record + const stderrSummary = summarizeStderr(output.stderr) session.append('hook/result', { turn: record.turn, point: record.point, handlerId: record.handlerId, - decision: record.decision, - ...record.exitCode !== undefined ? { exitCode: record.exitCode } : {}, - ...record.stderrSummary !== undefined ? { stderrSummary: record.stderrSummary } : {}, - durationMs: record.durationMs, + decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), + ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {}, + ...stderrSummary !== undefined ? { stderrSummary } : {}, }) } diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index 686a1480ac..38459d4eea 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -12,7 +12,9 @@ * - {@link mergeHookOutputs} — fold multiple matched hooks into one * most-restrictive {@link MergedHookOutcome} (deny > ask > allow). * - {@link appendHookInvoked} / {@link appendHookResult} — the log-only `hook/*` - * session-event helpers (declaration-merged into `SessionEventMap`). + * session-event helpers (declaration-merged into `SessionEventMap`); + * `appendHookResult` derives the durable `decision`/`stderrSummary` from the + * {@link HookOutput} so the shared event's semantics live in one place. * * Each bridge owns what genuinely DIFFERS: building the per-event stdin payload * (CC vs Codex field sets), the dialect's env/substitution, and mapping the @@ -29,9 +31,9 @@ export type { MatcherMode, } from './types.ts' export { matchesMatcher } from './matcher.ts' -export { BLOCKING_EXIT_CODE, parseHookOutput } from './codec.ts' -export { runHook } from './runner.ts' -export type { RunHookOptions, RunHookResult } from './runner.ts' +export { parseHookOutput } from './codec.ts' +export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' +export type { RunHookOptions } from './runner.ts' export { mergeHookOutputs } from './merge.ts' export type { MergedDecision, MergedHookOutcome } from './merge.ts' export { appendHookInvoked, appendHookResult } from './events.ts' diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index cea09c1fe7..303e160273 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -17,6 +17,14 @@ import type { BashExecutor } from '@deepseek-ai/dsh-bash' import { parseHookOutput } from './codec.ts' import type { CommandHook, HookOutput } from './types.ts' +/** + * The reference default per-hook timeout, in ms (10 minutes) — the value both + * Claude Code and Codex apply to a hook whose config sets no `timeout`. It + * lives here, once, as the protocol's default; a per-hook {@link CommandHook.timeoutSec} + * is the override surface. + */ +export const DEFAULT_HOOK_TIMEOUT_MS = 600_000 + /** Everything a single hook invocation needs beyond its command line. */ export interface RunHookOptions { /** The JSON payload object written to the hook's stdin (the bridge builds it). */ @@ -27,8 +35,6 @@ export interface RunHookOptions { cwd?: string /** Abort signal — cancels the hook run when fired (the parent step aborts). */ signal?: AbortSignal - /** Default timeout (ms) when the hook config sets none. */ - defaultTimeoutMs: number /** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */ trailingNewline: boolean /** @@ -40,30 +46,22 @@ export interface RunHookOptions { expectedEventName?: string } -/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */ -export interface RunHookResult { - output: HookOutput - durationMs: number -} - /** * Run `hook` via `bash` with `options.payload` serialized to its stdin, then - * decode the result. `now` is injected (a monotonic-ms source) so the duration - * is testable without a real clock. The hook's configured `timeoutSec` (wire - * unit: seconds) overrides `defaultTimeoutMs`. The command runs with the - * dialect's `env` merged after the executor's credential scrub (the trusted- - * plugin path). NEVER throws: an infrastructure failure (the executor rejecting) - * is surfaced as a {@link HookOutput} with `exitCode: undefined`, so the caller's - * merge logic treats it as a non-blocking error rather than crashing the turn. + * decode the result into a {@link HookOutput}. The hook's configured + * `timeoutSec` (wire unit: seconds) overrides {@link DEFAULT_HOOK_TIMEOUT_MS}. + * The command runs with the dialect's `env` merged after the executor's + * credential scrub (the trusted-plugin path). NEVER throws: an infrastructure + * failure (the executor rejecting) is surfaced as a {@link HookOutput} with + * `exitCode: undefined`, so the caller's merge logic treats it as a + * non-blocking error rather than crashing the turn. */ export async function runHook( bash: BashExecutor, hook: CommandHook, options: RunHookOptions, - now: () => number, -): Promise<RunHookResult> { - const started = now() - const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs +): Promise<HookOutput> { + const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : DEFAULT_HOOK_TIMEOUT_MS const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '') const request = { @@ -81,18 +79,12 @@ export async function runHook( // protocol's exit-code contract is numeric, so a signal death maps to // `undefined` (a non-blocking error — no clean exit code to act on). const exitCode = result.exitCode ?? undefined - return { - output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName), - durationMs: now() - started, - } + return parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName) } catch (error: unknown) { // The executor rejects only on infrastructure faults (unusable workdir, // missing shell). A hook that cannot run is a non-blocking error: no exit // code, the failure on stderr for the record. The turn proceeds. const message = error instanceof Error ? error.message : String(error) - return { - output: parseHookOutput(undefined, '', message), - durationMs: now() - started, - } + return parseHookOutput(undefined, '', message) } } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index c3b75e7c08..d5d19988c9 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -18,7 +18,7 @@ declare module '@deepseek-ai/dsh-session' { /** * A hook command was invoked at a hook point — log-only provenance (like * `compact/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`). - * `dialect` is the bridge that ran it (`claude`/`codex`/`native`), `point` + * `dialect` is the bridge that ran it (`claude`/`codex`), `point` * the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group * pattern that selected it (absent for match-all), `handlerId` a stable id * for the command (so an invoked/result pair correlates). `turn` is the open @@ -34,11 +34,13 @@ declare module '@deepseek-ai/dsh-session' { } /** * A hook command's outcome — log-only, paired with a prior `hook/invoked` - * (same `handlerId`). `decision` is the resolved dialect-neutral outcome the - * bridge mapped it to (`allow`/`deny`/`ask`/`block`/`continue`/`stop`/`pass`), - * `exitCode` the process exit (absent if it never ran), `stderrSummary` a - * truncated stderr (the block reason source on exit 2), `durationMs` the wall - * time. `turn` matches the `hook/invoked`. + * (same `handlerId`). `decision` is the dialect-neutral outcome derived by + * `appendHookResult` (which owns the rule): the hook's parsed decision + * (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to + * halt via `continue:false`, else `'pass'`. `exitCode` is the process exit + * (absent if it never ran), `stderrSummary` the trimmed stderr truncated to + * 500 characters (the block reason source on exit 2). `turn` matches the + * `hook/invoked`. * @mode emit */ 'hook/result': { @@ -48,13 +50,16 @@ declare module '@deepseek-ai/dsh-session' { decision: string exitCode?: number stderrSummary?: string - durationMs: number } } } -/** Which protocol dialect a hook config / invocation belongs to. */ -export type HookDialect = 'claude' | 'codex' | 'native' +/** + * The bridge that ran a hook — the CC bridge stamps `'claude'`, the Codex + * bridge `'codex'`. A native plugin on the interception seams is not a bridge + * and writes no `hook/*` provenance (see the interception-seams RFC). + */ +export type HookDialect = 'claude' | 'codex' /** * One configured command hook (the `{ type: 'command', command, timeout? }` @@ -115,8 +120,6 @@ export interface HookOutput { continue?: boolean /** Human-readable reason shown when {@link continue} is `false`. */ stopReason?: string - /** Hide the hook's stdout from the transcript (CC `suppressOutput`). */ - suppressOutput?: boolean /** * The neutral blocking decision a hook expressed, folded from the two channels * the reference protocols keep DISTINCT: the legacy top-level `decision` diff --git a/packages/hooks/hook-protocol/tests/codec.spec.ts b/packages/hooks/hook-protocol/tests/codec.spec.ts index 5f72753c57..252caa4cff 100644 --- a/packages/hooks/hook-protocol/tests/codec.spec.ts +++ b/packages/hooks/hook-protocol/tests/codec.spec.ts @@ -38,13 +38,12 @@ describe('parseHookOutput — exit code semantics', () => { }) describe('parseHookOutput — structured stdout (exit 0 only)', () => { - it('parses top-level continue/stopReason/suppressOutput/systemMessage', () => { + it('parses top-level continue/stopReason/systemMessage', () => { const out = parseHookOutput(0, JSON.stringify({ - continue: false, stopReason: 'budget exceeded', suppressOutput: true, systemMessage: 'heads up', + continue: false, stopReason: 'budget exceeded', systemMessage: 'heads up', }), '') expect(out.continue).toBe(false) expect(out.stopReason).toBe('budget exceeded') - expect(out.suppressOutput).toBe(true) expect(out.systemMessage).toBe('heads up') }) diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index f63ae2a9cb..4a4d846d63 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -1,6 +1,11 @@ import { describe, expect, it } from 'vitest' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import { appendHookInvoked, appendHookResult } from '@deepseek-ai/dsh-hook-protocol' +import { appendHookInvoked, appendHookResult, type HookOutput } from '@deepseek-ai/dsh-hook-protocol' + +/** A {@link HookOutput} with the required stream fields defaulted. */ +function output(over: Partial<HookOutput> = {}): HookOutput { + return { exitCode: 0, stderr: '', stdout: '', ...over } +} describe('hook/* session events', () => { it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => { @@ -18,7 +23,7 @@ describe('hook/* session events', () => { it('omits matcher when absent (match-all hook)', () => { const session = new Session(SessionId('s')) - appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'native', handlerId: 'h2' }) + appendHookInvoked(session, { turn: 2, point: 'Stop', dialect: 'codex', handlerId: 'h2' }) const ev = [...session.events].find(e => e.type === 'hook/invoked') if (ev?.type === 'hook/invoked') { @@ -26,32 +31,72 @@ describe('hook/* session events', () => { } }) - it('appendHookResult records the decided outcome, omitting absent optionals', () => { + it('appendHookResult derives decision/exitCode/stderrSummary from the output', () => { const session = new Session(SessionId('s')) appendHookResult(session, { - turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', - exitCode: 2, stderrSummary: 'blocked', durationMs: 12, + turn: 1, point: 'PreToolUse', handlerId: 'h1', + output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) const full = [...session.events].find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { - expect(full.data).toMatchObject({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 12 }) + expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked' }) } // A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys. const session2 = new Session(SessionId('s2')) - appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', decision: 'allow', durationMs: 3 }) + appendHookResult(session2, { + turn: 1, point: 'Stop', handlerId: 'h3', + output: output({ exitCode: undefined, decision: 'allow' }), + }) const sparse = [...session2.events].find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { expect('exitCode' in sparse.data).toBe(false) expect('stderrSummary' in sparse.data).toBe(false) - expect(sparse.data.durationMs).toBe(3) + expect(sparse.data.decision).toBe('allow') + } + }) + + it('the decision falls back to stop on continue:false, else pass', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', output: output({ continue: false }) }) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', output: output() }) + // An explicit decision wins over the continue:false fallback. + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', output: output({ continue: false, decision: 'block' }) }) + + const decisions = [...session.events] + .filter(e => e.type === 'hook/result') + .map(e => e.type === 'hook/result' ? [e.data.handlerId, e.data.decision] : []) + expect(decisions).toEqual([['halt', 'stop'], ['noop', 'pass'], ['both', 'block']]) + }) + + it('stderrSummary is trimmed and truncated to 500 characters with an ellipsis', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { + turn: 1, point: 'PreToolUse', handlerId: 'long', + output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), + }) + const ev = [...session.events].find(e => e.type === 'hook/result') + if (ev?.type === 'hook/result') { + expect(ev.data.stderrSummary).toBe('x'.repeat(500) + '…') + } + }) + + it('a 500-character stderr is kept verbatim (the cap is exclusive)', () => { + const session = new Session(SessionId('s')) + appendHookResult(session, { + turn: 1, point: 'PreToolUse', handlerId: 'edge', + output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), + }) + const ev = [...session.events].find(e => e.type === 'hook/result') + if (ev?.type === 'hook/result') { + expect(ev.data.stderrSummary).toBe('y'.repeat(500)) } }) it('an invoked/result pair correlates by handlerId', () => { const session = new Session(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' }) - appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', decision: 'allow', exitCode: 0, durationMs: 7 }) + appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', output: output({ decision: 'allow' }) }) const invoked = [...session.events].find(e => e.type === 'hook/invoked') const result = [...session.events].find(e => e.type === 'hook/result') diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index 1cbe1b46de..9f23c509a5 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import type { BashExecRequest, BashExecSpec, BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' -import { runHook } from '@deepseek-ai/dsh-hook-protocol' +import { DEFAULT_HOOK_TIMEOUT_MS, runHook } from '@deepseek-ai/dsh-hook-protocol' /** * A minimal stand-in for the bits of {@link BashExecutor} that {@link runHook} @@ -49,23 +49,20 @@ function result(over: Partial<BashRunResult> = {}): BashRunResult { } } -const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5 - describe('runHook — payload + env + stdin plumbing', () => { it('serializes the payload to stdin (with trailing newline when requested)', async () => { const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } })) await runHook(bash, { command: 'my-hook.sh' }, { payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' }, - defaultTimeoutMs: 60000, trailingNewline: true, - }, clock()) + }) expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n') expect(specs[0]!.command).toBe('my-hook.sh') }) it('omits the trailing newline when trailingNewline is false (Codex)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock()) + await runHook(bash, { command: 'h' }, { payload: { a: 1 }, trailingNewline: false }) expect(specs[0]!.stdin).toBe('{"a":1}') }) @@ -73,46 +70,46 @@ describe('runHook — payload + env + stdin plumbing', () => { const { bash, specs } = recordingBash(async () => result()) await runHook(bash, { command: 'h' }, { payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', - defaultTimeoutMs: 1000, trailingNewline: true, - }, clock()) + trailingNewline: true, + }) expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' }) expect(specs[0]!.workdir).toBe('/work') }) - it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => { + it('a per-hook timeoutSec (seconds) overrides the reference default', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, trailingNewline: true }) expect(specs[0]!.timeoutMs).toBe(3000) }) - it('falls back to the default timeout when the hook sets none', async () => { + it('falls back to DEFAULT_HOOK_TIMEOUT_MS when the hook sets none', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) - expect(specs[0]!.timeoutMs).toBe(60000) + await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true }) + expect(specs[0]!.timeoutMs).toBe(DEFAULT_HOOK_TIMEOUT_MS) + expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes) }) it('passes the abort signal through', async () => { const controller = new AbortController() const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, trailingNewline: true }) expect(specs[0]!.signal).toBe(controller.signal) }) }) -describe('runHook — outcome decoding + duration', () => { - it('decodes a clean exit with structured stdout and reports a duration', async () => { +describe('runHook — outcome decoding', () => { + it('decodes a clean exit with structured stdout', async () => { const { bash } = recordingBash(async () => result({ exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false }, })) - const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true }) expect(output.decision).toBe('block') expect(output.reason).toBe('no') - expect(durationMs).toBe(5) }) it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => { const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } })) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true }) expect(output.exitCode).toBeUndefined() expect(output.decision).toBeUndefined() expect(output.stderr).toBe('killed') @@ -120,7 +117,7 @@ describe('runHook — outcome decoding + duration', () => { it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => { const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') }) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true }) expect(output.exitCode).toBeUndefined() expect(output.stderr).toBe('bad workdir: ENOENT') expect(output.decision).toBeUndefined() @@ -128,7 +125,7 @@ describe('runHook — outcome decoding + duration', () => { it('a non-Error rejection is stringified onto stderr', async () => { const { bash } = recordingBash(async () => { throw 'plain string fault' }) - const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) + const output = await runHook(bash, { command: 'h' }, { payload: {}, trailingNewline: true }) expect(output.stderr).toBe('plain string fault') }) @@ -137,9 +134,9 @@ describe('runHook — outcome decoding + duration', () => { exitCode: 0, stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false }, })) - const { output } = await runHook(bash, { command: 'h' }, { - payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', - }, clock()) + const output = await runHook(bash, { command: 'h' }, { + payload: {}, trailingNewline: true, expectedEventName: 'Stop', + }) // A PreToolUse block on a Stop hook is malformed → its decision is discarded. expect(output.hookEventName).toBe('PreToolUse') expect(output.decision).toBeUndefined() diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index ce1c6b090a..1fe2c8ea50 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -12,7 +12,6 @@ const config: Config = { configPath: '/path/to/hooks.json', // required: a hooks.json or a settings file with a `hooks` key pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted - defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default) } ``` @@ -25,7 +24,7 @@ In a `cordis.yml`: projectDir: . ``` -The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. +The config is parsed **once** at load. `configPath` is **process-level**: a relative path resolves against the process's launch cwd at load time, so a single config applies to the whole process — there is no per-session (`session/new.cwd`) config discovery yet (`TODO(per-session-hook-config)`). A read/parse failure is contained — the bridge logs a warning and registers nothing rather than crashing boot (a typo'd path must not take the agent down). Only `type: 'command'` hooks run; a `prompt`/`agent`/HTTP hook is parsed-and-skipped with a warning. A hook with no per-hook `timeout` runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes — the CC default). The hooks **themselves** run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` (the `session/new.cwd`) as the hook process's working directory, so a hook's `pwd`/relative-path/marker operates in the user's project tree, not the server launch dir. diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 6151a11c44..3abe31a386 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -71,15 +71,12 @@ export interface Config { * unmodified hooks reference `$CLAUDE_PROJECT_DIR` for project-relative paths. */ projectDir?: string - /** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */ - defaultTimeoutMs?: number } export const Config: z<Config> = z.object({ configPath: z.string().required(), pluginRoot: z.string(), projectDir: z.string(), - defaultTimeoutMs: z.number().default(600_000), }) /** A stable per-handler id so an invoked/result pair correlates in the log. */ @@ -91,13 +88,6 @@ function nextHandlerId(point: string): string { /** The `{kind:'plugin'}` source stamped on every context this bridge injects. */ const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' } -/** Truncate a stderr blob for the `hook/result` summary field. */ -function summarize(stderr: string): string | undefined { - const t = stderr.trim() - if (t.length === 0) return undefined - return t.length > 500 ? t.slice(0, 500) + '…' : t -} - export function apply(ctx: Context, config: Config): void { // --- Parse the config ONCE at load. A read/parse failure is contained: the // bridge logs and registers nothing rather than crashing boot (a typo'd path @@ -118,8 +108,6 @@ export function apply(ctx: Context, config: Config): void { return } - const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000 - /** * Run every command hook configured for `point` whose matcher selects * `matchQuery`, with the per-event `payload` on stdin, and fold the results. @@ -163,17 +151,16 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } - const { output, durationMs } = await runHook(ctx.bash, hook, { + const output = await runHook(ctx.bash, hook, { payload, ...hookEnv ? { env: hookEnv } : {}, ...workdir !== undefined ? { cwd: workdir } : {}, ...opts.signal ? { signal: opts.signal } : {}, - defaultTimeoutMs, trailingNewline: true, // Discard a `hookSpecificOutput` block whose `hookEventName` names a // different event than the one firing (the schemas key it by event). expectedEventName: point, - }, () => performance.now()) + }) outputs.push(output) if (output.updatedInput !== undefined) { ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`) @@ -182,14 +169,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } if (session && opts.turn !== undefined) { - const stderrSummary = summarize(output.stderr) - appendHookResult(session, { - turn: opts.turn, point, handlerId, - decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), - ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {}, - ...stderrSummary !== undefined ? { stderrSummary } : {}, - durationMs, - }) + appendHookResult(session, { turn: opts.turn, point, handlerId, output }) } } } diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 63e2f611ce..abfb48a914 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -297,8 +297,8 @@ describe('hooks-claude coverage — more default/sparse arms', () => { }) }) -describe('hooks-claude coverage — schema-bypass default + unspawnable hook', () => { - it('a direct apply() (schema bypass) defaults the timeout and runs', async () => { +describe('hooks-claude coverage — schema-bypass apply + unspawnable hook', () => { + it('a direct apply() (schema bypass) with only configPath runs', async () => { const d = dir() const marker = join(d, 'ran') const s = sh(d, 'h.sh', `#!/usr/bin/env bash\ntouch "${marker}"\n`) @@ -312,8 +312,9 @@ describe('hooks-claude coverage — schema-bypass default + unspawnable hook', ( await ctx.plugin(AgentRegistry) await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - // Direct apply with only configPath — bypasses schemastery's defaults, so the - // runtime `defaultTimeoutMs ?? 600_000` fallback is exercised. + // Direct apply with only configPath — bypasses schemastery's defaults, so + // the bridge must run on the raw minimal config (the per-hook timeout is + // the protocol lib's reference default, not a config knob). HooksClaude.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 72be33f57f..2d50ca1611 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -19,7 +19,6 @@ import type { Config } from '@deepseek-ai/dsh-hooks-codex' const config: Config = { configPath: '/path/to/.codex/hooks.json', // required model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`) - defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none } ``` @@ -31,7 +30,7 @@ In a `cordis.yml`: model: deepseek-v4 ``` -The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias. Events outside the five Codex points are dropped at parse. +The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five Codex points are dropped at parse. The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir. diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index a704a6af24..425f1b3fb8 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -47,14 +47,11 @@ export interface Config { configPath: string /** The model name stamped on every payload (Codex includes `model` on each event). */ model?: string - /** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */ - defaultTimeoutMs?: number } export const Config: z<Config> = z.object({ configPath: z.string().required(), model: z.string().default(''), - defaultTimeoutMs: z.number().default(600_000), }) let handlerCounter = 0 @@ -64,12 +61,6 @@ function nextHandlerId(point: string): string { const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' } -function summarize(stderr: string): string | undefined { - const t = stderr.trim() - if (t.length === 0) return undefined - return t.length > 500 ? t.slice(0, 500) + '…' : t -} - export function apply(ctx: Context, config: Config): void { let parsed: CodexHookConfig = {} try { @@ -84,7 +75,6 @@ export function apply(ctx: Context, config: Config): void { return } - const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000 const model = config.model ?? '' async function runPoint( @@ -111,15 +101,14 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } - const { output, durationMs } = await runHook(ctx.bash, hook, { + const output = await runHook(ctx.bash, hook, { payload, ...workdir !== undefined ? { cwd: workdir } : {}, ...opts.signal ? { signal: opts.signal } : {}, - defaultTimeoutMs, trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline. // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, - }, () => performance.now()) + }) // Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN // (non-JSON) stdout as additionalContext. The codec keeps that raw text on // `output.stdout` but only sets `additionalContext` from a JSON @@ -140,14 +129,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } if (session && opts.turn !== undefined) { - const stderrSummary = summarize(output.stderr) - appendHookResult(session, { - turn: opts.turn, point, handlerId, - decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), - ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {}, - ...stderrSummary !== undefined ? { stderrSummary } : {}, - durationMs, - }) + appendHookResult(session, { turn: opts.turn, point, handlerId, output }) } } } diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 87032c98ce..22dfa071c7 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -206,7 +206,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) }) - it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => { + it('warns on a skipped async hook and a direct apply() (schema bypass) runs', async () => { const d = dir() const marker = join(d, 'ran') hooks(d, { UserPromptSubmit: [{ hooks: [ @@ -220,7 +220,7 @@ describe('hooks-codex coverage — decision mapping paths', () => { await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) ctx.logger.warn = warn as never - // Direct apply (schema bypass) → defaultTimeoutMs ?? 600_000 + model ?? '' fallbacks. + // Direct apply (schema bypass) → the `model ?? ''` fallback is exercised. HooksCodex.apply(ctx, { configPath: join(d, 'hooks.json') }) ctx.llm.registerAdapter(['mock'], adapter) const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) From 40363003539b2bee08c0aab6bc7186d7b7c45179 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:46:11 +0800 Subject: [PATCH 09/32] refactor(acp): trim unreachable bridge surface (branding knobs, kind-sniffing fallback) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two pieces of dsh-acp surface were unreachable from any shipped config: - AcpConfig.agentName/agentVersion: the app package hands the bridge only { model, systemPrompt }, so no leaf cordis.yml could set them; they were settable only by direct-mounting the bridge (a unit test). Hardcode agentInfo at the initialize site and delete the fields, their schema defaults, the ?? fallbacks, and the TODO(double-default) whose subject vanishes. The handshake wire value is unchanged (all snapshot initialize lines byte-identical). - The toolKindFor name heuristic special-cased bash*/read*/write/edit* names in the generic-fallback path, violating the bridge's own design rule ("the bridge never special-cases tool names"). Every first-party tool ships its kind via presentCall; the fallback now renders the neutral kind 'other'. The fallback is reachable when a presentCall throws OR when model args fail the tool schema (defineTool's presentCall wrapper returns undefined on violations) — the latter shows up in one committed golden (hook-codex-posttool-block: three bash calls missing the required description), whose kind cells flip execute->other. That 3-line golden refresh is the whole transcript delta. The empty-arguments branch of parseToolArguments lost its only exercise with the deleted heuristic test; it is live behavior (JSON.parse('') throws, so the guard is what renders a zero-arg call as rawInput {}), so it gets a dedicated pin instead of deletion. RFC moved to docs/rfc/implemented/simplification/ and amended to shipped reality: fallback reachability includes schema-invalid args, and the golden churn is exactly the three kind cells (the original zero-churn claim held only for the branding half). --- docs/rfc/README.md | 2 +- ...-04-trim-acp-bridge-unreachable-surface.md | 22 +++++++++++ ...-04-trim-acp-bridge-unreachable-surface.md | 27 -------------- .../stdout.golden.jsonl | 6 +-- packages/ui/acp/README.md | 6 +-- packages/ui/acp/acp-feature-support.md | 4 +- packages/ui/acp/src/index.ts | 37 +++++-------------- packages/ui/acp/tests/bridge.spec.ts | 9 ++--- packages/ui/acp/tests/stream-update.spec.ts | 24 ++++++------ packages/ui/acp/tests/turns.spec.ts | 5 ++- 10 files changed, 62 insertions(+), 80 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index d53684bf6b..e19a16e52b 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -62,7 +62,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | | [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | -| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | ### Architecture @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md new file mode 100644 index 0000000000..3df0e0322a --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md @@ -0,0 +1,22 @@ +# RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback + +Status: implemented (accepted 2026-07-04) + +## Problem + +Two pieces of `dsh-acp` surface were unreachable from any shipped configuration: + +1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model, systemPrompt }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — could set the knobs at all; they were settable solely by direct-mounting the bridge, which only a unit test did. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carried a live `TODO(double-default)`: the literals existed twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. +2. **The `toolKindFor` name heuristic** (same file) special-cased `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms matched ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fell through to `other` anyway. The arms were production-reachable only when a tool declined to present its own call — a `presentCall` that THROWS (the containment fallback), or model arguments that fail the tool's schema so `defineTool`'s `presentCall` wrapper returns `undefined` (e.g. a `bash` call missing the required `description`) — and the bridge's own module doc states the design rule the heuristic violated: "the bridge never special-cases tool names". + +## Decision + +`agentInfo` is hardcoded at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`); the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` (whose subject vanished with them) are gone, along with the knob half of the direct-mount config test, the two config rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cells that described the knobs and the name inference. The emitted handshake wire value is unchanged — zero golden churn on the branding half. `toolKindFor` is replaced by the constant `'other'` at both fallback sites (the presenter fallback and `nullToolPresenter`), and the heuristic is deleted with its test rows. The fixed handshake identity stays pinned by the bridge's initialize unit test and by every snapshot golden. On the fallback half the transcript delta shows up in exactly one committed golden: `hook-codex-posttool-block`, whose recorded model omits the required `description` on three `bash` calls, so those cards take the declined-to-present fallback and carry `kind: 'other'` — the honest neutral card for a call the tool would not vouch for. + +## Why not keep them? + +`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO was its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` loses an inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The only shipped paths the heuristic reached were the declined-to-present fallbacks (a throwing `presentCall`, or schema-invalid model args); rendering kind `other` there makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter or a malformed call. + +## Risks + +None beyond the fallback rendering trade described above — degenerate paths whose neutral card is more diagnosable than an inferred first-party one. diff --git a/docs/rfc/proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md b/docs/rfc/proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md deleted file mode 100644 index a4bfbbf896..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback - -Status: proposed - -## Problem - -Two pieces of `dsh-acp` surface are unreachable from any shipped configuration: - -1. **`AcpConfig.agentName` / `agentVersion`** (`packages/ui/acp/src/index.ts`). The shipped app package hands the bridge only `{ model, systemPrompt }` (`packages/ui/acp-agent/src/index.ts`), so no leaf `cordis.yml` — the only production config surface — can set the knobs at all; they are settable solely by direct-mounting the bridge, which only a unit test does. Every snapshot golden — the hook-matrix scenarios included — pins the schema defaults (`deepseek-harness-acp` / `0.0.1`). The pair also carries a live `TODO(double-default)`: the literals exist twice (schema `.default(...)` plus `??` fallbacks), with the TODO asking to pick one home. -2. **The `toolKindFor` name heuristic** (same file) special-cases `bash*`/`read*`/`write`/`edit*` tool names in the generic-fallback path. Since the [render-intent union](../../implemented/architecture/2026-07-02-tool-render-intent-union.md), every first-party tool those arms match ships its own `presentCall` carrying its kind, and the presenter-less production tools (`subagent`, `subagent_fork`) fall through to `other` anyway. The arms are production-reachable only when a tool's `presentCall` THROWS (the containment fallback) — and the bridge's own module doc states the design rule the heuristic violates: "the bridge never special-cases tool names". - -## Proposal - -Hardcode `agentInfo` at the `initialize` site (`{ name: 'deepseek-harness-acp', version: '0.0.1' }`), deleting the two config fields, their schema defaults, the `??` fallbacks, and the `TODO(double-default)` whose subject vanishes; drop the knob half of the direct-mount config test, the two rows in `packages/ui/acp/README.md`, and the `packages/ui/acp/acp-feature-support.md` cell that cites the knobs. Zero golden churn — the emitted wire value is unchanged. Replace `toolKindFor` with the constant `'other'` in both fallback sites (the presenter fallback and `nullToolPresenter`) and delete the heuristic with its test rows. - -## Why not keep them? - -`agentInfo` is client-visible branding a deployment will eventually want configurable — but a knob no shipped config can reach is not configurability, it is drift surface (the double-default TODO is its symptom), and the honest re-add must include the `dsh-acp-agent` plumb-through that does not exist today either; both arrive together with the deployment that needs them. For the heuristic: a hypothetical third-party presenter-less tool named `read_docs` would lose its inferred `read` icon — but inferring kinds from unknown plugins' names is exactly the special-casing the render-intent design rejected. The behavior delta on shipped paths is confined to the presenter-throw fallback, where rendering kind `other` makes the client show the raw input instead of a masquerading first-party card — strictly better diagnostics for a broken presenter. - -## Acceptance criteria - -- `agentName`/`agentVersion` and `toolKindFor` appear only in this RFC; snapshot goldens are byte-identical; bridge tests are green with the constant fallback. -- The `initialize` handshake continues to report `deepseek-harness-acp`/`0.0.1` (pinned by the handshake snapshot). - -## Risks - -None beyond the presenter-throw rendering delta described above — an error path whose new behavior is more diagnosable than the old. diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl index a2979ec54f..bc189b2cc2 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/stdout.golden.jsonl @@ -79,7 +79,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" or"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" command"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","title":"bash","kind":"execute","status":"in_progress","rawInput":{"command":"echo HELLO"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"echo HELLO"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_XLxXjqVljRIC2KwFZCKX3369","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"The"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" tool"}}}} @@ -99,7 +99,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" invoke"}}}} {"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":"."}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","title":"bash","kind":"execute","status":"in_progress","rawInput":{"command":"printf 'HELLO\\n'"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"printf 'HELLO\\n'"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":"Every"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_thought_chunk","content":{"type":"text","text":" attempt"}}}} @@ -139,7 +139,7 @@ {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" is"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":" working"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":":"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","title":"bash","kind":"execute","status":"in_progress","rawInput":{"command":"pwd"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","title":"bash","kind":"other","status":"in_progress","rawInput":{"command":"pwd"}}}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"tool_call_update","toolCallId":"call_00_xOnTckie072jHuPhX0Mz5115","status":"failed","content":[{"type":"content","content":{"type":"text","text":"tool output rejected by codex policy: summarize instead"}}]}}} {"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":" seems"}}}} diff --git a/packages/ui/acp/README.md b/packages/ui/acp/README.md index a311164c6b..38596b7fd0 100644 --- a/packages/ui/acp/README.md +++ b/packages/ui/acp/README.md @@ -16,8 +16,8 @@ It is a **client-driver / UI plugin**, the structured analogue of the readline ` |---|---|---| | `model` | — | Model name for created agents (must have a registered adapter). | | `systemPrompt` | — | Per-agent system prompt. | -| `agentName` | `deepseek-harness-acp` | Server name reported in `initialize`. | -| `agentVersion` | `0.0.1` | Server version reported in `initialize`. | + +The `initialize` handshake reports a fixed server identity (`agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }`) — branding is a literal at the `initialize` site, not config. ## ACP method mapping @@ -48,7 +48,7 @@ How a tool call renders in the editor is owned by the TOOL, not the bridge — t - `{ card: 'terminal', title, description?, cwd? }` — a shell command → a terminal card (see Terminal card). - `{ card: 'diff', title, diffs, locations? }` — a file create/modify → an inline diff card; `diffs` is `FileDiff[]` (`{ path, oldText, newText }`, `oldText: null` ⇒ new file). The bridge emits each diff as an ACP `{ type: 'diff', path, oldText, newText }` `tool_call.content` block, which Zed renders as an inline diff / new-file preview. -`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind inferred from the name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. +`presentResult` returns a `ToolResultView`, one of three cards: `{ card: 'generic', title?, content? }` (an optional replacement `title` and reformatted `content`), `{ card: 'terminal', title?, output?, exitCode?, signal? }` (the captured run output + exit — see Terminal card), or `{ card: 'diff', title?, diffs }` (a completed file mutation → typically the applied hunks with context lines computed from the before/after content, or a whole-file diff for a create; a successful mutation ALWAYS returns this so the model-facing result text can't clobber the diff — an ACP `tool_call_update.content` replaces the call's content). The bridge looks the definition up by name in `ctx.tools` and `switch (view.card)`es to build the ACP `tool_call`/`tool_call_update` wire shape per card; a tool that declares neither gets a generic fallback (title = tool name, raw parsed args as `rawInput`, kind `other` — the bridge never sniffs a kind from the tool name). For example `dsh-tool-bash` returns a `terminal` card for a foreground `bash` (title = the exact `command` "ls -la src", `description` = the model description); the `dsh-tool-fs` `write`/`edit` tools return a `diff` call card and a `diff` result card, and `read` returns a `generic` card (`kind: 'read'`, the read window in its title — `Read foo.txt (5 - 8)` — and a `locations` entry for the file). For a file card the bridge **relativizes the title** against the session cwd (mirroring `claude-agent-acp`'s `toDisplayPath` — `Read src/foo.ts`, not the absolute path) while keeping `locations[]`/`diffs[].path` **raw** so the editor opens the real path. The `tool/result` session event does not carry the tool name or args — so to call a tool's `presentResult` the bridge keeps a small per-session map from `callId` to the in-flight call's `(name, args)`, populated on `tool/call` and removed as each result is presented (it holds only currently-in-flight calls, never finished ones). This is bridge-local state — NOT a change to the event schema or a core service. The map lives on the `SessionRecord`, so two concurrent sessions never cross their in-flight tool state; a `session/load` replay uses a throwaway presenter that pairs each `tool/call` with its `tool/result` as the log replays in order, so replayed tool cards render identically to live ones. diff --git a/packages/ui/acp/acp-feature-support.md b/packages/ui/acp/acp-feature-support.md index 4a051bf5aa..d37c9b1eaf 100644 --- a/packages/ui/acp/acp-feature-support.md +++ b/packages/ui/acp/acp-feature-support.md @@ -63,7 +63,7 @@ These are capabilities the bridge would *drive* on the editor. The harness runs | `sessionCapabilities.*` | S | ❌ | ✅ | ✅ | None advertised (list/delete/resume/close/additionalDirectories/fork all off). | | `auth.logout` | S | ❌ | ✅ | ✅ | Not advertised. | | `authMethods[]` | S | ⚠️ | ✅ | ✅ | Advertised as empty (no auth required to reach the model). | -| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | From `agentName` / `agentVersion` config. | +| `agentInfo` (name/version) | S | ✅ | ✅ | ✅ | Fixed literals: `deepseek-harness-acp` / `0.0.1` (not config). | | `_meta` custom caps | S | ❌ | ✅ | — | E.g. Claude's `claudeCode.promptQueueing`. The bridge advertises no custom `_meta`. | ### 3b. `clientCapabilities` (consumed by the bridge) @@ -96,7 +96,7 @@ Tool-call presentation is **owned by each tool** (`presentCall` / `presentResult | Feature | Stable | Bridge | Claude | Codex | Notes | |---|---|---|---|---|---| -| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit`/`other` inferred from the tool; richer mapping possible. | +| `ToolCallKind` mapping | S | ✅ | ✅ | ✅ | `execute`/`read`/`edit` declared by each tool's `presentCall`; presenter-less tools render `other` (no name sniffing); richer mapping possible. | | `ToolCallStatus` | S | ✅ | ✅ | ✅ | `in_progress` → `completed`/`failed`. | | `content` blocks | S | ✅ | ✅ | ✅ | Text content; the description renders above the card. | | `diff` content | S | ✅ | ✅ | ✅ | The `write`/`edit` tools declare a `diff` render intent: `presentCall` → a call-time `{ card: 'diff' }` snippet, and `presentResult` → a result-time `{ card: 'diff' }`. For an edit or an overwrite it carries the applied hunk(s) with surrounding context (one per `replace_all` site), computed from the before/after text and persisted on the `tool/result` event as `meta`; for a create (no before-image) it is an args-derived whole-file diff. The bridge emits `{ type: 'diff', path, oldText, newText }` content blocks; a successful mutation ALWAYS returns the result diff (an ACP `tool_call_update.content` replaces the call's content, so the result diff — not the model-facing text — is what survives). | diff --git a/packages/ui/acp/src/index.ts b/packages/ui/acp/src/index.ts index 5492707902..98dfba0391 100644 --- a/packages/ui/acp/src/index.ts +++ b/packages/ui/acp/src/index.ts @@ -67,7 +67,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { AgentId } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TodoItem, TurnEndReason } from '@deepseek-ai/dsh-session' -import type { ToolCallKind, ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' +import type { ToolCallView, ToolRegistry, ToolResultView, TerminalResultView } from '@deepseek-ai/dsh-tools' // Side-effect type import: declaration-merges `ctx.sessionPersistence` onto // Context (the bridge injects it and reads `list()` for load cwd validation). import type {} from '@deepseek-ai/dsh-session-persistence' @@ -117,10 +117,6 @@ export interface AcpConfig { model?: string /** Per-agent system prompt. */ systemPrompt?: string - /** Agent/server name reported to the client in `initialize`. */ - agentName?: string - /** Agent/server version reported to the client in `initialize`. */ - agentVersion?: string /** * Transport stream override. Production omits this (the plugin wires * `process.stdin`/`process.stdout` via `ndJsonStream`). Tests inject an @@ -134,8 +130,6 @@ export interface AcpConfig { export const Config: Schema<AcpConfig> = Schema.object({ model: Schema.string(), systemPrompt: Schema.string(), - agentName: Schema.string().default('deepseek-harness-acp'), - agentVersion: Schema.string().default('0.0.1'), }) /** @@ -209,13 +203,6 @@ interface SessionRecord { * (settle-exactly-once). */ export function apply(ctx: Context, config: AcpConfig): void { - // TODO(double-default): these literals duplicate the Config schema defaults - // (`agentName`/`agentVersion` `.default(...)` above). The Loader applies the - // schema before apply() runs, so the `??` only fires for direct-apply unit - // tests. Pick one home for the default to avoid drift. - const agentName = config.agentName ?? 'deepseek-harness-acp' - const agentVersion = config.agentVersion ?? '0.0.1' - // Capture the injected services NOW, during apply(), while we are inside this // plugin's fiber (where `inject` grants access). The ACP method handlers run // LATER, from the AgentSideConnection's JSON-RPC read loop — a context that is @@ -430,7 +417,9 @@ export function apply(ctx: Context, config: AcpConfig): void { terminalOutputCap = params.clientCapabilities?._meta?.['terminal_output'] === true return Promise.resolve({ protocolVersion, - agentInfo: { name: agentName, version: agentVersion }, + // Fixed server identity: this bridge IS the harness ACP server, so the + // branding is a literal, not config (no shipped surface sets it). + agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, agentCapabilities: { loadSession: true, // Baseline prompt blocks only: text plus resource_link rendered as @@ -911,9 +900,11 @@ export class ToolPresenter { this.onError(`acp: tool "${name}" presentCall threw, using generic presentation: ${String(error)}`) present = undefined } - // No tool-owned presentation: fall back to the tool name as the title and the - // full parsed args as the raw input (the generic card). - const view: ToolCallView = present ?? { card: 'generic', title: name, kind: toolKindFor(name), rawInput: args } + // No tool-owned presentation: fall back to the tool name as the title, the + // full parsed args as the raw input, and kind `other` (the generic card). + // The kind is never sniffed from the name — the bridge does not special-case + // tool names; a tool that wants a richer kind declares `presentCall`. + const view: ToolCallView = present ?? { card: 'generic', title: name, kind: 'other', rawInput: args } this.pending.set(callId, { name, args, card: view.card }) return view } @@ -951,18 +942,10 @@ export class ToolPresenter { * results pass their raw content through unchanged. */ export const nullToolPresenter: Pick<ToolPresenter, 'call' | 'result'> = { - call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: toolKindFor(name), rawInput: parseToolArguments(argsJson) }), + call: (_callId, name, argsJson) => ({ card: 'generic', title: name, kind: 'other', rawInput: parseToolArguments(argsJson) }), result: (_callId, content) => ({ card: 'generic', content }), } -/** Map a harness tool name to an ACP ToolKind (best-effort; default `other`). */ -function toolKindFor(name: string): ToolCallKind { - if (name === 'bash' || name === 'bash_output' || name === 'bash_kill') return 'execute' - if (name === 'read' || name.startsWith('read')) return 'read' - if (name === 'write' || name === 'edit' || name.startsWith('edit')) return 'edit' - return 'other' -} - /** Parse a tool-call arguments JSON string for `rawInput`; raw string on failure. */ function parseToolArguments(args: string): unknown { try { diff --git a/packages/ui/acp/tests/bridge.spec.ts b/packages/ui/acp/tests/bridge.spec.ts index e9ebca8d62..5d729eaab8 100644 --- a/packages/ui/acp/tests/bridge.spec.ts +++ b/packages/ui/acp/tests/bridge.spec.ts @@ -33,7 +33,7 @@ describe('acp bridge', () => { expect(res.protocolVersion).toBe(PROTOCOL_VERSION) expect(res.agentCapabilities?.loadSession).toBe(true) expect(res.agentCapabilities?.promptCapabilities).toMatchObject({ image: false, audio: false }) - expect(res.agentInfo?.name).toBe('deepseek-harness-acp') + expect(res.agentInfo).toEqual({ name: 'deepseek-harness-acp', version: '0.0.1' }) }) it('session/new creates a session and a full prompt turn streams text then settles end_turn', async () => { @@ -148,14 +148,13 @@ describe('acp bridge', () => { await expect(harness.client.authenticate({ methodId: 'whatever' })).resolves.toBeDefined() }) - it('honors agentName/agentVersion/systemPrompt config', async () => { + it('honors systemPrompt config', async () => { harness = await makeBridgeHarness({ storageDir, script: [textResponse('ok')], - config: { agentName: 'custom-agent', agentVersion: '9.9.9', systemPrompt: 'be terse' }, + config: { systemPrompt: 'be terse' }, }) - const res = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - expect(res.agentInfo).toMatchObject({ name: 'custom-agent', version: '9.9.9' }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) // Create + prompt so the systemPrompt config flows through agentOptions and // reaches the model request. const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index b580a5fc3d..b8c6d226d3 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -50,32 +50,34 @@ describe('streamSessionEventUpdate', () => { .toEqual([]) }) - it('maps tool/call to an in_progress tool_call with inferred kind and parsed rawInput (generic fallback, no presenter)', () => { + it('maps tool/call to an in_progress tool_call with kind other and parsed rawInput (generic fallback, no presenter)', () => { const updates = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: '{"command":"ls"}' })) expect(updates).toEqual([{ sessionUpdate: 'tool_call', toolCallId: 'c1', title: 'bash', - kind: 'execute', + // The fallback never sniffs a kind from the tool name — even a name a + // first-party tool uses (`bash`) renders `other`; kinds are tool-owned + // via presentCall. + kind: 'other', status: 'in_progress', rawInput: { command: 'ls' }, }]) }) - it('infers tool kinds: read*/write*/edit*/other', () => { - const kind = (name: string): unknown => - updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c'), name, arguments: '' }))[0] - expect((kind('read_file') as { kind: string }).kind).toBe('read') - expect((kind('write') as { kind: string }).kind).toBe('edit') - expect((kind('edit_file') as { kind: string }).kind).toBe('edit') - expect((kind('frobnicate') as { kind: string }).kind).toBe('other') - }) - it('falls back to the raw argument string when tool arguments are not JSON', () => { const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'bash', arguments: 'not json' }))[0] expect((update as { rawInput: unknown }).rawInput).toBe('not json') }) + it('parses EMPTY tool arguments to an empty-object rawInput (a zero-arg call, not the raw-string fallback)', () => { + // `JSON.parse('')` throws, so without the empty-string guard a zero-arg + // call would render `rawInput: ''` via the non-JSON fallback; the guard + // normalizes it to `{}`. + const update = updatesFor(evt('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'noop', arguments: '' }))[0] + expect((update as { rawInput: unknown }).rawInput).toEqual({}) + }) + it('maps tool/result to completed/failed tool_call_update with text content', () => { const ok = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'out' }], isError: false })) expect(ok).toEqual([{ diff --git a/packages/ui/acp/tests/turns.spec.ts b/packages/ui/acp/tests/turns.spec.ts index 7634602a35..7151fb64fb 100644 --- a/packages/ui/acp/tests/turns.spec.ts +++ b/packages/ui/acp/tests/turns.spec.ts @@ -66,7 +66,10 @@ describe('acp bridge — turn outcomes', () => { const toolCalls = harness.updates.filter(u => u.sessionUpdate === 'tool_call') const toolUpdates = harness.updates.filter(u => u.sessionUpdate === 'tool_call_update') expect(toolCalls).toHaveLength(1) - expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'execute', status: 'in_progress' }) + // The inline stand-in declares no presentCall, so the generic fallback + // renders kind `other` (kinds are tool-owned; the bridge never sniffs the + // name — the REAL dsh-tool-bash test below covers the execute card). + expect(toolCalls[0]).toMatchObject({ toolCallId: 'c1', title: 'bash', kind: 'other', status: 'in_progress' }) expect(toolUpdates).toHaveLength(1) expect(toolUpdates[0]).toMatchObject({ toolCallId: 'c1', status: 'completed' }) From 74261b0e21e051d02c9f61c74d8a139dbc4ce8d5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:56:51 +0800 Subject: [PATCH 10/32] docs(rfc): finish amending the seam RFC's status-layer description The package-topology paragraph of the web capability seam RFC still described ctx.web as carrying 'one small selection-status layer', contradicting the shipped surface the rest of the amended RFC records (no aggregated status query; execution-time WebError explains a selection failure). State the current shape: two capability kinds and a richer selection policy whose failure the thrown error explains. --- .../implemented/architecture/2026-06-24-web-capability-seam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index d0ca9c8b9a..6acaab8459 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -37,7 +37,7 @@ The seam deliberately exposes no observation surface — no registry-change even ## Package topology -The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and one small selection-status layer so diagnostics and execution can explain why a search or fetch capability can or cannot run. +The three-package interface/implementation/consumer split follows bash and filesystem, but the *interface* package is closer to the LLM seam. `LlmService` (`packages/llm/llm/src/index.ts`) is a name-keyed provider registry: `registerAdapter(models, adapter)` stores adapters in a `Map`, returns a disposer, throws `DUPLICATE_ADAPTER` on duplicate keys, and throws `NO_ADAPTER` at resolution time. `ctx.web` follows that registry shape, but has two capability kinds and a richer selection policy (a configured provider id, or auto-select when exactly one usable provider is registered), so the `WebError` an execution throws can explain why a search or fetch capability cannot run. The dependency direction mirrors bash and filesystem: From e9589b523f9027ddfc5ea2f11baa42619a0e92e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 15:59:12 +0800 Subject: [PATCH 11/32] =?UTF-8?q?docs:=20fix=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20support-group=20summary=20row,=20smoke-prose=20expo?= =?UTF-8?q?rt-shape=20claims?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The packages/README.md group table still described support/ as holding the stdio UI; and three prose sites credited the keyless smokes with guarding the app export shape, which a bundle without inject cannot do (a stray default boots rather than crashes) — the shape is pinned by the stdio-agent unit suite's explicit unwrapExports assertion; the smokes prove the composed tree boots. --- .../simplification/2026-07-04-fold-stdio-ui-helper.md | 2 +- examples/coding-agent/tests/keyless-smoke.e2e.ts | 7 ++++--- examples/echo-agent/tests/echo.e2e.ts | 9 +++++---- packages/README.md | 2 +- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md index 292e2dc541..599aacefac 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md +++ b/docs/rfc/implemented/simplification/2026-07-04-fold-stdio-ui-helper.md @@ -10,7 +10,7 @@ The boundary bought package metadata, workspace and tsconfig references, module- ## Decision -The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep guarding the app's export shape end-to-end. +The helper lives inside `@deepseek-ai/dsh-stdio-agent` as the in-package `stdio-chat` module (`packages/ui/stdio-agent/src/stdio-chat.ts`): `createStdioChat`, its `StdioRuntime` test seam, and its unit tests (`packages/ui/stdio-agent/tests/stdio-chat.spec.ts`, `readline.spec.ts`) moved with it, so EOF handling, rendering, disposal, and piped-vs-TTY behavior stay unit-covered under the per-file coverage gate without hijacking process globals. The module keeps the named `name`/`inject`/`Config`/`apply` export shape — the contract the app's `ctx.plugin(uiStdio, …)` mount consumes — and the keyless Loader-path smokes in `examples/echo-agent` and `examples/coding-agent` keep proving the composed tree boots through the real Loader (the app's export SHAPE is pinned by the stdio-agent unit suite's explicit `unwrapExports` assertion, since a bundle without `inject` would boot past a stray default rather than crash). The `packages/support/ui-stdio` package is gone: manifest, tsconfig references, module-graph rows, and README rows deleted; the doc comments that named the package (the example e2e module docs, `packages/README.md`, the support and todo READMEs, [the ui group README](../../../../packages/ui/README.md)) describe the in-package module. diff --git a/examples/coding-agent/tests/keyless-smoke.e2e.ts b/examples/coding-agent/tests/keyless-smoke.e2e.ts index e079aa014b..dba8b140c7 100644 --- a/examples/coding-agent/tests/keyless-smoke.e2e.ts +++ b/examples/coding-agent/tests/keyless-smoke.e2e.ts @@ -18,9 +18,10 @@ import { afterEach, describe, expect, it } from 'vitest' * `apply()` only requires a key to be PRESENT (it does not validate it and only * uses it when a stream actually starts), so a dummy key lets the tree boot * while the absence of any prompt guarantees no network call. The value is the - * real-Loader-path guard for the app + bundle + UI plugin export shapes (a broken - * `export default` that drops `inject`/`Config` would crash here — see postmortem - * 0001), complementing coding-agent's with-key e2e suites which prove the real + * real-Loader-path guard that the composed tree boots (see postmortem 0001; + * the app carries no `inject`, so its export SHAPE is pinned by the stdio-agent + * unit suite's unwrap assertion, not by a crash here), + * complementing coding-agent's with-key e2e suites which prove the real * product. */ diff --git a/examples/echo-agent/tests/echo.e2e.ts b/examples/echo-agent/tests/echo.e2e.ts index ec02e58f5b..01bb34bff0 100644 --- a/examples/echo-agent/tests/echo.e2e.ts +++ b/examples/echo-agent/tests/echo.e2e.ts @@ -14,10 +14,11 @@ import { afterEach, describe, expect, it } from 'vitest' * This is the guard the per-file unit suite structurally cannot be: it drives * the `@deepseek-ai/dsh-stdio-agent` app plugin, the `@deepseek-ai/dsh-agent-core` * bundle it loads, the app's in-package readline UI module, AND the - * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path, so - * a broken plugin export shape (a stray `export default` that `unwrapExports` - * would collapse, dropping `inject`/`Config`) fails here even though hand-mounted - * unit tests stay green (see docs/postmortem/0001). It needs no API key — the + * example-local `mock-llm.ts` / `echo-tool.ts` through their REAL load path + * (see docs/postmortem/0001). The app itself carries no `inject`, so a stray + * `export default` would boot rather than crash here — the export SHAPE is + * pinned by the explicit unwrap assertion in the stdio-agent unit suite; this + * smoke proves the composed tree actually runs. It needs no API key — the * `mock-echo` adapter never touches the network — so it runs in the default e2e * gate. * diff --git a/packages/README.md b/packages/README.md index da17075d36..f016f1e149 100644 --- a/packages/README.md +++ b/packages/README.md @@ -19,7 +19,7 @@ Packages are grouped by modular role at `packages/<group>/<pkg>/`. The group dir | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface | | [`ui/`](ui/README.md) | Editor/client integration surfaces (the ACP bridge) | Product — stable surface | -| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, stdio UI, replay adapter) | Support — lower compatibility expectations | +| [`support/`](support/README.md) | Dev/test/example infrastructure (invariants, replay adapter, subagent mock) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (the `Branded<B>` primitive) | Support — small, stable, harness-dep-free | The split is the point: a package's group says whether it is part of the product API or support/test/example infrastructure, so release and removal decisions do not have to treat every package as an equal public contract. New packages join an existing group; adding a new top-level group is a deliberate act (extend the group READMEs and the hierarchy docs). From b049aa7a8af4819c8528da2bd9eccc3e9d549299 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:02:36 +0800 Subject: [PATCH 12/32] docs(rfc): drop a change-unit reference from the hook-protocol-lib RFC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Execution bullet cited "the bash-seam PR" — a change unit a reader of the current tree cannot see. State the standing fact instead, matching the runner module doc's own phrasing. --- docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md index 419931345e..12a47ddcf1 100644 --- a/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md +++ b/docs/rfc/implemented/feature/2026-06-30-hook-protocol-lib.md @@ -16,7 +16,7 @@ A new `packages/hooks/` group with `hook-protocol` as a pure library. It owns fo **Shared (here):** - **Matcher** — `matchesMatcher(pattern, query, mode)`. The ONE axis the dialects differ on is collapsed to the `mode` parameter: `claude` treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` is always an unanchored regex. Match-all on absent/`''`/`'*'`; an invalid regex matches nothing (never throws into the loop). -- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added in the bash-seam PR for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). +- **Execution** — `runHook(bash, hook, options)`. Runs a command hook through the `ctx.bash` seam rather than a bespoke `spawn`: the executor already provides the scrubbed-but-overridable env, process-group kills, and timeout the protocol needs, and `dsh-bash`'s `stdin`/`env` fields (added for exactly this) are the trusted-plugin surface an in-process bridge is allowed to use. It serializes the bridge-built payload to stdin (trailing newline iff CC), honors the hook's `timeoutSec` (else `DEFAULT_HOOK_TIMEOUT_MS`, the 10-minute reference default both dialects share), and never throws (an executor rejection becomes a non-blocking-error `HookOutput`). - **Decode** — `parseHookOutput(exit, stdout, stderr)`, the exit-code + structured-stdout codec, producing a dialect-neutral `HookOutput`. Exit `0` → lenient JSON parse of stdout; exit `2` → blocking error with `stderr` as the reason (surfaced as `decision: 'block'` so no caller needs a separate exit-code branch); other → non-blocking error. Parses the CC structured-stdout fields that have a consumer on some path (`continue`/`stopReason`/`decision`/`hookSpecificOutput.{permissionDecision,additionalContext,updatedInput}`/`systemMessage`); the bridge honors only the subset meaningful for its dialect. Fields with no consumer on any path are not parsed at all (CC's `suppressOutput` — hook stdout never enters a transcript here, so there is nothing to suppress; see [the tighten-hook-protocol-contract RFC](../simplification/2026-07-04-tighten-hook-protocol-contract.md)). - **Merge** — `mergeHookOutputs(outputs)`, folding multiple matched hooks into one most-restrictive `MergedHookOutcome`: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined `\n\n`, context/system-messages accumulated in order. - **`hook/*` session events** — `hook/invoked` / `hook/result`, declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT `SurfaceEventType`s), with `appendHookInvoked`/`appendHookResult` helpers so the invoked/result pairing and turn-enclosure stay consistent across bridges. `appendHookResult` also owns the durable record's semantics — the decision string (the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`) and the 500-character `stderrSummary` truncation derive from the `HookOutput` here, not per-bridge. From 6df70c207943753f2c7c67297e9f17801fb56204 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:08:03 +0800 Subject: [PATCH 13/32] docs: same smoke-vs-shape claim fixed at its two remaining sites The stdio app's module doc and its unit suite's doc carried the claim the review already corrected elsewhere: an inject-less app boots past a collapsed export shape, so the smoke proves the tree runs while the unwrapExports assertion in the unit suite pins the shape. --- packages/ui/stdio-agent/src/index.ts | 6 ++++-- packages/ui/stdio-agent/tests/stdio-agent.spec.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ui/stdio-agent/src/index.ts b/packages/ui/stdio-agent/src/index.ts index 6a854dfe29..c576d6aaba 100644 --- a/packages/ui/stdio-agent/src/index.ts +++ b/packages/ui/stdio-agent/src/index.ts @@ -30,8 +30,10 @@ * Plugin export shape: named `name`/`Config`/`apply`, NO default export — the * cordis Loader's `unwrapExports` does `exports.default ?? exports`, so a stray * default would collapse the module to the bare `apply` and drop the `Config` - * namespace (see docs/postmortem/0001). The keyless Loader-path smoke in the - * echo example guards this end-to-end. + * namespace (see docs/postmortem/0001). This app carries no `inject`, so a + * collapsed shape would BOOT rather than crash a smoke — the shape is pinned by + * the explicit `unwrapExports` assertion in this package's unit suite, and the + * keyless echo smoke proves the composed tree runs through the real Loader. * * @module @deepseek-ai/dsh-stdio-agent */ diff --git a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts index f72de0a1da..5c22fdb984 100644 --- a/packages/ui/stdio-agent/tests/stdio-agent.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-agent.spec.ts @@ -12,9 +12,11 @@ import * as stdioAgent from '../src/index.ts' * agent; `persistenceRoot`/`welcome`/`resumeSessionId` route to their backends. * * `hmr` is NOT part of this plugin (it is a leaf entry — a Loader-only dev - * plugin the in-process tier cannot import); the REAL Loader-path guard (export - * shape, `unwrapExports`, the whole subprocess tree incl. `hmr`) is the keyless - * echo smoke in `examples/echo-agent`. Here we assert the composition + config + * plugin the in-process tier cannot import); the keyless echo smoke in + * `examples/echo-agent` proves the whole subprocess tree (incl. `hmr`) boots + * through the real Loader, while the export SHAPE is pinned by this suite's + * explicit `unwrapExports` assertion (an inject-less app would boot past a + * stray default rather than crash). Here we assert the composition + config * forwarding the unit tier can reach. */ async function mount(config: stdioAgent.Config): Promise<Context> { From 4a0941fb4bd7fd5813fec4bd8ac061fb01208ad0 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 16:43:06 +0800 Subject: [PATCH 14/32] feat(ui): share the app bins' boot glue in @deepseek-ai/dsh-app-boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four near-twin helpers the two published bins carried — loadEnv, installFailLoud, assertEntriesLoaded, boot — live once in packages/ui/app-boot, parameterized by the bin's diagnostic prefix and injectable at their side-effect seams (warn sink, process slice), so every branch sits under the per-file 100% coverage gate: the unit suite drives boot() in-process against the real Loader (relative-specifier configs) through both the settled-tree path and the fiber-less-entry rejection, and exercises the ENOENT/unloadable .env split, the Error/non-Error/stackless fail-loud arms, and the disabled-entry exclusion. resolveConfigPath (snapshot-aware) becomes the single path resolver for both bins. Each bin.ts is now a thin self-executing composition plus its app-specific lifecycle (acp: replay env-skip + stdin-EOF dispose; stdio: nothing extra), exports nothing, and stays coverage-excluded; the built-bin smokes still prove both artifacts under plain node in the node_modules-shaped temp dir (now symlinking ui/app-boot), including the missing-config non-zero exit. Implements docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md (moved from proposed/ and amended); the extract-example-app-packages RFC's bin-ownership facts are amended in the same change. --- AGENTS.md | 2 + docs/module-graph.md | 7 +- docs/rfc/README.md | 2 +- ...2026-06-20-extract-example-app-packages.md | 2 +- .../2026-07-04-share-app-bin-boot-glue.md | 23 +++ .../2026-07-04-share-app-bin-boot-glue.md | 27 --- packages/README.md | 6 +- packages/ui/README.md | 1 + packages/ui/acp-agent/package.json | 2 + packages/ui/acp-agent/src/bin.ts | 178 +++--------------- packages/ui/acp-agent/tests/built-bin.e2e.ts | 2 +- packages/ui/acp-agent/tsconfig.json | 3 + packages/ui/app-boot/README.md | 15 ++ packages/ui/app-boot/package.json | 34 ++++ packages/ui/app-boot/src/index.ts | 152 +++++++++++++++ packages/ui/app-boot/tests/app-boot.spec.ts | 178 ++++++++++++++++++ packages/ui/app-boot/tsconfig.json | 21 +++ packages/ui/stdio-agent/package.json | 2 + packages/ui/stdio-agent/src/bin.ts | 141 ++------------ .../ui/stdio-agent/tests/built-bin.e2e.ts | 2 +- packages/ui/stdio-agent/tsconfig.json | 3 + pnpm-lock.yaml | 18 ++ tsconfig.build.json | 1 + tsconfig.json | 1 + 24 files changed, 510 insertions(+), 313 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md create mode 100644 packages/ui/app-boot/README.md create mode 100644 packages/ui/app-boot/package.json create mode 100644 packages/ui/app-boot/src/index.ts create mode 100644 packages/ui/app-boot/tests/app-boot.spec.ts create mode 100644 packages/ui/app-boot/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 004fef4611..184c75d798 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -112,6 +112,8 @@ packages/ Harness packages, grouped by role at packages/<group>/<pkg>/. front door) acp-agent/ ACP server APP: agent-core spine + JSONL persistence + the acp bridge, NO stdout logger + a bin (the demo:acp front door) + app-boot/ shared boot glue for the two app bins: .env loading, + fail-loud Loader guards, config resolution, boot sequence support/ dev/test/example infrastructure (lower compat expectations) invariants/ dev-mode event-contract invariants + session-log freeze llm-replay/ record/replay adapter: short-circuits llm/stream from a diff --git a/docs/module-graph.md b/docs/module-graph.md index c2c2c97bf7..64b89c294f 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -113,9 +113,11 @@ graph TD tool-subagent --> tools acp-agent --> acp acp-agent --> agent-core + acp-agent --> app-boot acp-agent --> session-persistence-jsonl stdio-agent --> agent stdio-agent --> agent-core + stdio-agent --> app-boot stdio-agent --> session stdio-agent --> session-persistence-jsonl subagent-fork --> agent @@ -128,6 +130,7 @@ graph TD | Package | Depends on | | --- | --- | +| `app-boot` | — | | `brand` | — | | `bash` | `brand` | | `llm` | `brand` | @@ -168,7 +171,7 @@ graph TD | `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` | +| `acp-agent` | `acp`, `agent-core`, `app-boot`, `session-persistence-jsonl` | +| `stdio-agent` | `agent`, `agent-core`, `app-boot`, `session`, `session-persistence-jsonl` | | `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 9a4aff345e..c207d50e76 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -59,7 +59,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | | [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | -| [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | | [Tighten the hook-protocol contract — dialect, discarded fields, double defaults, and lib-owned `hook/result` semantics](proposed/simplification/2026-07-04-tighten-hook-protocol-contract.md) | 2026-07-04 | | [Trim unreachable ACP bridge surface — the branding knobs and the kind-sniffing fallback](proposed/simplification/2026-07-04-trim-acp-bridge-unreachable-surface.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Fold the stdio UI helper into the stdio app](implemented/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | +| [Share the app bins' boot glue instead of maintaining twin copies](implemented/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md index 02517d7322..380586699b 100644 --- a/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md +++ b/docs/rfc/implemented/architecture/2026-06-20-extract-example-app-packages.md @@ -14,7 +14,7 @@ Each example is now **mostly an invocation of an app package**, splitting the wi - **`@deepseek-ai/dsh-agent-core`** ([packages/core/agent-core](../../../../packages/core/agent-core)) — a Cordis bundle plugin for the providerless, executor-less, UI-less spine: `timer` + `llm` + sessions + system-prompt + tools + agents + invariants + `tool-bash` + `agent-loop`, mounted as child plugins inside its `apply(ctx)` via `ctx.plugin(...)`. This is the old `base-core.yml` **minus** `bash-local`, **plus** `timer` and the loop, as code instead of a YAML include. The bundle **forwards** `agent-loop`'s `agents` list as its own config (`export const Config = AgentLoop.Config`, default `[]`, the existing `AgentLoop.Config` shape in [packages/core/agent-loop/src/index.ts](../../../../packages/core/agent-loop/src/index.ts)) — so each app supplies its own pre-created agents. This is precisely the reason the old `base-core.yml` gave for keeping `agent-loop` *out* of the shared core ("the examples disagree — stdio needs a pre-created `main`, acp needs none"); forwarding the config dissolves that objection — the loop is shared, the agents list is per-app. The bundle children register into the root service store, so a leaf-mounted sibling (the adapter, the executor) sees them exactly as a nested `plugin-include` subtree's services were seen before. Depending on the CONCRETE `dsh-agent-loop` (not just the `dsh-agent` interface) is deliberate and is the sanctioned exception to the "extension plugins depend on interfaces, never on the concrete loop" rule (packages/README.md, docs/architecture.md § Layering): the rule constrains plugins that EXTEND the system, whereas this bundle's whole job is to COMPOSE the concrete spine. Swapping the loop means publishing a different bundle, not rewiring every extension. - **`@deepseek-ai/dsh-stdio-agent`** ([packages/ui/stdio-agent](../../../../packages/ui/stdio-agent)) and **`@deepseek-ai/dsh-acp-agent`** ([packages/ui/acp-agent](../../../../packages/ui/acp-agent)) — app packages, each consuming `dsh-agent-core` and **baking in its coupled front-door cluster**: stdio = `ui-stdio` + console logger + a pre-created `main`; acp = the `acp` bridge + JSONL persistence + **no stdout logger** + no pre-created agents. The leaf no longer carries the cluster, so it has no logger entry to copy wrong by default — the common stdout-purity mistake loses its foothold. (A leaf can still *add* a sibling logger entry — a package cannot forbid what a leaf author writes — so the rule "never add a stdout logger to an ACP leaf" stays documented at the leaf; what changed is that the default leaf has nothing to get wrong.) They land under the existing `ui` group alongside `acp`, so no new package group (and no `tsconfig`/`packages/README` group plumbing) was needed. -- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, snapshot-mode selection, and stdin-dispose lifecycle moved into that bin, owned by the app. The `bin.ts` files are coverage-excluded (a self-executing CLI entry, like the old `start.ts`) and driven by the keyless Loader-path tests. +- **`start.ts` is gone.** Each app package exposes a `bin` (`dsh-stdio-agent` / `dsh-acp-agent`); the `demo:*` scripts invoke it (e.g. `dsh-stdio-agent ./cordis.yml`). The Loader-boot tail, `.env` loading, and fail-loud guards live in the shared [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) package (unit-tested under the per-file coverage gate — see [share the app bins' boot glue](../simplification/2026-07-04-share-app-bin-boot-glue.md)); each bin is a thin self-executing composition over those helpers plus its app-specific lifecycle (the ACP bin: snapshot-mode selection and stdin-dispose). The `bin.ts` files themselves stay coverage-excluded (self-executing CLI entries, like the old `start.ts`) and are driven by the keyless Loader-path tests. - **Each leaf `cordis.yml` collapses** to backends + config: the LLM adapter (`llm-deepseek` with apiKey/models, or `llm-replay`), the bash executor (`bash-local`), `hmr` for the stdio demos (see the amendment below), and one app entry carrying the app's config (model, system prompt, persistence root — surfaced as the app package's own `Config`, which routes each value to wherever the app wires it: stdio onto its pre-created agent, acp onto the bridge plugin). - **echo-agent folds onto `dsh-stdio-agent`**, swapping the LLM backend to the local `mock-llm` and adding the local `echo-tool` (plus `bash-local`, which the spine's `tool-bash` injects) at the leaf — the clean demonstration of "swap the backend, keep the app". `mock-llm.ts` / `echo-tool.ts` stay as example-local teaching plugins. - **`base.yml`, `base-core.yml`, and `acp-agent/acp-tail.yml` are retired** — the spine they shared now lives in `dsh-agent-core`. diff --git a/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md new file mode 100644 index 0000000000..faa7b3f106 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-share-app-bin-boot-glue.md @@ -0,0 +1,23 @@ +# RFC: Share the app bins' boot glue instead of maintaining twin copies + +Status: implemented + +## Problem + +`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carried four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differed essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note. The copies had drifted (`boot(configPath)` resolved the path internally in one bin but required a pre-resolved absolute path in the other, with forked JSDoc prose), and all of it sat outside the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin runs it — which also made the helpers' `export` keywords decorative: no spec could import them, so the only exercisers were subprocess smokes. + +## Decision + +The helpers live once, in [`@deepseek-ai/dsh-app-boot`](../../../../packages/ui/app-boot) (`packages/ui/app-boot`, in the `ui` group because the bins are published artifacts whose runtime dependency must itself be published, not `support/`): `resolveConfigPath` (snapshot-aware, the single path resolver for both bins), `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, and `boot`, each parameterized by the bin's diagnostic prefix and injectable at its side-effect seams (the warn sink, the process slice) so the unit suite covers every branch — including `boot()` driven in-process against the real Loader with relative-specifier configs, both the settled-tree happy path and the fiber-less-entry rejection. The package carries the per-file 100% coverage gate; the loader-failure lore has one home. + +Each `bin.ts` is a thin self-executing composition over the shared helpers plus its app-specific lifecycle (the ACP bin: replay-mode env skipping and the stdin-EOF dispose; the stdio bin: nothing extra). The bins stay coverage-excluded and export nothing; the published-artifact guards are unchanged — the built-bin smokes still run each bin under plain node in a node_modules-shaped temp dir (now symlinking `ui/app-boot` too) and still assert the missing-config non-zero exit, per the "real entry path means the published artifact" defensive pattern. The [extract-example-app-packages RFC](../architecture/2026-06-20-extract-example-app-packages.md)'s bin-ownership facts are amended accordingly. + +## Why not keep the duplication? + +The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) comparable to the deduplicated line count. But app-vs-app sharing was never weighed by the RFC that created the bins — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift was observed fact; and the coverage-gap argument is independent of the dedup argument: this was the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The recorded fallback (extracting only the pure logic into per-app modules) would have ended the exemption but kept two homes for the lore. + +## Consequences + +- A boot-glue change (a new guard, a resolution fix) lands once and both published bins inherit it; the bins cannot drift apart again. +- `dsh-app-boot` stays dependency-light (cordis + the loader/include pair) — it is boot machinery, not app surface. +- The bins' own files are near-trivial compositions; everything with branches lives under the coverage gate. diff --git a/docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md b/docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md deleted file mode 100644 index adc3ec6da3..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-share-app-bin-boot-glue.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Share the app bins' boot glue instead of maintaining twin copies - -Status: proposed - -## Problem - -`packages/ui/stdio-agent/src/bin.ts` and `packages/ui/acp-agent/src/bin.ts` carry four near-twin helpers — `loadEnv`, `installFailLoud`, `assertEntriesLoaded`, `boot` — whose bodies differ essentially in the diagnostic prefix, plus two copies of the hardest-won boot lore in the repo: the `Promise.allSettled` swallow inside `loader.await()`, the silent-exit-0 import-failure guard, and the `--expose-internals` resolution note (the failure classes behind AGENTS.md's "real entry path means the published artifact" pattern). Drift has already begun: `boot(configPath)` resolves the path internally in one bin but requires a pre-resolved absolute path in the other, and the twin JSDoc prose has forked. - -The duplication is aggravated by a coverage hole: all of this logic sits OUTSIDE the per-file 100% gate — `vitest.config.ts` excludes `packages/*/*/src/bin.ts` because importing a self-executing bin (top-level `await main()`) runs it — which also makes the `export` keywords on these helpers decorative: no spec can import them, so the only exercisers are the subprocess smokes, and the two `built-bin.e2e.ts` suites duplicate their temp-node_modules scaffolding as well. The genuinely per-app pieces are small and real: the ACP bin owns snapshot-mode config selection (`resolveConfigPath`), replay-mode env skipping, the stdin-EOF dispose lifecycle, and stdout purity; the stdio bin owns nothing extra. - -## Proposal - -Extract the four helpers, parameterized by the bin's diagnostic prefix, into an importable non-bin module shared by both apps — a small published package in the `ui` group (the bins are published artifacts, so their runtime dependency must be published too, not `support/`). Each `bin.ts` becomes a thin self-executing `main()` plus its app-specific glue. The shared module gains unit tests and falls under the coverage gate; the loader-failure lore gets one home; the subprocess smokes remain the artifact-level guard — the published-bin smoke is NOT replaced by unit tests, per the "real entry path" defensive pattern. The implementing PR amends the [extract example app packages RFC](../../implemented/architecture/2026-06-20-extract-example-app-packages.md)'s facts ("boot glue moved into that bin, owned by the app" is the sentence that changes). - -## Why not keep the duplication? - -The bins were framed as independently-owned published artifacts, and a new package carries fixed overhead (manifest, README, tsconfig reference, publint surface) that rivals the deduplicated line count. But app-vs-app sharing was never weighed by that RFC — it consolidated three example `start.ts` copies INTO the bins and stopped there; the drift is now observed fact rather than speculation; and the coverage-gap argument is independent of the dedup argument: this is the only nontrivial runtime logic in the repo exempt from the per-file 100% gate. The alternative of a copy-by-convention shared source file is the current state with extra steps. - -## Acceptance criteria - -- The four helpers exist once, unit-tested, under the coverage gate; both bins are thin mains plus app-specific glue. -- Both built-bin smokes still pass under plain node in the node_modules-shaped temp dir, including the missing-config non-zero exit. -- The app-packages RFC's facts are amended in the same change. - -## Risks - -Churn in two published bins and one new package boundary; the shared module must stay dependency-light (cordis plus the loader). If the implementing PR finds the package overhead genuinely exceeds the dedup — the honest failure mode of this proposal — the fallback that still pays is extracting only the coverage-exempt pure logic (`assertEntriesLoaded`, `resolveConfigPath`) into an importable module within each app package, ending the coverage exemption without a new package. diff --git a/packages/README.md b/packages/README.md index f016f1e149..2f87d57d0d 100644 --- a/packages/README.md +++ b/packages/README.md @@ -63,8 +63,9 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool) dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log) 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-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + readline UI + bin) -dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) +dsh-app-boot ← (cordis + loader only) (shared bin boot glue: .env, fail-loud guards, boot sequence) +dsh-stdio-agent ← dsh-agent-core, dsh-session-persistence-jsonl, dsh-agent, dsh-session, dsh-app-boot (stdio chat APP + readline UI + bin) +dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl, dsh-app-boot (ACP server APP + bin) ``` The rule: **extension** plugins depend on interfaces, never on the concrete loop. `dsh-agent-loop` is swappable — UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-core`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it — swapping the loop means shipping a different bundle, not rewiring every extension. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../docs/rfc/implemented/architecture/2026-06-13-capability-seams.md)). @@ -104,6 +105,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `acp/` | `ui` | Agent Client Protocol bridge: serves the agent to an ACP editor over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `stdio-agent/` | `ui` | Terminal stdio chat APP: agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | `ui` | ACP server APP: agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | +| `app-boot/` | `ui` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the boot sequence | (library for the bins) | | `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-inprocess/` | `subagent` | Shared in-process subagent run driver used by spawn/fork; pure library, registers nothing | (none) | diff --git a/packages/ui/README.md b/packages/ui/README.md index 1a3c45727d..8b0293cf9e 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -7,6 +7,7 @@ Integrations that expose the agent to an external editor or client. These are ** | `acp/` | Agent Client Protocol bridge: serves the agent to an ACP editor (Zed) over JSON-RPC stdio | (drives `ctx.agents`/`ctx.sessions`) | | `stdio-agent/` | Terminal stdio chat APP: the agent-core spine + console logger + readline UI + a pre-created `main` agent, with a `bin` | (composition + `bin`) | | `acp-agent/` | ACP server APP: the agent-core spine + JSONL persistence + the `acp` bridge (no stdout logger), with a `bin` | (composition + `bin`) | +| `app-boot/` | Shared boot glue for the two app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | A UI integration is a client-driver plugin, not a loop change and not a capability seam: it consumes the existing `agent/*` event taxonomy and the `dsh-agent` factory. The readline UI is the unstructured analogue of the `acp` bridge and lives INSIDE the stdio app (the `stdio-chat` module of [`stdio-agent/`](stdio-agent/README.md)): it is scaffolding for that one front door, not an independently swappable integration, so it carries no package boundary of its own. diff --git a/packages/ui/acp-agent/package.json b/packages/ui/acp-agent/package.json index 72eb95b2f7..71f8fe0171 100644 --- a/packages/ui/acp-agent/package.json +++ b/packages/ui/acp-agent/package.json @@ -32,6 +32,7 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@deepseek-ai/dsh-acp": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -41,6 +42,7 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/packages/ui/acp-agent/src/bin.ts b/packages/ui/acp-agent/src/bin.ts index 24e31f3251..1cbdff64d1 100644 --- a/packages/ui/acp-agent/src/bin.ts +++ b/packages/ui/acp-agent/src/bin.ts @@ -2,167 +2,45 @@ /** * The `dsh-acp-agent` bin: boot the ACP server from a leaf `cordis.yml` that * loads the {@link @deepseek-ai/dsh-acp-agent} app plugin (plus an LLM adapter - * and a bash executor), speaking ACP JSON-RPC on stdio. + * and a bash executor), speaking ACP JSON-RPC on stdio. The shared boot glue — + * `.env` loading, the fail-loud Loader guards, snapshot-aware config + * resolution, the settle-the-tree boot sequence — lives in + * {@link @deepseek-ai/dsh-app-boot}; this bin owns only the ACP-specific + * lifecycle: * - * Owns the ACP-specific boot glue the example's `start.ts` once held: - * - `.env` loading (`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`) — SKIPPED in - * snapshot REPLAY so a stray key can never trigger a live model call. - * - snapshot-mode config selection: `DSH_SNAPSHOT=replay` swaps the given - * `cordis.yml` for its sibling `cordis.snapshot.yml` (the keyless replay - * tree: `llm-replay` in place of `llm-deepseek`). - * - the stdin-dispose lifecycle: in a snapshot run the harness closes stdin - * when done, so dispose the context (flushing persistence) and exit cleanly. + * - `.env` loading is SKIPPED in snapshot REPLAY so a stray key can never + * trigger a live model call. + * - `DSH_SNAPSHOT=replay` swaps the given `cordis.yml` for its sibling + * `cordis.snapshot.yml` (the keyless replay tree: `llm-replay` in place of + * `llm-deepseek`). + * - In a snapshot run the harness closes stdin when done, so dispose the + * context (flushing persistence) and exit cleanly. In a normal editor + * session stdin stays open for the connection's lifetime (the editor kills + * the process), so the EOF handler never fires. * * IMPORTANT: stdout is the ACP JSON-RPC channel. This bin writes diagnostics to - * STDERR only; the app plugin loads no stdout logger. A stray stdout write - * corrupts the protocol frames. + * STDERR only (the app plugin loads no stdout logger, and the shared guards + * write to stderr); a stray stdout write corrupts the protocol frames. * * Usage: `dsh-acp-agent [path-to-cordis.yml]` (default `./cordis.yml`). * * @module @deepseek-ai/dsh-acp-agent/bin */ -import { pathToFileURL } from 'node:url' -import { basename, dirname, resolve } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -/** - * Resolve the config to boot, honoring snapshot REPLAY. Given the requested - * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in - * the SAME directory (the keyless replay tree). Other modes use the path as-is. - * Returns an absolute path resolved from the cwd. - */ -export function resolveConfigPath(configPath: string, snapshotMode: string | undefined): string { - const absolute = resolve(process.cwd(), configPath) - if (snapshotMode !== 'replay') return absolute - const dir = dirname(absolute) - const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') - return resolve(dir, replayName) -} +const NAME = 'dsh-acp-agent' -/** - * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the - * cwd (Node native). Diagnostics go to STDERR (stdout is the protocol). In - * REPLAY mode the caller skips this entirely — replay must never reach the - * network, so a present `.env` must not enable a live call. - */ -function loadEnv(): void { - try { - process.loadEnvFile(resolve(process.cwd(), '.env')) - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`dsh-acp-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. - } -} - -/** - * Make a load failure fail loud with a clear message on stderr. Covers the - * failure path the entry-tree check below cannot: when the include's - * `[Service.init]` throws (e.g. a config FILE missing in a real directory), the - * cordis Loader surfaces it as an unhandled promise rejection AFTER `boot()` - * resolves — `loader.await()` does NOT rethrow it (`EntryTree.await()` uses - * `Promise.allSettled`, which swallows rejections). Node's default handler - * already exits non-zero on an unhandled rejection, so this does not change the - * exit code; it replaces the noisy stack dump with a single labelled line (on - * STDERR — stdout is the ACP JSON-RPC channel) and guarantees `process.exit(1)`. - * Install before `boot()`. - */ -export function installFailLoud(): void { - process.on('unhandledRejection', (err: unknown) => { - process.stderr.write(`dsh-acp-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - process.exit(1) +/* v8 ignore start -- thin self-executing composition over the unit-tested + dsh-app-boot helpers; exercised end-to-end by the snapshot suite and the + built-bin smoke */ +installFailLoud(NAME) +const snapshotMode = process.env['DSH_SNAPSHOT'] +if (snapshotMode !== 'replay') loadEnv(NAME) +const ctx = await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', snapshotMode)) +if (snapshotMode !== undefined) { + process.stdin.on('end', () => { + void ctx.fiber.dispose().then(() => { process.exit(0) }) }) } - -/** - * After the tree settles, assert every loader entry actually started. This is - * the load-bearing guard against the SILENT-exit-0 bug: a plugin module that - * fails to IMPORT (e.g. a config path in a non-existent directory) is caught and - * only LOGGED by the cordis Loader (`entry._init`), leaving the entry with no - * `fiber` and producing no rejection — so the process would otherwise exit 0. A - * started entry has a `fiber`; throw on any entry still missing one so `boot()` - * rejects. - * - * A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()` - * deliberately skips `init()` for it, so it settles without a fiber by design — - * a valid "plugin turned off" config, not a failed import. Exclude it. - */ -function assertEntriesLoaded(ctx: Context): void { - const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) - if (failed.length > 0) { - const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`dsh-acp-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`) - } -} - -/** - * Boot the Loader against `absoluteConfigPath`. The include is handed the - * config's ABSOLUTE `file://` URL as its `path`, so resolution never depends on - * `ctx.baseUrl` (an absolute URL ignores the base) and can never fall back to - * the cwd. `baseUrl` is still pinned to the config's directory so the config's - * OWN relative plugin/include paths resolve against it. Returns the root context - * once the whole tree has settled. - * - * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once - * the include ENTRY is registered, but the include then loads its child plugins - * asynchronously. Without awaiting the tree, `boot()` would resolve while the ACP - * bridge is still mounting — the process would have no stdin handle attached yet - * and could exit 0 silently. Awaiting keeps the process alive until the bridge - * is up. - * - * `loader.await()` does NOT rethrow load errors (`EntryTree.await()` uses - * `Promise.allSettled`), so failures are surfaced two ways: a plugin that fails - * to IMPORT leaves an entry with no fiber, caught here by - * {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init THROWS - * surfaces as an unhandled rejection caught by {@link installFailLoud} (installed - * by `main()` before this runs). Together any load failure exits non-zero. - * - * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are - * resolved by the cordis Loader's internal module loader, which is only active - * under `node --expose-internals`. The `demo:acp` script runs under tsx (whose - * tsconfig `paths` map resolves the workspace plugins instead), but a consumer - * running the built bin under plain node must pass `--expose-internals` so the - * Loader resolves the config's plugins from the config directory rather than - * relative to its own module. - */ -export async function boot(absoluteConfigPath: string): Promise<Context> { - const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' - await ctx.plugin(Loader) - await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { path: pathToFileURL(absoluteConfigPath).href }, - }) - await ctx.loader.await() - assertEntriesLoaded(ctx) - return ctx -} - -/** - * Entry point. Installs the fail-loud guard, selects the config (snapshot-aware), - * loads `.env` outside replay, boots, and — in a snapshot run — disposes the - * context on stdin EOF so the session log is fully flushed before exit and the - * harness's `waitForExit` resolves. In a normal editor session stdin stays open - * for the connection's lifetime (the editor kills the process), so the EOF - * handler never fires. - */ -export async function main(argv: string[] = process.argv.slice(2)): Promise<void> { - installFailLoud() - const snapshotMode = process.env.DSH_SNAPSHOT - const configPath = resolveConfigPath(argv[0] ?? './cordis.yml', snapshotMode) - if (snapshotMode !== 'replay') loadEnv() - const ctx = await boot(configPath) - if (snapshotMode !== undefined) { - process.stdin.on('end', () => { - void ctx.fiber.dispose().then(() => { process.exit(0) }) - }) - } -} - -/* v8 ignore start -- top-level CLI invocation; the testable core is - resolveConfigPath()/boot()/main(), driven by the keyless snapshot + Loader-path tests */ -await main() /* v8 ignore stop */ diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index 51c5d53c0b..d900c14152 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -40,7 +40,7 @@ const acpBin = join(repoRoot, 'packages/ui/acp-agent/lib/bin.js') const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'llm/llm-deepseek', 'bash/bash', - 'bash/bash-local', 'bash/tool-bash', 'support/invariants', + 'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/acp', 'ui/acp-agent', ] diff --git a/packages/ui/acp-agent/tsconfig.json b/packages/ui/acp-agent/tsconfig.json index ffea8ec6f6..6cc211087c 100644 --- a/packages/ui/acp-agent/tsconfig.json +++ b/packages/ui/acp-agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../app-boot" + }, { "path": "../acp" }, diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md new file mode 100644 index 0000000000..7a03b2b72b --- /dev/null +++ b/packages/ui/app-boot/README.md @@ -0,0 +1,15 @@ +# `@deepseek-ai/dsh-app-boot` + +Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md), [`dsh-acp-agent`](../acp-agent/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between two published artifacts. + +| Export | Role | +|---|---| +| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | +| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | +| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | +| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | +| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | + +Two failure classes the guards close — both would otherwise exit 0 with a usable config typo reported only as a log line: `loader.await()` swallows init rejections (`Promise.allSettled`), surfaced instead by `installFailLoud`; a failed plugin IMPORT is only logged by the Loader, leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. + +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/package.json b/packages/ui/app-boot/package.json new file mode 100644 index 0000000000..67b8b00dbc --- /dev/null +++ b/packages/ui/app-boot/package.json @@ -0,0 +1,34 @@ +{ + "name": "@deepseek-ai/dsh-app-boot", + "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", + "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": { + "@cordisjs/plugin-include": "^1.0.4", + "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@cordisjs/plugin-include": "workspace:^", + "@cordisjs/plugin-loader": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts new file mode 100644 index 0000000000..5322ca7eb6 --- /dev/null +++ b/packages/ui/app-boot/src/index.ts @@ -0,0 +1,152 @@ +/** + * Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load + * the gitignored `.env`, install the fail-loud Loader guards, resolve the + * config path (snapshot-aware), and drive the cordis Loader against a leaf + * `cordis.yml` until the whole tree has settled. Each bin stays a thin + * self-executing `main()` over these helpers, parameterized by its diagnostic + * prefix; the loader-failure lore lives here, once, under the per-file + * coverage gate. + * + * Two failure classes the guards close, both of which would otherwise exit 0 + * with a usable config typo reported only as a log line: + * + * - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses + * `Promise.allSettled`, which swallows rejections). A plugin whose + * `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()` + * resolves — {@link installFailLoud} turns that into one labelled stderr + * line and a guaranteed non-zero exit. + * - A plugin module that fails to IMPORT is caught and only LOGGED by the + * cordis Loader (`entry._init`), leaving the entry with no `fiber` and + * producing no rejection — {@link assertEntriesLoaded} makes `boot()` reject + * on any such entry instead of returning a half-empty context. + * + * @module @deepseek-ai/dsh-app-boot + */ + +import { pathToFileURL } from 'node:url' +import { basename, dirname, resolve } from 'node:path' +import { Context } from 'cordis' +import Loader from '@cordisjs/plugin-loader' + +/** + * Resolve the config to boot, honoring snapshot REPLAY. Given the requested + * path, replay mode swaps a `cordis.yml` basename for `cordis.snapshot.yml` in + * the SAME directory (the keyless replay tree). Other modes — including no + * snapshot mode at all — use the path as-is. Returns an absolute path resolved + * from `cwd`. + */ +export function resolveConfigPath( + configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(), +): string { + const absolute = resolve(cwd, configPath) + if (snapshotMode !== 'replay') return absolute + const dir = dirname(absolute) + const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml') + return resolve(dir, replayName) +} + +/** + * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in + * `dir` (Node native `process.loadEnvFile`). An absent file is fine — the + * environment may already carry the variables; the leaf `cordis.yml` reads + * them via the `!!js` tag. A present-but-unreadable `.env` is a real + * misconfiguration: surface it via `warn` (one line, default stderr) rather + * than silently running with the wrong environment. + */ +export function loadEnv( + binName: string, dir: string = process.cwd(), + warn: (line: string) => void = line => void process.stderr.write(line), +): void { + try { + process.loadEnvFile(resolve(dir, '.env')) + } catch (error) { + if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { + warn(`${binName}: failed to load .env: ${String(error)}\n`) + } + // ENOENT (no .env) is fine — rely on the ambient environment. + } +} + +/** + * The slice of `process` {@link installFailLoud} needs — injectable so tests + * exercise the handler without registering on (or exiting) the real process. + */ +export interface FailLoudProcess { + on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown + off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown + stderr: { write(chunk: string): unknown } + exit(code: number): void +} + +/** + * Make a load failure fail loud with a clear message on stderr. Covers the + * failure path {@link assertEntriesLoaded} cannot: an include whose + * `[Service.init]` throws (e.g. a config FILE that does not exist in a real + * directory) surfaces as an unhandled promise rejection AFTER `boot()` + * resolves. Node's default handler already exits non-zero on an unhandled + * rejection; this replaces the noisy stack dump with a single labelled line on + * STDERR (never stdout — for the ACP bin that channel carries JSON-RPC) and + * guarantees `exit(1)`. Install before `boot()`. Returns the uninstaller + * (tests use it; the bins run until exit and never do). + */ +export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void { + const handler = (err: unknown): void => { + proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) + proc.exit(1) + } + proc.on('unhandledRejection', handler) + return () => void proc.off('unhandledRejection', handler) +} + +/** + * After the tree settles, assert every loader entry actually started. A + * started entry has a `fiber`; an entry with `fiber === undefined` after the + * tree settled never loaded (its module failed to import), so throw and let + * `boot()` reject instead of returning a half-empty context. A `disabled` + * entry is the one legitimate fiber-less state: `Entry.refresh()` deliberately + * skips `init()` for it — a valid "plugin turned off" config, not a failed + * import — so it is excluded. + */ +export function assertEntriesLoaded(ctx: Context, binName: string): void { + const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) + if (failed.length > 0) { + const names = failed.map(entry => entry.options.name).join(', ') + throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`) + } +} + +/** + * Boot the Loader against `absoluteConfigPath` and return the root context + * once the whole tree has settled. The include is handed the config's ABSOLUTE + * `file://` URL as its `path`, so resolution never depends on `ctx.baseUrl` + * (an absolute URL ignores the base) and can never fall back to the cwd; + * `baseUrl` is still pinned to the config's directory so the config's OWN + * relative plugin/include paths resolve against it. + * + * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns + * once the include ENTRY is registered, but the include then loads its child + * plugins asynchronously — without awaiting the tree, `boot()` would resolve + * while the app's plugins are still mounting, and a CLI process with no + * attached handles yet exits 0 silently. Failures surface two ways: an entry + * whose module failed to import is caught here by {@link assertEntriesLoaded} + * (this `boot()` rejects); an init that THROWS surfaces as an unhandled + * rejection caught by {@link installFailLoud} (installed by the bin first). + * + * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) + * are resolved by the cordis Loader's internal module loader, which is only + * active under `node --expose-internals`; a consumer running a built bin must + * pass that flag (or install the plugins where node hoists them). Relative + * specifiers resolve against the config directory with no flag. + */ +export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> { + const ctx = new Context() + ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' + await ctx.plugin(Loader) + await ctx.loader.create({ + name: '@cordisjs/plugin-include', + config: { path: pathToFileURL(absoluteConfigPath).href }, + }) + await ctx.loader.await() + assertEntriesLoaded(ctx, binName) + return ctx +} diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts new file mode 100644 index 0000000000..510186ebb0 --- /dev/null +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -0,0 +1,178 @@ +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve, sep } from 'node:path' +import { describe, expect, it, vi } from 'vitest' +import type { Context } from 'cordis' +import { + assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath, + type FailLoudProcess, +} from '../src/index.ts' + +const NAME = 'dsh-test-bin' + +const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-app-boot-')) + +describe('resolveConfigPath', () => { + it('resolves relative to the given cwd outside replay mode', () => { + expect(resolveConfigPath('./cordis.yml', undefined, `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.yml')) + expect(resolveConfigPath('conf/app.yaml', 'record', `${sep}base`)).toBe(resolve(`${sep}base`, 'conf/app.yaml')) + }) + + it('swaps a cordis.yml/.yaml basename for cordis.snapshot.yml in replay mode', () => { + expect(resolveConfigPath('./cordis.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'cordis.snapshot.yml')) + expect(resolveConfigPath('deep/cordis.yaml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'deep/cordis.snapshot.yml')) + }) + + it('leaves a non-cordis basename alone in replay mode and defaults cwd to the process cwd', () => { + expect(resolveConfigPath('custom.yml', 'replay', `${sep}base`)).toBe(resolve(`${sep}base`, 'custom.yml')) + expect(resolveConfigPath('./x.yml', undefined)).toBe(resolve(process.cwd(), 'x.yml')) + }) +}) + +describe('loadEnv', () => { + it('loads variables from .env in the given dir', () => { + const dir = tmp() + writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_VAR=loaded\n') + const warn = vi.fn() + loadEnv(NAME, dir, warn) + expect(process.env['DSH_APP_BOOT_SPEC_VAR']).toBe('loaded') + expect(warn).not.toHaveBeenCalled() + delete process.env['DSH_APP_BOOT_SPEC_VAR'] + }) + + it('stays silent when no .env exists (ambient environment wins)', () => { + const warn = vi.fn() + loadEnv(NAME, tmp(), warn) + expect(warn).not.toHaveBeenCalled() + }) + + it('warns (labelled, single line) when .env exists but cannot be loaded', () => { + const dir = tmp() + mkdirSync(join(dir, '.env')) // a directory named .env: present, unreadable as a file + const warn = vi.fn() + loadEnv(NAME, dir, warn) + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toMatch(new RegExp(`^${NAME}: failed to load \\.env: `)) + }) + + it('defaults dir to the process cwd and warn to a stderr write', () => { + const dir = tmp() + writeFileSync(join(dir, '.env'), 'DSH_APP_BOOT_SPEC_DEFAULTS=yes\n') + const previous = process.cwd() + process.chdir(dir) + try { + loadEnv(NAME) // happy path: the default warn sink is never invoked + } finally { + process.chdir(previous) + } + expect(process.env['DSH_APP_BOOT_SPEC_DEFAULTS']).toBe('yes') + delete process.env['DSH_APP_BOOT_SPEC_DEFAULTS'] + // The default warn sink itself: point it at a broken .env with stderr + // spied, so the arrow body runs without polluting the test output. + const broken = tmp() + mkdirSync(join(broken, '.env')) + const write = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + let written: string[] + try { + loadEnv(NAME, broken) + written = write.mock.calls.map(call => String(call[0])) + } finally { + write.mockRestore() + } + expect(written).toHaveLength(1) + expect(written[0]).toContain(`${NAME}: failed to load .env: `) + }) +}) + +describe('installFailLoud', () => { + function fakeProc(): FailLoudProcess & { handlers: Array<(err: unknown) => void>; written: string[]; exits: number[] } { + const handlers: Array<(err: unknown) => void> = [] + const written: string[] = [] + const exits: number[] = [] + return { + handlers, written, exits, + on: (_event, handler) => { handlers.push(handler) }, + off: (_event, handler) => { handlers.splice(handlers.indexOf(handler), 1) }, + stderr: { write: (chunk: string) => { written.push(chunk) } }, + exit: (code: number) => { exits.push(code) }, + } + } + + it('writes one labelled line with the stack and exits 1 on an Error rejection', () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + const error = new Error('boom') + proc.handlers[0]!(error) + expect(proc.written[0]).toContain(`${NAME}: fatal load failure: `) + expect(proc.written[0]).toContain(error.stack) + expect(proc.exits).toEqual([1]) + }) + + it('stringifies a non-Error rejection and an Error without a stack falls back to its message', () => { + const proc = fakeProc() + installFailLoud(NAME, proc) + proc.handlers[0]!('plain failure') + expect(proc.written[0]).toContain('plain failure') + const stackless = new Error('no stack') + delete (stackless as { stack?: string }).stack + proc.handlers[0]!(stackless) + expect(proc.written[1]).toContain('no stack') + expect(proc.exits).toEqual([1, 1]) + }) + + it('returns an uninstaller that removes the handler (and defaults to the real process)', () => { + const proc = fakeProc() + const uninstall = installFailLoud(NAME, proc) + expect(proc.handlers).toHaveLength(1) + uninstall() + expect(proc.handlers).toHaveLength(0) + // Default-proc arm: install on the real process, then immediately uninstall + // so the suite leaks no handler and can never exit the runner. + const before = process.listenerCount('unhandledRejection') + const uninstallReal = installFailLoud(NAME) + expect(process.listenerCount('unhandledRejection')).toBe(before + 1) + uninstallReal() + expect(process.listenerCount('unhandledRejection')).toBe(before) + }) +}) + +describe('assertEntriesLoaded', () => { + const ctxWith = (entries: Array<{ fiber?: unknown; disabled?: boolean; options: { name?: string } }>): Context => + ({ loader: { entries: () => entries } }) as unknown as Context + + it('passes when every enabled entry has a fiber', () => { + expect(() => { assertEntriesLoaded(ctxWith([ + { fiber: {}, options: { name: 'a' } }, + { disabled: true, options: { name: 'off' } }, + ]), NAME) }).not.toThrow() + }) + + it('throws naming every enabled fiber-less entry', () => { + expect(() => { assertEntriesLoaded(ctxWith([ + { fiber: {}, options: { name: 'ok' } }, + { options: { name: 'broken-a' } }, + { options: { name: 'broken-b' } }, + ]), NAME) }).toThrow(`${NAME}: plugin(s) failed to load: broken-a, broken-b`) + }) +}) + +describe('boot', () => { + it('boots a leaf config through the real Loader and settles the tree', async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + const entries = [...ctx.loader.entries()] + expect(entries.some(entry => entry.options.name === './noop.mjs' && entry.fiber !== undefined)).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + + it('rejects (never exits 0 half-empty) when a config names a plugin that cannot be imported', async () => { + const dir = tmp() + writeFileSync(join(dir, 'cordis.yml'), '- id: ghost\n name: ./missing.mjs\n') + await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`) + }) +}) diff --git a/packages/ui/app-boot/tsconfig.json b/packages/ui/app-boot/tsconfig.json new file mode 100644 index 0000000000..3171312de4 --- /dev/null +++ b/packages/ui/app-boot/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../../vendor/include" + } + ] +} diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index f6db23a232..d219248f41 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -32,6 +32,7 @@ "peerDependencies": { "@cordisjs/plugin-include": "^1.0.4", "@cordisjs/plugin-loader": "^1.0.0-rc.4", + "@deepseek-ai/dsh-app-boot": "^0.0.1", "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", @@ -43,6 +44,7 @@ "devDependencies": { "@cordisjs/plugin-include": "workspace:^", "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", diff --git a/packages/ui/stdio-agent/src/bin.ts b/packages/ui/stdio-agent/src/bin.ts index a07bb2e600..7056486996 100644 --- a/packages/ui/stdio-agent/src/bin.ts +++ b/packages/ui/stdio-agent/src/bin.ts @@ -2,139 +2,24 @@ /** * The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that * loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM - * adapter and a bash executor). Owns the boot glue the three `examples/*` once - * duplicated in their `start.ts`: load the gitignored repo-root `.env`, then - * drive the cordis Loader against the config path (default `./cordis.yml`). + * adapter and a bash executor). The boot glue — `.env` loading, the fail-loud + * Loader guards, the settle-the-tree boot sequence — lives in + * {@link @deepseek-ai/dsh-app-boot}, shared with the ACP bin. * - * Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:repl` - * scripts invoke it with the example's config. + * Usage: `dsh-stdio-agent [path-to-cordis.yml]` (default `./cordis.yml`). The + * `demo:echo` / `demo:repl` scripts invoke it with the example's config. * * @module @deepseek-ai/dsh-stdio-agent/bin */ -import { pathToFileURL } from 'node:url' -import { dirname, resolve } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -/** - * Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the - * CURRENT WORKING DIRECTORY (Node native `process.loadEnvFile`). An absent file - * is fine — the environment may already carry the variables; the leaf - * `cordis.yml` reads them via the `!!js` tag. A present-but-unreadable/malformed - * `.env` is a real misconfiguration: surface it on stderr rather than silently - * running with the wrong environment. The mock-model demo (echo) ships no key - * and simply has no `.env`. - */ -function loadEnv(): void { - try { - process.loadEnvFile(resolve(process.cwd(), '.env')) - } catch (error) { - if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') { - process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`) - } - // ENOENT (no .env) is fine — rely on the ambient environment. - } -} +const NAME = 'dsh-stdio-agent' -/** - * Make a load failure fail loud with a clear message on stderr. Covers the - * failure path the entry-tree check below cannot: when the include's - * `[Service.init]` throws (e.g. a config FILE that does not exist in a real - * directory), the cordis Loader surfaces it as an unhandled promise rejection - * AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because - * `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections. - * Node's default handler already exits non-zero on an unhandled rejection, so - * this does not change the exit code; it replaces Node's noisy stack dump with a - * single labelled line and guarantees `process.exit(1)`. Install before `boot()`. - */ -export function installFailLoud(): void { - process.on('unhandledRejection', (err: unknown) => { - process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`) - process.exit(1) - }) -} - -/** - * After the tree settles, assert every loader entry actually started. This is - * the load-bearing guard against the SILENT-exit-0 bug: when a plugin module - * fails to IMPORT (e.g. a config path in a non-existent directory, so the include - * plugin itself cannot be resolved), the cordis Loader catches the import error - * and only LOGS it (`entry._init`), leaving the entry with no `fiber` and - * producing no rejection — so the process would otherwise exit 0 with a usable - * config typo reported only as a log line. A started entry has a `fiber`; an - * entry with `fiber === undefined` after the tree settled never loaded. Throw on - * any such entry so `boot()` rejects (and the top-level `await` fails the process - * non-zero) instead of returning a half-empty context. - * - * A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()` - * deliberately skips `init()` for it, so it settles without a fiber by design. - * That is a valid config (a consumer turning an optional plugin off), not a - * failed import — exclude it so the guard catches only real load failures. - */ -function assertEntriesLoaded(ctx: Context): void { - const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled) - if (failed.length > 0) { - const names = failed.map(entry => entry.options.name).join(', ') - throw new Error(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`) - } -} - -/** - * Boot the Loader against `configPath` (resolved from the CWD). The include is - * handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never - * depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall - * back to the cwd. `baseUrl` is still pinned to the config's directory so the - * config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve - * against it. Returns the root context once the whole tree has settled. - * - * The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once - * the include ENTRY is registered, but the include then loads its child plugins - * asynchronously. Without awaiting the tree, `boot()` (and `main()`) would - * resolve while the app plugins — the stdin reader, the agent loop — are still - * mounting, and a CLI process with no attached handles yet exits 0 silently. - * Awaiting the tree keeps the process alive until the app's handles are attached. - * - * `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()` - * uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that - * fails to IMPORT leaves an entry with no fiber, caught here by - * {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init - * THROWS surfaces as an unhandled rejection caught by {@link installFailLoud} - * (installed by `main()` before this runs). Together they make any load failure - * exit non-zero with a clear message. - * - * Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are - * resolved by the cordis Loader's internal module loader, which is only active - * under `node --expose-internals` (the flag the `demo:echo`/`demo:repl` scripts - * pass). Without it the Loader falls back to resolving relative to its own module - * and cannot find the config's plugins, so a consumer running the built bin must - * pass `--expose-internals` (or install the plugins where node hoists them). - */ -export async function boot(configPath: string): Promise<Context> { - const absolute = resolve(process.cwd(), configPath) - const ctx = new Context() - ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/' - await ctx.plugin(Loader) - await ctx.loader.create({ - name: '@cordisjs/plugin-include', - config: { path: pathToFileURL(absolute).href }, - }) - await ctx.loader.await() - assertEntriesLoaded(ctx) - return ctx -} - -/** - * Entry point: install the fail-loud guard, load `.env`, then boot the config - * named on argv (default `./cordis.yml`). Awaited at the module top level by the - * published bin (`#!/usr/bin/env node` shebang via the package's `bin` field). - */ -export async function main(argv: string[] = process.argv.slice(2)): Promise<void> { - installFailLoud() - loadEnv() - await boot(argv[0] ?? './cordis.yml') -} - -/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */ -await main() +/* v8 ignore start -- thin self-executing composition over the unit-tested + dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and + built-bin smokes */ +installFailLoud(NAME) +loadEnv(NAME) +await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined)) /* v8 ignore stop */ diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index 30bc952673..cda8b41b1f 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -35,7 +35,7 @@ const stdioBin = join(repoRoot, 'packages/ui/stdio-agent/lib/bin.js') const dshPackages = [ 'core/agent-core', 'core/agent', 'core/session', 'core/system-prompt', 'core/tools', 'core/agent-loop', 'llm/llm', 'bash/bash', 'bash/bash-local', - 'bash/tool-bash', 'support/invariants', + 'bash/tool-bash', 'support/invariants', 'ui/app-boot', 'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl', 'ui/stdio-agent', ] diff --git a/packages/ui/stdio-agent/tsconfig.json b/packages/ui/stdio-agent/tsconfig.json index 948813c370..8b76352d11 100644 --- a/packages/ui/stdio-agent/tsconfig.json +++ b/packages/ui/stdio-agent/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../../vendor/loader" }, + { + "path": "../app-boot" + }, { "path": "../../../vendor/logger-console" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8950475e0c..22f2119329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -870,6 +870,9 @@ importers: '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../session-persistence/session-persistence-jsonl @@ -880,6 +883,18 @@ importers: specifier: ^3.17.0 version: 3.18.0 + packages/ui/app-boot: + devDependencies: + '@cordisjs/plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@cordisjs/plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.6(@cordisjs/plugin-include@vendor+include)(@cordisjs/plugin-loader@vendor+loader) + packages/ui/stdio-agent: devDependencies: '@cordisjs/plugin-include': @@ -897,6 +912,9 @@ importers: '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../app-boot '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session diff --git a/tsconfig.build.json b/tsconfig.build.json index 477c68d31e..b6ba7901f2 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -41,6 +41,7 @@ { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, diff --git a/tsconfig.json b/tsconfig.json index 7d1209b42e..9cd7aa8a6d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -52,6 +52,7 @@ { "path": "./packages/support/invariants" }, { "path": "./packages/ui/acp" }, { "path": "./packages/ui/acp-agent" }, + { "path": "./packages/ui/app-boot" }, { "path": "./packages/ui/stdio-agent" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/subagent/subagent" }, From 4aaf7d848cbc812158a711c56fb9c77745a2c517 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:05:00 +0800 Subject: [PATCH 15/32] =?UTF-8?q?docs(app-boot):=20fix=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20exit-semantics=20precision,=20dep=20row,=20compo?= =?UTF-8?q?sition=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The module/README prose claimed both guard-covered failure classes would otherwise exit 0; only the failed-IMPORT class does (an init throw already exits non-zero via Node's default handler — the guard contributes the labelled line and the guaranteed exit(1), as its own JSDoc says). The dependency-graph row now says loader/include (include is a peer: boot() names it by entry string, so the installed consumer provides it). The bins are self-executing compositions, not main() wrappers. --- packages/README.md | 2 +- packages/ui/app-boot/README.md | 2 +- packages/ui/app-boot/src/index.ts | 20 +++++++++++--------- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/packages/README.md b/packages/README.md index 2f87d57d0d..1d6baadf5a 100644 --- a/packages/README.md +++ b/packages/README.md @@ -63,7 +63,7 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool) dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log) 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-app-boot ← (cordis + loader only) (shared bin boot glue: .env, fail-loud guards, boot sequence) +dsh-app-boot ← (cordis + loader/include only) (shared bin boot glue: .env, fail-loud guards, boot sequence) dsh-stdio-agent ← dsh-agent-core, dsh-session-persistence-jsonl, dsh-agent, dsh-session, dsh-app-boot (stdio chat APP + readline UI + bin) dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl, dsh-app-boot (ACP server APP + bin) ``` diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 7a03b2b72b..92fbc333be 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -10,6 +10,6 @@ Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md) | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | | `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | -Two failure classes the guards close — both would otherwise exit 0 with a usable config typo reported only as a log line: `loader.await()` swallows init rejections (`Promise.allSettled`), surfaced instead by `installFailLoud`; a failed plugin IMPORT is only logged by the Loader, leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. +Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`) resolve through the cordis Loader's internal module loader, active only under `node --expose-internals`; the bins' subprocess smokes exercise that path, while this package's unit suite drives `boot()` in-process against configs with relative specifiers. diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 5322ca7eb6..5515501188 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -3,22 +3,24 @@ * the gitignored `.env`, install the fail-loud Loader guards, resolve the * config path (snapshot-aware), and drive the cordis Loader against a leaf * `cordis.yml` until the whole tree has settled. Each bin stays a thin - * self-executing `main()` over these helpers, parameterized by its diagnostic - * prefix; the loader-failure lore lives here, once, under the per-file - * coverage gate. + * self-executing composition over these helpers, parameterized by its + * diagnostic prefix; the loader-failure lore lives here, once, under the + * per-file coverage gate. * - * Two failure classes the guards close, both of which would otherwise exit 0 - * with a usable config typo reported only as a log line: + * Two failure classes the guards handle: * * - `loader.await()` does NOT rethrow a load error (`EntryTree.await()` uses * `Promise.allSettled`, which swallows rejections). A plugin whose * `[Service.init]` throws surfaces as an unhandled rejection AFTER `boot()` - * resolves — {@link installFailLoud} turns that into one labelled stderr - * line and a guaranteed non-zero exit. + * resolves — Node's default handler already exits non-zero, and + * {@link installFailLoud} replaces the noisy dump with one labelled stderr + * line and a guaranteed `exit(1)`. * - A plugin module that fails to IMPORT is caught and only LOGGED by the * cordis Loader (`entry._init`), leaving the entry with no `fiber` and - * producing no rejection — {@link assertEntriesLoaded} makes `boot()` reject - * on any such entry instead of returning a half-empty context. + * producing no rejection — the process would otherwise exit 0 with a usable + * config typo reported only as a log line; {@link assertEntriesLoaded} makes + * `boot()` reject on any such entry instead of returning a half-empty + * context. * * @module @deepseek-ai/dsh-app-boot */ From 695e64a9787102731a13f2fe07d09468e4d24046 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:12:50 +0800 Subject: [PATCH 16/32] fix(stdio-agent): declare the dsh-llm dependency the moved spec uses stdio-chat.spec.ts imports ContentBlock/StreamChunk types from @deepseek-ai/dsh-llm; the manifest entry lived on the folded package and did not move with the tests. Declared peer+dev like the module's other harness deps (the src consumes dsh-llm vocabulary through the session events it renders). --- packages/ui/stdio-agent/package.json | 2 ++ pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/ui/stdio-agent/package.json b/packages/ui/stdio-agent/package.json index f6db23a232..b2dfa47891 100644 --- a/packages/ui/stdio-agent/package.json +++ b/packages/ui/stdio-agent/package.json @@ -34,6 +34,7 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.4", "@cordisjs/plugin-logger-console": "^1.0.0", "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-agent-core": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", @@ -45,6 +46,7 @@ "@cordisjs/plugin-loader": "workspace:^", "@cordisjs/plugin-logger-console": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-agent-core": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8950475e0c..cb67eb434f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -897,6 +897,9 @@ importers: '@deepseek-ai/dsh-agent-core': specifier: workspace:^ version: link:../../core/agent-core + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session From 6fc33da9604d94a1ce54b82e0bc71b97b68a7e70 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:17:36 +0800 Subject: [PATCH 17/32] docs: regenerate the module graph and dependency row for the dsh-llm edge --- docs/module-graph.md | 3 ++- packages/README.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index c2c2c97bf7..b2f86332aa 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -116,6 +116,7 @@ graph TD acp-agent --> session-persistence-jsonl stdio-agent --> agent stdio-agent --> agent-core + stdio-agent --> llm stdio-agent --> session stdio-agent --> session-persistence-jsonl subagent-fork --> agent @@ -169,6 +170,6 @@ graph TD | `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` | +| `stdio-agent` | `agent`, `agent-core`, `llm`, `session`, `session-persistence-jsonl` | | `subagent-fork` | `agent`, `session`, `subagent`, `subagent-inprocess` | | `subagent-spawn` | `subagent`, `subagent-inprocess` | diff --git a/packages/README.md b/packages/README.md index f016f1e149..54a46c47b7 100644 --- a/packages/README.md +++ b/packages/README.md @@ -63,7 +63,7 @@ dsh-subagent-acp ← dsh-subagent, dsh-agent, dsh-llm, @agentclientprotocol/sdk dsh-tool-subagent ← dsh-subagent, dsh-tools, dsh-agent, dsh-llm (model-facing delegation tool) dsh-tool-todo ← dsh-tools, dsh-agent, dsh-session (model-facing todo_write tool; whole list on the session log) 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-session-persistence-jsonl, dsh-agent, dsh-session (stdio chat APP + readline UI + bin) +dsh-stdio-agent ← dsh-agent-core, dsh-session-persistence-jsonl, dsh-agent, dsh-session, dsh-llm (stdio chat APP + readline UI + bin) dsh-acp-agent ← dsh-agent-core, dsh-acp, dsh-session-persistence-jsonl (ACP server APP + bin) ``` From 2745879132f682b59e61ff59c02b6f184c5a275f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:21:13 +0800 Subject: [PATCH 18/32] refactor(llm): drop the image content block until a path can honor it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ImageBlock had no production producer and every consumer dropped it: the deepseek serializer skipped it, the pi-ai converter skipped it as unrepresentable, the ACP bridge neither advertises image prompt capability nor forwards image blocks, and compact-basic charged a flat 85-token estimate and rendered an [image] placeholder. A block constructed today would silently vanish from the wire — the vocabulary advertised a capability no path honors, the silent-data-loss shape the defensive patterns warn against. The only constructors were tests pinning the skip/estimate branches. Remove ImageBlock and its ContentBlockMap entry (its cache?: CacheHint field leaves with it; CacheHint itself and the other two cache? fields are out of scope). compact-basic loses its explicit image estimate and placeholder arms (the merge-extensible default arms absorb the case); the deepseek serializer, pi-ai converter, and ACP codec already handled image in their default arms, so only their image-naming comments change. The codec's inbound rejection of ACP-protocol image prompt content stays — that guards wire content a client can send regardless of our vocabulary. Tests that constructed harness image blocks to pin the removed branches are dropped (the 85-token estimate pin) or retargeted onto plugin-added block types / other non-text blocks, which the surviving default arms own. Docs, the type-equiv pastes, and the content-block vocabulary RFC's block list and multimodal-home consequence are updated in the same change; the RFC moves to implemented/ and the index is regenerated. A real multimodal feature reintroduces image via declaration merging together with the adapter mapping, ACP advertisement, and compaction pricing that honor it. --- docs/architecture.md | 2 +- docs/core-data-structures/core.md | 3 +- docs/core-data-structures/llm-streaming.md | 1 - docs/rfc/README.md | 2 +- .../2026-06-11-content-block-vocabulary.md | 4 +- .../2026-07-04-drop-image-content-block.md | 27 ++++++++++++ .../2026-07-04-drop-image-content-block.md | 27 ------------ ...-prune-producerless-vocabulary-variants.md | 2 +- packages/bash/tool-bash/tests/tools.spec.ts | 2 +- packages/compact/compact-basic/README.md | 4 +- packages/compact/compact-basic/src/index.ts | 17 ++------ .../compact-basic/tests/compact-basic.spec.ts | 42 +++++++++---------- packages/llm/llm-deepseek/README.md | 1 - packages/llm/llm-deepseek/src/serialize.ts | 1 - .../llm/llm-deepseek/tests/serialize.spec.ts | 14 +++++-- packages/llm/llm-pi-ai/README.md | 2 +- packages/llm/llm-pi-ai/src/convert.ts | 2 +- packages/llm/llm-pi-ai/tests/convert.spec.ts | 6 +-- packages/llm/llm/README.md | 2 +- packages/llm/llm/src/types.ts | 16 ++++--- packages/llm/llm/tests/assembler.spec.ts | 12 +++--- packages/llm/llm/tests/properties.spec.ts | 2 +- packages/ui/acp/src/codec.ts | 6 +-- packages/ui/acp/tests/codec.spec.ts | 5 ++- packages/ui/acp/tests/stream-update.spec.ts | 2 +- 25 files changed, 97 insertions(+), 107 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-drop-image-content-block.md diff --git a/docs/architecture.md b/docs/architecture.md index 0266f1a2e9..84c113c13b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -93,7 +93,7 @@ The web capability uses the same three-package split but folds two capabilities ## The vocabulary (dsh-llm) -Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`, `image`); the union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed sum types instead of strings. +Messages are arrays of typed **content blocks** (`text`, `reasoning`, `tool-call`, `tool-result`); the union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction ([the drop-image RFC](rfc/implemented/simplification/2026-07-04-drop-image-content-block.md)). The same merge-extensible-map pattern is used for `MessageSource`, `FinishReason`, `TurnTrigger`, and `TurnEndReason` — typed sum types instead of strings. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages; the loop logs raw chunks (replay fidelity) while feeding the same chunks through an assembler. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index 9a282e305c..e2f3cd35f5 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -87,11 +87,10 @@ interface ContentBlockMap { 'reasoning': ReasoningBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock - 'image': ImageBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`), `ImageBlock` (`url`, `mimeType?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it. A `Message` is a role plus blocks: diff --git a/docs/core-data-structures/llm-streaming.md b/docs/core-data-structures/llm-streaming.md index 7439eb14a6..997928a3a0 100644 --- a/docs/core-data-structures/llm-streaming.md +++ b/docs/core-data-structures/llm-streaming.md @@ -59,7 +59,6 @@ interface ContentBlockMap { 'reasoning': ReasoningBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock - 'image': ImageBlock } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index d53684bf6b..f0987d2e65 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -52,7 +52,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Drop the `image` content block until a path can honor it](proposed/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | | [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Stop mirroring durable boundaries as agent events](implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) | 2026-06-20 | | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | +| [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 49d55badac..d2d2162588 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -10,12 +10,12 @@ The harness needs one internal language for messages that the loop, session log, ## Decision -Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`, `image`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. +Own it: messages are arrays of typed content blocks (`text`, `reasoning`, `tool-call`, `tool-result`), with the union derived from the merge-extensible `ContentBlockMap` so plugins add block types via declaration merging. The same merge-extensible-map pattern types every "stringly" field (`MessageSource`, `FinishReason`, `TurnTrigger`, `TurnEndReason`). Streaming is a raw chunk protocol; `BlockAssembler` is the single shared assembly implementation. Adapters translate to provider wire formats — mapping cost lives in adapters, where it belongs. In-session context injection (`context/message`, `steering/message`) renders as tagged user-role envelopes (the system-reminder pattern) rather than a new role, so adapters carry zero burden. Live-adapter review has since validated the tagged-envelope rendering against current DeepSeek behavior; a future provider-specific mismatch should be handled in that adapter rather than by adding a new role to the canonical content vocabulary. ## Consequences -- Reasoning, prefill, cache hints, and multimodal content all have a home without provider contortions. +- Reasoning, prefill, and cache hints have a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md new file mode 100644 index 0000000000..ccbf5d755d --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md @@ -0,0 +1,27 @@ +# RFC: Drop the `image` content block until a path can honor it + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +`ImageBlock` (`packages/llm/llm/src/types.ts`) had no production producer, and every consumer on every path DROPPED it: the deepseek adapter's serializer skipped image blocks (a documented MVP limitation), the pi-ai converter skipped them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwarded image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charged a flat token constant and rendered `[image]`. An `ImageBlock` constructed then would silently vanish from the wire — the vocabulary advertised a capability no path honored, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere were tests pinning the skip/drop/estimate branches. + +## Decision + +Remove `ImageBlock`, its `ContentBlockMap` entry (and its `cache?: CacheHint` field with it), the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms absorb the case the way they absorb any unknown block type. Updated in the same change: the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../AGENTS.md); the tests that constructed image blocks to exercise the removed branches were dropped (the estimate pin) or retargeted onto the merge-extensible default arms (plugin-added block types). The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays. + +## Why not keep it? + +This was the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the sibling request-knobs proposal (`2026-07-04-drop-inert-request-knobs`) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw. + +The recorded fallback, had review landed on keeping the slot: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the silent drop was the one state with no defender. Review landed on removal; the fallback stands as the documented alternative should the slot ever return ahead of a full feature. + +## Acceptance criteria + +- No `ImageBlock` / harness `type: 'image'` block construction outside this RFC; the codec's inbound ACP-image rejection still passes its tests. +- Adapter/codec/compaction switches handle the case through their unknown-block default arms (pinned by the plugin-added-block tests). +- Doc pastes, the manifest, and the architecture vocabulary list updated; `pnpm run doc-sync` green. + +## Risks + +Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it existed to preserve. diff --git a/docs/rfc/proposed/simplification/2026-07-04-drop-image-content-block.md b/docs/rfc/proposed/simplification/2026-07-04-drop-image-content-block.md deleted file mode 100644 index e9144cc3aa..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-drop-image-content-block.md +++ /dev/null @@ -1,27 +0,0 @@ -# RFC: Drop the `image` content block until a path can honor it - -Status: proposed - -## Problem - -`ImageBlock` (`packages/llm/llm/src/types.ts`) has no production producer, and every consumer on every path DROPS it: the deepseek adapter's serializer skips image blocks (a documented MVP limitation), the pi-ai converter skips them as unrepresentable, the ACP codec neither advertises image prompt capability nor forwards image blocks outbound and REJECTS image prompt content inbound, and the compaction estimator charges a flat token constant and renders `[image]`. An `ImageBlock` constructed today would silently vanish from the wire — the vocabulary advertises a capability no path honors, which is the silent-data-loss shape AGENTS.md's defensive patterns warn against. The only constructors anywhere are tests pinning the skip/drop/estimate branches. - -## Proposal - -Remove `ImageBlock`, its `ContentBlockMap` entry, the explicit `image` estimate/placeholder arms in compact-basic, and the image-naming comments in the deepseek serializer's, pi-ai converter's, and ACP codec's default arms — those default arms already absorb the case the way they absorb any unknown block type. Update the vocabulary line in [architecture.md](../../../architecture.md), the block list in `packages/llm/llm/README.md`, the deepseek README's image-skip row, the pi-ai README's images-not-representable row, the compact-basic README's image-estimation and `[image]`-placeholder rows, the pastes in [core.md](../../../core-data-structures/core.md) and [llm-streaming.md](../../../core-data-structures/llm-streaming.md), and the type-equiv manifest; amend the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md)'s block list and multimodal-home consequence per [implemented/AGENTS.md](../../implemented/AGENTS.md); drop or retarget the tests that construct image blocks to exercise the removed branches. The ACP codec's inbound rejection of image PROMPT content is unaffected — that guard is about protocol content a client can send regardless of our vocabulary, and it stays. - -## Why not keep it? - -This is the most contested cut in the batch. Multimodal input (screenshots) is a plausible near-term coding-agent feature, and the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md) reserved the slot deliberately. Two responses. First, `ContentBlockMap` is merge-extensible by design: a real multimodal feature reintroduces `image` in core in the same coordinated change that maps it in the adapters, advertises and renders it in ACP, and prices it in compaction — the producer and its consumers arrive together, which is how the map is meant to grow. Second, the middle option — keep the type but make adapters throw UNSUPPORTED instead of silently dropping — converts this into exactly the shape the [request-knobs RFC](2026-07-04-drop-inert-request-knobs.md) argues against: surface whose only implementation is rejection. Absence (a compile error at the would-be producer) is strictly clearer than either silent loss or universal throw. - -If review lands on keeping the slot, the fallback this RFC records is: keep `ImageBlock` but replace every silent skip with a loud rejection, and document that policy in the vocabulary — the current silent drop is the one state with no defender. - -## Acceptance criteria - -- No `ImageBlock` / harness `type: 'image'` block construction outside this RFC; the codec's inbound ACP-image rejection still passes its tests. -- Adapter/codec/compaction switches handle the case through their unknown-block default arms (pinned by the existing plugin-added-block tests where present). -- Doc pastes, the manifest, and the architecture vocabulary list updated; `pnpm run doc-sync` green. - -## Risks - -Re-adding a core vocabulary type later touches several packages at once — but that coordinated change is the shape a real multimodal feature needs anyway (adapter mapping, ACP advertisement, compaction pricing), and none of it exists today to preserve. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md index d967e1d3b2..38565c9214 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -28,4 +28,4 @@ The [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-con ## Risks -None operational — nothing can construct these values today. The mirror-event removals (recorded in [the boundary-mirror RFC](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lack one. If the [image-block RFC](2026-07-04-drop-image-content-block.md) ships first, one of the three `cache?` fields leaves with it; the two proposals are independent and compose in either order. +None operational — nothing can construct these values today. The mirror-event removals (recorded in [the boundary-mirror RFC](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lack one. If the [image-block RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md) ships first, one of the three `cache?` fields leaves with it; the two proposals are independent and compose in either order. diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 82b69fb133..6d5f78b8d0 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -824,7 +824,7 @@ describe('tool-owned UI presentation (presentCall / presentResult)', () => { const ctx = await setup() const present = ctx.tools.get('bash')!.presentResult!( { command: 'x', description: 'x' }, - { content: [{ type: 'image', url: 'https://x/y.png' }], isError: false }, + { content: [{ type: 'reasoning', text: 'unexpected' }], isError: false }, ) expect(present).toBeUndefined() }) diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index c195ae42fb..00cf72bd16 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -8,10 +8,10 @@ This is the implementation tier of the compaction capability — see the [interf The abstract contract states only WHAT compaction does; this backend owns every HOW decision: -- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). +- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, unknown blocks via JSON length). - **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check. - **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface. -- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it. +- **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[tool-call: name(args)]`, `[tool-result: …]`, …) so the summarizer is told what existed rather than silently dropping it. - **Checkpoint framing** — the raw summary is not landed directly. `compactRegion()` wraps it in a checkpoint preamble (so a resuming model reads it as a checkpoint, not a fresh user request, and builds on the captured context rather than restating it) plus `<compacted-summary>…</compacted-summary>` tags. Because region compaction can be invoked manually, a surface may hold several checkpoints, so the framing does not claim everything after it is recent or verbatim. The tags make a prior checkpoint detectable in the transcript on the next compaction cycle: the summarization prompt then instructs the model to merge it in place (preserve still-true facts, drop stale ones) rather than re-summarize it verbatim — a cheap incremental merge that needs no extra log/event machinery. The unframed summary stays on the `compact/summary` provenance event. - **Surface mutation** — `compactRegion()` appends the `compact/start` → `compact/summary` → `compact/end` log records and the single `user/message` replace node carrying the framed summary (see the interface README). - **Auto-compaction** — an `agent/pre-step` listener delegates to `compactIfNeeded()` before every step (not just a turn's first — a tool-heavy turn grows the surface mid-turn, so a runaway turn still compacts, and per-step firing is the only moment to rescue it before overflow). `agent/pre-step` is a serial (awaited, in-order) surface-mutation checkpoint that fires after `turn/start` and BEFORE the step opens (`step/start`) and its request history is derived, so compaction mutates the surface — with its log-only `compact/*` records landing cleanly outside any step — and the loop derives once from the result: no double-derive, and the listener cannot see (or need to rewrite) an already-assembled `messages` array. The listener owns no threshold logic of its own (the single token-pressure check lives in `compactIfNeeded()`); because Cordis `serial` bails early on non-void return values, the listener returns `void` and does not use the dispatcher's bail channel as a veto surface. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f53dace461..2986c3d15d 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -44,9 +44,6 @@ export { resolveConfig } from './types.ts' /** Per-block structural overhead for JSON framing / type tag. */ const BLOCK_OVERHEAD = 4 -/** Heuristic token count for an image block (~85 tokens for low-res URL). */ -const IMAGE_TOKEN_COST = 85 - /** Role-field framing overhead added per message in {@link BasicCompactService.estimateTokens}. */ const ROLE_OVERHEAD = 4 @@ -230,9 +227,6 @@ export class BasicCompactService extends CompactService { case 'tool-result': tokens += this.estimateContentTokens(block.content) + BLOCK_OVERHEAD break - case 'image': - tokens += IMAGE_TOKEN_COST - break default: // Unknown block types (merge-extensible ContentBlockMap): // estimate conservatively via JSON stringify. @@ -706,10 +700,10 @@ export class BasicCompactService extends CompactService { /** * Render content blocks to a single plain-text string for the summarization * prompt. Text and reasoning contribute their text; every other block type - * contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, - * …) so the summarizer is told what non-text content existed in the region - * rather than silently losing it. Blocks join with newlines; empty-text - * blocks contribute nothing. + * contributes a type-tagged placeholder (`[tool-call: name(args)]`, + * `[tool-result: …]`, …) so the summarizer is told what non-text content + * existed in the region rather than silently losing it. Blocks join with + * newlines; empty-text blocks contribute nothing. */ private _blocksToText(blocks: readonly ContentBlock[]): string { const parts: string[] = [] @@ -729,9 +723,6 @@ export class BasicCompactService extends CompactService { parts.push(inner ? `[tool-result: ${inner}]` : '[tool-result]') break } - case 'image': - parts.push('[image]') - break // ContentBlockMap is merge-extensible — render an unknown block as a // bare type-tagged placeholder so a plugin-added block type is still // signalled to the summarizer rather than dropped. diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1929e656be..0e73e1cc1a 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -811,11 +811,6 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { ])).toBe(10) }) - it('estimates image blocks at fixed 85 tokens', () => { - const svc = new BasicCompactService(new Context(), cfg({ auto: false })) - expect(svc.estimateContentTokens([{ type: 'image', url: 'https://example.com/img.png' }])).toBe(85) - }) - it('returns 0 for empty content blocks', () => { const svc = new BasicCompactService(new Context(), cfg({ auto: false })) expect(svc.estimateContentTokens([])).toBe(0) @@ -1324,7 +1319,7 @@ describe('BasicCompactService edge cases', () => { s.append('assistant/message', { turn: 1, step: 1, content: [ - { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'image', url: 'https://x/n.png' }] }, + { type: 'tool-result', toolCallId: CallId('n1'), content: [{ type: 'chart', data: 'x' } as unknown as ContentBlock] }, { type: 'custom-widget', payload: 'x' } as unknown as ContentBlock, { type: 'tool-call', id: CallId('b1'), name: 'bash', arguments: '{}' }, ], @@ -1343,7 +1338,7 @@ describe('BasicCompactService edge cases', () => { const nodes = s.surface.nodes await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! - expect(text).toContain('[tool-result: [image]]') // nested tool-result with content + expect(text).toContain('[tool-result: [chart]]') // nested tool-result with content expect(text).toContain('[custom-widget]') // unknown block placeholder expect(text).toContain('Tool result (call b1): [tool-result]') // empty nested → bare placeholder }) @@ -1510,25 +1505,28 @@ describe('BasicCompactService edge cases', () => { it('renders non-text blocks as type-tagged placeholders across all message kinds', async () => { const svc = createTestService() const s = new Session(SessionId('placeholders')) + // A plugin-added block type (merge-extensible ContentBlockMap) — the + // placeholder path must cover every message kind, not just assistant. + const chart = (id: string): ContentBlock => ({ type: 'chart', data: id } as unknown as ContentBlock) s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) s.append('step/start', { turn: 1, step: 1 }) - // user/message with only an image block → '[image]' placeholder. - s.append('user/message', { content: [{ type: 'image', url: 'https://x/y.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - // assistant/message with an image block AND the tool-call its tool/result - // answers (so the surface is tool-pairing balanced) → '[image]' placeholder. + // user/message with only a plugin-added block → '[chart]' placeholder. + s.append('user/message', { content: [chart('y')], source: { kind: 'user' } }, { surfaceOp: 'append' }) + // assistant/message with a plugin-added block AND the tool-call its + // tool/result answers (so the surface is tool-pairing balanced). s.append('assistant/message', { turn: 1, step: 1, content: [ - { type: 'image', url: 'https://x/z.png' }, + chart('z'), { type: 'tool-call', id: CallId('e1'), name: 'bash', arguments: '{}' }, ], }, { surfaceOp: 'append' }) - // tool/result with an image block → '[image]' placeholder. + // tool/result with a plugin-added block → '[chart]' placeholder. s.append('tool/call', { turn: 1, step: 1, callId: CallId('e1'), name: 'bash', arguments: '{}' }) - s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [{ type: 'image', url: 'https://x/r.png' }], isError: false }, { surfaceOp: 'append' }) - // context/message and steering/message with image content. - s.append('context/message', { content: [{ type: 'image', url: 'https://x/c.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) - s.append('steering/message', { turn: 1, content: [{ type: 'image', url: 'https://x/s.png' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('tool/result', { turn: 1, step: 1, callId: CallId('e1'), content: [chart('r')], isError: false }, { surfaceOp: 'append' }) + // context/message and steering/message with plugin-added content. + s.append('context/message', { content: [chart('c')], source: { kind: 'user' } }, { surfaceOp: 'append' }) + s.append('steering/message', { turn: 1, content: [chart('s')], source: { kind: 'user' } }, { surfaceOp: 'append' }) s.append('step/end', { turn: 1, step: 1 }) s.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) s.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } }) @@ -1537,11 +1535,11 @@ describe('BasicCompactService edge cases', () => { await compactRegion(svc, s, nodes[0]!.seq, nodes[nodes.length - 1]!.seq, 'm') const { text } = svc.summarizeCalls[0]! // Every non-text block surfaces as a placeholder rather than being dropped. - expect(text).toContain('User: [image]') - expect(text).toContain('Assistant: [image]') - expect(text).toContain('Tool result (call e1): [image]') - expect(text).toContain('[Context: [image]]') - expect(text).toContain('[Steering: [image]]') + expect(text).toContain('User: [chart]') + expect(text).toContain('Assistant: [chart]') + expect(text).toContain('Tool result (call e1): [chart]') + expect(text).toContain('[Context: [chart]]') + expect(text).toContain('[Steering: [chart]]') }) }) diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 76af182580..66d6ae3e9b 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -34,7 +34,6 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds ## Limitations (MVP, documented deliberately) - `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work. -- `image` blocks are skipped (no vision support on these models). - `tool_choice` is not mapped (not part of the core vocabulary). ## Errors diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index 4e967d6667..c4e9dcb0a0 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -11,7 +11,6 @@ * rule for thinking mode — required there, ignored elsewhere, so we save * the tokens elsewhere); `tool-call` → `tool_calls[]` * - `tool-result` → its own `{role: 'tool'}` message (text flattened) - * - `image` → skipped (MVP limitation, documented in the README) * * @module dsh-llm-deepseek/serialize */ diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 04387d7aa8..51d4788fe4 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId, LlmError } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' function request(overrides: Partial<GenerateOptions> = {}): GenerateOptions { @@ -110,11 +110,17 @@ describe('serializeMessages', () => { ]) }) - it('skips image blocks (documented MVP limitation)', () => { + it('skips plugin-added block types (merge-extensible ContentBlockMap)', () => { const wire = serializeMessages([ - { role: 'user', content: [{ type: 'image', url: 'data:image/png;base64,x' }, { type: 'text', text: 'see image' }] }, + { + role: 'user', + content: [ + { type: 'chart', data: 'x' } as unknown as ContentBlock, + { type: 'text', text: 'see chart' }, + ], + }, ]) - expect(wire).toEqual([{ role: 'user', content: 'see image' }]) + expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) }) it('emits an empty user message rather than dropping block-less messages', () => { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 70f61fdb62..ca34a8f586 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -31,7 +31,7 @@ pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time depe ## Limitations -Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, images are not representable, `tool_choice` is not mapped. +Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, `tool_choice` is not mapped. ## Testing diff --git a/packages/llm/llm-pi-ai/src/convert.ts b/packages/llm/llm-pi-ai/src/convert.ts index 0ddc41386a..6fa96a0597 100644 --- a/packages/llm/llm-pi-ai/src/convert.ts +++ b/packages/llm/llm-pi-ai/src/convert.ts @@ -93,7 +93,7 @@ export function toPiContext(options: GenerateOptions): PiContext { }) break default: - // image / plugin-added block types: not representable here. + // plugin-added block types: not representable here. break } } diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index be42c9e9b4..078d2a4d3b 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { CallId } from '@deepseek-ai/dsh-llm' -import type { StreamChunk } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { mapStopReason, mapUsage, toPiContext, toStreamChunks } from '@deepseek-ai/dsh-llm-pi-ai' @@ -171,13 +171,13 @@ describe('toPiContext', () => { expect(context.messages.map(message => message.role)).toEqual(['user', 'user', 'toolResult']) }) - it('skips image and unknown blocks in assistant content', () => { + it('skips plugin-added (unknown) blocks in assistant content', () => { const context = toPiContext({ model: 'm', messages: [{ role: 'assistant', content: [ - { type: 'image', url: 'data:,x' }, + { type: 'chart', data: 'x' } as unknown as ContentBlock, { type: 'text', text: 'visible' }, ], }], diff --git a/packages/llm/llm/README.md b/packages/llm/llm/README.md index 4227f5fdef..49a188e523 100644 --- a/packages/llm/llm/README.md +++ b/packages/llm/llm/README.md @@ -25,7 +25,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa ### Content-block vocabulary (`types.ts`) -Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`, `image`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. +Messages are arrays of typed content blocks: `text`, `reasoning`, `tool-call`, `tool-result`. The union is derived from the merge-extensible `ContentBlockMap`, so plugins can add block types via declaration merging. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the map together with the adapter/UI/compaction support that honors it. Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta`, `tool-call-delta`, `block-end`, `usage`, `finish`). `BlockAssembler` is the single shared implementation that assembles chunks into blocks/messages. diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 7b1b9bdc47..fbfc7eb523 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -57,24 +57,22 @@ export interface ToolResultBlock { cache?: CacheHint } -/** An image, by URL or data URL. */ -export interface ImageBlock { - type: 'image' - url: string - mimeType?: string - cache?: CacheHint -} - /** * All known content block shapes, keyed by their `type` tag. * Merge-extensible: plugins add new block types via declaration merging. + * + * The core set is deliberately limited to blocks every shipping path honors. + * Multimodal content (images, audio, …) has no core block type: a feature + * that needs one adds it via declaration merging in the same coordinated + * change that maps it in the adapters, surfaces it in the UI bridges, and + * prices it in compaction — a producer never lands without its consumers + * (see docs/rfc/implemented/simplification/2026-07-04-drop-image-content-block.md). */ export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock - 'image': ImageBlock } export type ContentBlockType = keyof ContentBlockMap diff --git a/packages/llm/llm/tests/assembler.spec.ts b/packages/llm/llm/tests/assembler.spec.ts index e8ad04e3b5..d9a4fe33f3 100644 --- a/packages/llm/llm/tests/assembler.spec.ts +++ b/packages/llm/llm/tests/assembler.spec.ts @@ -63,12 +63,12 @@ describe('BlockAssembler', () => { it('throws from assemble() when a partial has an unhandled blockType', () => { const assembler = new BlockAssembler() - // Directly push a block-end for an image block whose block-start never - // called ensure — but the image block-type flows through normally. - // What we really need is a partial whose blockType is not text/reasoning/tool-call. - // We can achieve this via a block-start for 'image' followed by blocks(). - assembler.push({ type: 'block-start', index: 0, blockType: 'image' } as unknown as StreamChunk) - expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "image"') + // A partial whose blockType is not text/reasoning/tool-call cannot be + // assembled without its block-end. A plugin-added block type (here + // 'video', via the merge-extensible ContentBlockMap) opened by a + // block-start with no closing block-end exercises that throw. + assembler.push({ type: 'block-start', index: 0, blockType: 'video' } as unknown as StreamChunk) + expect(() => assembler.blocks()).toThrow('cannot assemble incomplete block of type "video"') }) it('mustGet throws when an index is missing from the partials map (invariant violation)', () => { diff --git a/packages/llm/llm/tests/properties.spec.ts b/packages/llm/llm/tests/properties.spec.ts index c63d56abbb..3bb2a76c38 100644 --- a/packages/llm/llm/tests/properties.spec.ts +++ b/packages/llm/llm/tests/properties.spec.ts @@ -81,7 +81,7 @@ describe('BlockAssembler properties', () => { fc.assert(fc.property(streamArb, (chunks) => { const blocks = feed(chunks).blocks() for (const block of blocks) { - expect(['text', 'reasoning', 'tool-call', 'tool-result', 'image']).toContain(block.type) + expect(['text', 'reasoning', 'tool-call', 'tool-result']).toContain(block.type) } })) }) diff --git a/packages/ui/acp/src/codec.ts b/packages/ui/acp/src/codec.ts index 3b03a81c83..444e71545c 100644 --- a/packages/ui/acp/src/codec.ts +++ b/packages/ui/acp/src/codec.ts @@ -69,8 +69,8 @@ export function turnEndToStopReason(reason: TurnEndReason): StopReason { * client as message content. Today only `text` maps; `resource_link` is an * ACP prompt-only input rendered into text by {@link acpPromptToText}; * `reasoning` is surfaced via `agent_thought_chunk` - * streaming rather than as a message block, and `tool-call`/`tool-result`/ - * `image` are handled by the tool-call update path or not advertised. + * streaming rather than as a message block, and `tool-call`/`tool-result` + * are handled by the tool-call update path. */ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | undefined { switch (block.type) { @@ -78,7 +78,7 @@ export function harnessBlockToAcpContent(block: ContentBlock): AcpContentBlock | return { type: 'text', text: block.text } // reasoning → streamed as agent_thought_chunk, not a message block // tool-call / tool-result → the tool_call / tool_call_update path - // image → not advertised + // plugin-added block types → not surfaced default: return undefined } diff --git a/packages/ui/acp/tests/codec.spec.ts b/packages/ui/acp/tests/codec.spec.ts index 859e8d40cd..b4f0c10792 100644 --- a/packages/ui/acp/tests/codec.spec.ts +++ b/packages/ui/acp/tests/codec.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { TurnEndReason } from '@deepseek-ai/dsh-session' import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' import { @@ -33,9 +34,9 @@ describe('harnessBlockToAcpContent', () => { expect(harnessBlockToAcpContent({ type: 'text', text: 'hi' })).toEqual({ type: 'text', text: 'hi' }) }) - it('returns undefined for non-text blocks (reasoning/tool/image)', () => { + it('returns undefined for non-text blocks (reasoning / plugin-added)', () => { expect(harnessBlockToAcpContent({ type: 'reasoning', text: 'think' })).toBeUndefined() - expect(harnessBlockToAcpContent({ type: 'image', url: 'https://x/y.png', mimeType: 'image/png' })).toBeUndefined() + expect(harnessBlockToAcpContent({ type: 'chart', data: 'x' } as unknown as ContentBlock)).toBeUndefined() }) }) diff --git a/packages/ui/acp/tests/stream-update.spec.ts b/packages/ui/acp/tests/stream-update.spec.ts index b580a5fc3d..036bb4f7bb 100644 --- a/packages/ui/acp/tests/stream-update.spec.ts +++ b/packages/ui/acp/tests/stream-update.spec.ts @@ -91,7 +91,7 @@ describe('streamSessionEventUpdate', () => { it('drops non-text tool-result content (text-only)', () => { const update = updatesFor(evt('tool/result', { turn: 1, step: 1, callId: CallId('c1'), - content: [{ type: 'image', url: 'https://x/y.png' }], + content: [{ type: 'reasoning', text: 'private' }], isError: false, }))[0] expect((update as { content: unknown[] }).content).toEqual([]) From 5a450af7383242d92fae4457bd79d201249683ae Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:29:07 +0800 Subject: [PATCH 19/32] test(acp): derive the replay config from the live config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/acp-agent/cordis.snapshot.yml is a 26-line declarative overlay: one entry mounts @cordisjs/plugin-include on ./cordis.yml with patches that disable the llm-deepseek entry by id and insert llm-replay. Every other entry is the live tree loaded through the include, so the replay tier exercises exactly what ships and an app-shape change lands once — the silent-drift class the hand-mirrored 125-line twin invited is structurally gone. The bin is untouched; recording still boots cordis.yml; assertEntriesLoaded tolerates the disabled entry by design. All snapshot scenarios pass unchanged, byte-identical goldens included; the include applies patches at load time only, which a one-shot replay boot is exactly. Implements docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md (moved from proposed/ and amended); the acp-snapshot-tests and hook-snapshot-matrix RFCs' replay-config facts are amended in the same change. --- docs/rfc/README.md | 2 +- .../testing/2026-06-19-acp-snapshot-tests.md | 2 +- .../2026-07-04-hook-snapshot-matrix.md | 2 +- ...6-07-04-single-source-acp-replay-config.md | 23 +++ ...6-07-04-single-source-acp-replay-config.md | 26 ---- examples/acp-agent/cordis.snapshot.yml | 147 +++--------------- 6 files changed, 50 insertions(+), 152 deletions(-) create mode 100644 docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md delete mode 100644 docs/rfc/proposed/testing/2026-07-04-single-source-acp-replay-config.md diff --git a/docs/rfc/README.md b/docs/rfc/README.md index c207d50e76..922b71d33e 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -84,7 +84,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r |---|---| | [Deterministic tests, the replay invariant fixture, and race stress](proposed/testing/2026-06-11-deterministic-and-stress-testing.md) | 2026-06-11 | | [Mutation testing as the coverage counterweight](proposed/testing/2026-06-11-mutation-testing.md) | 2026-06-11 | -| [Single-source the acp-agent replay config](proposed/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 | <!-- gen-rfc-index:end proposed --> ## Implemented @@ -183,6 +182,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Record fork and mixed spawn+fork snapshot scenarios](implemented/testing/2026-06-22-fork-snapshot-scenarios.md) | 2026-06-22 | | [Per-session snapshot replay for nested agents](implemented/testing/2026-06-22-subagent-snapshot-replay.md) | 2026-06-22 | | [Hook snapshot matrix — end-to-end goldens for both bridges](implemented/testing/2026-07-04-hook-snapshot-matrix.md) | 2026-07-04 | +| [Single-source the acp-agent replay config](implemented/testing/2026-07-04-single-source-acp-replay-config.md) | 2026-07-04 | <!-- gen-rfc-index:end implemented --> ## Rejected diff --git a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md index 7f6cd47b48..cd7058487f 100644 --- a/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md +++ b/docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md @@ -46,7 +46,7 @@ Replay is positional: the Nth `stream()` call serves the Nth `ReplayEntry`. This Recording runs the scenario with the real `llm-deepseek` adapter and the JSONL persistence backend, then copies the produced `.jsonl` into the scenario dir. Per-event appends are durable, but the harness shuts the subprocess down gracefully (close stdin → `await ctx.dispose()`) before harvesting so the final events are flushed. `llm-replay` itself does no recording — it is replay-only. -The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot reuse the normal config — it uses a dedicated `examples/acp-agent/cordis.snapshot.yml` that installs `llm-replay` in place of the adapter. The rest of the tree is not duplicated: both the normal `examples/acp-agent/cordis.yml` and the replay config load the same `@deepseek-ai/dsh-acp-agent` app entry (which bundles the agent-core spine + JSONL persistence + the ACP bridge), differing only in the LLM backend (`llm-deepseek` vs `llm-replay`) and the bash executor line. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. +The ACP server app loads `@deepseek-ai/dsh-llm-deepseek`, whose `apply` throws when no API key is present ([packages/llm/llm-deepseek/src/index.ts](../../../../packages/llm/llm-deepseek/src/index.ts)). So replay cannot boot the normal config as-is — `examples/acp-agent/cordis.snapshot.yml` is an include-overlay of `cordis.yml` that disables the `llm-deepseek` entry by id and inserts `llm-replay` (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)); every other entry IS the live tree, loaded through the include. Recording reuses the normal `cordis.yml` (real adapter) — its persistence root reads `$DSH_SNAPSHOT_SESSIONS_ROOT` when the harness sets it — so there is no separate record config. The `dsh-acp-agent` bin selects `cordis.snapshot.yml` for `DSH_SNAPSHOT=replay` and skips `.env` loading in that mode so a stray key cannot trigger a live call. ### Two surfaces: normalize, then compare diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index f8ec029d22..ad61bd3b84 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -18,7 +18,7 @@ Two coupled changes, in one PR: It is safe because a bridge whose config file is absent is a **silent no-op**: `apply()` catches the read failure, logs through `ctx.logger`, and registers nothing — zero listeners, zero session events. The `acp-agent` app ships no stdout logger, so the warning cannot reach the ACP JSON-RPC channel. A scenario (or a real project) that wants only Claude hooks ships only `hooks.json`; the Codex bridge sees no `codex-hooks.json` and vanishes. This was verified empirically: with both bridges loaded, all pre-existing snapshots (none of which ship a `codex-hooks.json`) are byte-identical. -Loading both is the minimum that lets the snapshot tier exercise each dialect against the same real app the product ships. Recording (which boots `cordis.yml`) must load both too, so a recorded Codex scenario captures the transcript with its hook genuinely active — hence the symmetric edit to both configs. +Loading both is the minimum that lets the snapshot tier exercise each dialect against the same real app the product ships. Recording (which boots `cordis.yml`) loads both by construction, and replay inherits them the same way: `cordis.snapshot.yml` is an include-overlay of `cordis.yml` that swaps only the llm entry (see [single-source the acp-agent replay config](2026-07-04-single-source-acp-replay-config.md)), so a bridge added to the live tree is in the replay tree with no second edit. ### 2. A snapshot scenario per hook point × its headline outcome, both dialects diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md new file mode 100644 index 0000000000..56a4cfc552 --- /dev/null +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -0,0 +1,23 @@ +# RFC: Single-source the acp-agent replay config + +Status: implemented + +## Problem + +`examples/acp-agent` shipped two hand-maintained configs: `cordis.yml` (the live tree) and a `cordis.snapshot.yml` that mirrored it entry-for-entry with only the llm backend swapped — stripped of comments, the entire difference was the eight-line `llm-deepseek` stanza versus the two-line `llm-replay` stanza. Every app-shape change had to be made twice, and nothing gated the symmetry: if the copies drifted, the snapshot tier would silently exercise a different app than the one that ships — the ["green units, broken product" class of gap](../../../postmortem/0001-acp-default-export-drops-inject.md) the snapshot tier exists to close, reintroduced one level up, with reviewer vigilance as the only defense. + +## Decision + +`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry by id and insert the `llm-replay` entry ([the vendored include plugin](../../../../vendor/include/src/index.ts)'s patch mechanism: by-id overrides with a name-assertion guard, plus top-level inserts). Every other entry — the app, the bash executor, the fs/subagent/todo tools, both hook bridges, the system prompt — is the live tree itself, loaded through the include, so replay exercises exactly what ships and an app-shape change lands once. The `dsh-acp-agent` bin is untouched (it still just selects this file for `DSH_SNAPSHOT=replay`); recording still boots `cordis.yml` directly; the bin's `assertEntriesLoaded` guard tolerates the disabled entry by design (a disabled entry is the one legitimate fiber-less state). + +One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical goldens included. + +## Why not the alternatives? + +Keeping the full twin with a symmetry verify-gate was the recorded fallback — it would have removed the silent-drift class but kept a 125-line near-copy whose only content was one entry's difference, growing with every plugin the app gains. A bin-side swap (parse the config, replace the entry, delete the file) would have put YAML surgery inside a published artifact and moved the replay delta out of sight; the overlay keeps the delta declarative, readable, and next to the base config — the teaching value the twin's defenders actually wanted. + +## Consequences + +- A plugin added to `cordis.yml` is in the replay tree with no second edit; the drift class is structurally gone rather than gated. +- The overlay depends on entries carrying stable `id:`s — which the live config already does, and which the include's name-assertion patch guard makes checkable. +- If a future replay tree needs a second divergence (another backend swapped), it is one more patch line, not a second fork of the file. diff --git a/docs/rfc/proposed/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/proposed/testing/2026-07-04-single-source-acp-replay-config.md deleted file mode 100644 index 3cb986a3e7..0000000000 --- a/docs/rfc/proposed/testing/2026-07-04-single-source-acp-replay-config.md +++ /dev/null @@ -1,26 +0,0 @@ -# RFC: Single-source the acp-agent replay config - -Status: proposed - -## Problem - -`examples/acp-agent` ships two hand-maintained configs: `cordis.yml` (the live tree) and `cordis.snapshot.yml` (the keyless replay tree). Stripped of comments and blanks, their entire difference is ONE plugin entry — the eight-line `llm-deepseek` stanza (with its `!!js` env keys and model list) versus the two-line `llm-replay` stanza. Every other entry is byte-identical, including the multi-line system prompt and both hook-bridge stanzas. Every app-shape change must therefore be made twice, and the [hook-snapshot-matrix RFC](../../implemented/testing/2026-07-04-hook-snapshot-matrix.md) records paying exactly that tax: "hence the symmetric edit to both configs". - -Nothing gates the symmetry. If the copies drift, the snapshot tier silently exercises a different app than the one that ships — the ["green units, broken product" class of gap](../../../postmortem/0001-acp-default-export-drops-inject.md) the snapshot tier exists to close, reintroduced one level up, with reviewer vigilance as the only defense. - -## Proposal - -Make the replay tree derive from the live tree instead of mirroring it. Preferred endpoint: a single source — either `cordis.snapshot.yml` becomes a thin overlay that includes `cordis.yml` and swaps only the llm entry (if the vendored loader/include config supports entry-level override), or the acp-agent bin's existing `DSH_SNAPSHOT=replay` branch performs the one-entry swap on the parsed config and `cordis.snapshot.yml` is deleted. Fallback endpoint, if single-sourcing is judged too magical for a teaching example: keep both files and add a boring verify gate (in the `doc-sync`/`hygiene` family) asserting the two configs' entry sets are equal modulo the llm entry. The implementing PR picks after checking the loader's include/override capability, updates the recording docs, and amends the snapshot RFCs' facts per [implemented/AGENTS.md](../../implemented/AGENTS.md). - -## Why not keep the twin? - -An explicit replay file is transparently readable and teaches replay semantics — the strongest counterargument, and the reason the fallback keeps the file and adds only the gate. YAML surgery inside the published bin is real complexity in a shipping artifact, and an include-overlay depends on loader capability that may not exist. But the status quo — a 125-line hand-maintained near-copy of a 141-line file whose one meaningful difference is two lines, defended by nothing — is the one option with a silent failure mode, and it grows with every plugin the app gains (the hook-bridge stanzas are twins in both files). - -## Acceptance criteria - -- Either one config file plus a mechanical llm-entry swap exercised by the snapshot suite itself, or two files plus a symmetry gate that fails CI on any non-llm divergence. -- All snapshot scenarios (hook matrix included) pass unchanged; `pnpm run test:snapshot:record` still boots the live tree. - -## Risks - -The include-overlay shape may be unsupported by the vendored loader — then the bin-side swap or the gate. `echo-agent`/`coding-agent` are unaffected (no snapshot twin). If the gate route is chosen, it is one more bespoke verify script — the cost the repo's gate-friendly policy explicitly accepts for encoding an invariant no human reliably remembers. diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index 3bef79d83a..0826c49b29 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -1,125 +1,26 @@ -# Snapshot-test REPLAY config: the acp-agent plugin tree with the model backend -# swapped to llm-replay (serves a recorded session JSONL — no API key, no -# network). The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. +# Snapshot-test REPLAY overlay: the SAME app tree as cordis.yml, derived from +# it by an include — the one difference is the model backend. A keyless replay +# run cannot boot the real adapter (llm-deepseek's apply() throws without +# DEEPSEEK_API_KEY), so the include patches the live tree at load time: the +# llm-deepseek entry is disabled by id, and the llm-replay entry (which serves +# a recorded session JSONL — no API key, no network) is inserted. Every other +# entry — the app, the bash executor, the fs/subagent/todo tools, both hook +# bridges, the system prompt — IS the live tree, so replay exercises exactly +# what ships and an app-shape change lands once, in cordis.yml. # -# Same app as cordis.yml (@deepseek-ai/dsh-acp-agent: the agent-core spine + -# JSONL persistence + the ACP bridge) — only the LLM backend differs: llm-replay -# here, llm-deepseek there. It can't reuse the real adapter because llm-deepseek's -# apply() throws without DEEPSEEK_API_KEY, killing a keyless replay run at boot. -# -# stdout is reserved for the ACP JSON-RPC protocol — no stdout logger (the app -# package omits it). The replay fixture path comes from $DSH_SNAPSHOT_FILE (and -# an optional $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. - -# The replay adapter: short-circuits llm/stream with the recorded log's chunks, -# in place of llm-deepseek. -- id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - -# Local bash executor for agent-core's tool-bash schema. -# FIXME(config-comments): keep this executor note from implying bash is the -# whole tool set; filesystem, subagent, and todo_write are loaded below. -- id: bash - name: '@deepseek-ai/dsh-bash-local' +# The dsh-acp-agent bin selects this file for DSH_SNAPSHOT=replay. The replay +# fixture path comes from $DSH_SNAPSHOT_FILE (and an optional +# $DSH_SNAPSHOT_OVERRIDE sidecar), set by the snapshot harness. stdout stays +# reserved for the ACP JSON-RPC protocol (the app package loads no stdout +# logger). Patches apply when the include loads the file — a one-shot replay +# boot, so the load-time-only patch semantics are exactly enough. +- id: base + name: '@cordisjs/plugin-include' config: - timeoutMs: 60000 - -# The ACP server app — identical to cordis.yml's entry. -- id: acp-agent - name: '@deepseek-ai/dsh-acp-agent' - config: - model: deepseek-v4-flash - persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' - systemPrompt: | - You are a coding assistant driven over the Agent Client Protocol. - - Your tools are read/write/edit for file operations, bash (plus - bash_output/bash_kill for background tasks), and subagent. Use read to - inspect UTF-8 text files, write to create or replace files, and edit for - targeted literal replacements. Use bash for shell commands, tests, - searches, and operations that are not ordinary file reads or edits. 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. Use - subagent_fork instead when the subtask needs THIS conversation's - context: the child inherits the log so far. - - For multi-step work, use the todo_write tool to track a task list: - send the WHOLE list each call (it replaces the previous one), keep at - most one task in_progress (exactly one while work remains), and mark a - task completed as soon as it is done. Skip it for trivial single-step - tasks. - -# 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 - -# The model-facing todo_write tool — identical to cordis.yml's wiring, so a -# replayed todo_write tool call resolves to a real tool during snapshot replay. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -# Filesystem capability stack — identical to cordis.yml's wiring, so replayed -# read/write/edit tool calls resolve to the real tools during snapshot replay. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# The Claude Code hook bridge. `configPath` is read ONCE at load and resolves -# `./hooks.json` against the PROCESS cwd (not per-session) — in these snapshot -# runs the harness launches the subprocess with process cwd = the scenario's temp -# workspace, so a scenario that ships `workspace/hooks.json` (copied into that cwd -# before the run) exercises the hooks path end-to-end; every other scenario has no -# such file, so the parse fails-soft and the bridge registers nothing (a silent -# no-op — the ACP app loads no logger exporter, so the warning never reaches -# stdout). Hooks themselves run in the session cwd (the bridge passes it as workdir). -- id: hooks-claude - name: '@deepseek-ai/dsh-hooks-claude' - config: - configPath: ./hooks.json - -# The Codex hook bridge, loaded alongside the Claude one (symmetric with -# cordis.yml so a recorded Codex scenario fires the hook during recording too). It -# reads its OWN file `./codex-hooks.json` (Codex's dialect) — the two bridges -# cannot share one config. Same fails-soft-when-absent contract: a scenario that -# ships `workspace/codex-hooks.json` exercises the Codex path end-to-end; a -# scenario without one registers nothing (a silent no-op, never reaching stdout). -- id: hooks-codex - name: '@deepseek-ai/dsh-hooks-codex' - config: - configPath: ./codex-hooks.json + path: ./cordis.yml + patches: + - id: llm-deepseek + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' From bee8132a7f4d5f3a98c92314b9f5f65a36984b93 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:37:05 +0800 Subject: [PATCH 20/32] Add the no-hardcoded-tunables convention and its review check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A number or string that two reasonable deployments could want set differently — a timeout, grace period, output cap, result-count limit, model name, base URL — belongs on the plugin's schemastery Config with the shipped value as its default, not in a bare literal or module constant. A DEFAULT_* constant or a test-only injection seam is not configurability: the test is whether a cordis.yml deployment can change the value without a code edit. Protocol/wire constants, semantic constants, values pinned by an external spec, and security invariants stay hardcoded. The convention lands in AGENTS.md § Conventions (the authoritative source the review skill cites); dsh-code-review gains the matching reviewer-only check, since no mechanical gate can detect a hardcoded tunable. --- .agents/skills/dsh-code-review/SKILL.md | 3 ++- AGENTS.md | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 9dc4de5ca6..d23c68ab2b 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -21,7 +21,7 @@ Independent judgment governs *what to look at* and *how to apply a rule to this These define the conventions and gates this repo is checked against, and they are authoritative. Read them at the source so this skill never drifts out of sync — and apply judgment in *interpreting* them for the case at hand, not in deciding whether they apply. -- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, the empty-`catch` rule, symmetry. +- **[AGENTS.md](../../../AGENTS.md) § Conventions** — effect-based registrations, declaration-merging for events/ctx keys, waterfall `next()` discipline, discriminated-union match-don't-chain, explicit-over-implicit at seams, no hardcoded tunables in plugins, the empty-`catch` rule, symmetry. - **AGENTS.md § Defensive patterns (hard-won)** — each bullet is a bug class that bit us. Reviewing anything touching process lifecycle, async/await, disposal, or adapter error paths? Re-read this first — then look for the *adjacent* mistake it doesn't name. - **AGENTS.md § Type Safety and Documentation** — the doc-sync rule (code change ⇒ update README + JSDoc in the SAME commit) and the no-hard-wrap markdown convention. - **[packages/AGENTS.md](../../../packages/AGENTS.md)** — per-package conventions (file layout, the HMR-safety test requirement). @@ -44,6 +44,7 @@ Where your independent reasoning earns its keep. Start here, then keep going acr - **e2e verifies the world, not the agent's self-report.** For real-API tests, confirm the assertion re-runs the command/checks the file externally — a keyword probe lets a cheating agent pass (see AGENTS.md e2e bullet). For a behavior change to the agent's real flows, a no-key/mock test alone is usually insufficient: a with-key e2e (especially a smoke test that boots the real example and checks the world) is cheap here and catches "green units, broken product" — encourage it rather than treating real-API tests as expensive (see AGENTS.md § Secrets / .env). - **Plugin export shape + real-loader coverage.** A new/changed `cordis.yml`-loaded plugin: is it a function/namespace plugin (`name`/`inject`/`Config`/`apply` named exports) with NO `export default`? A stray default export makes the Loader's `unwrapExports` drop `inject` and the plugin crashes at load with `cannot get property … without inject` — invisible to hand-built `ctx.plugin({...})` tests and to line coverage. Confirm there's a test driving it through the REAL loader path (the no-key subprocess e2e for ACP is the model). And any opportunistic read of a service NOT in `static inject` should use `ctx.get(name)`, not `ctx.<name>` (the property proxy throws through a foreign shadow). See [packages/AGENTS.md](../../../packages/AGENTS.md) and [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md). - **Seam discipline.** New swappable capability? Check it's split per the capability-seams RFC (interface / impl / consumer), and that the consumer injects the interface key, never an implementation type. +- **Hardcoded tunables that should be plugin config.** A literal timeout, grace period, output/truncation cap, result-count limit, retry count, buffer size, model name, API base URL, user agent, or filesystem path introduced inside a plugin belongs on the plugin's schemastery `Config` with the shipped value as its default (AGENTS.md § Conventions "No hardcoded tunables in plugins"). A named `DEFAULT_*` constant or a test-only injection seam is not configurability — the question to ask is whether a `cordis.yml` deployment can change the value without a code edit. Protocol/wire constants, semantic constants, values pinned by an external spec, and security invariants are exempt; a new `Config` field also needs its README row and range validation. No gate detects a hardcoded tunable — this check is entirely on the reviewer. - **Test quality — sufficiency, not just coverage.** 100% per-file coverage and a green suite are necessary, not sufficient: they prove the lines *ran*, not that the feature *works the way it ships*. Judge whether the tests are sufficient on two axes. (1) **Would they fail if the behavior regressed?** A test that passes but asserts the wrong thing — or restates the implementation instead of the contract (events fired, disposal reached, the world changed) — is worse than none. (2) **Do they exercise the REAL thing, the way it's actually used?** Prefer the genuine collaborator over a fake, drive the change through its real entry path (the cordis Loader, the ACP bridge, a booted subprocess — not a hand-built `ctx.plugin({...})` that bypasses `unwrapExports`), and verify the WORLD (re-read the file/log/registry externally), not the agent's self-report. A test that fakes the inputs just enough to cover every line will agree with whatever the author assumed; the real thing won't. When a test sets up a *clean/happy* path to reach a line, ask whether the line's PURPOSE is exercised — e.g. a durability/teardown path "tested" by a fully-completed turn never proves the mid-flight teardown it exists for; a torn-tail recovery branch covered by a well-formed log never proves recovery. Flag tests that hit the line but not the scenario. See AGENTS.md § Defensive patterns "Line coverage is not behavior coverage" and "Prefer the REAL implementation over a mock/stand-in in tests". - **Snapshot coverage for transcript/UX changes.** If the PR changes the editor-facing transcript or end-to-end agent UX — the ACP bridge's event→update translation, the agent loop's observable output, tool presentation, or anything an editor renders — it must add or update a snapshot scenario (`examples/*/tests/**/*.snapshot.ts`, goldens under `examples/acp-agent/tests/snapshots/`) or note explicitly why none applies (AGENTS.md § Conventions). Review the golden diff itself: a changed `stdout.golden.txt` / `session.golden.txt` is a behavior change in disguise — confirm it's intended, not an accidental regression someone re-recorded away. A pure internal refactor with no observable-output change is exempt, but the PR should 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). - **Bilingual docs: review translation quality, not just pairing.** If the PR adds or edits a doc pair, read the changed English and Chinese sides and compare the meaning, not only the mechanical diff. Verify terms against [terminology.md](../../../docs/i18n/terminology.md), including first-occurrence annotations and "do not translate as" prohibitions; if a new term has no established precedent, the PR should keep it in English, list it under `待定术语`, and update the terminology table once the rendering is decided. A green `verify-translation-pairing` only proves hashes, switchers, and structure were recorded — it does not prove the translation is faithful, natural, or correctly termed. Treat [translation-rules.md](../../../docs/i18n/translation-rules.md) MUST/MUST NOT violations as blocking. diff --git a/AGENTS.md b/AGENTS.md index ee5eb7d6f0..f9acd0e629 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -261,6 +261,7 @@ Dev/test/demo run **unbuilt** via tsx + the source `paths` map in the root `tsco - **Plugins, not loop changes**: new behavior goes into a plugin on the documented extension seams (see the plugin sanity checklist in docs/architecture.md). Changing `agent-loop` requires updating that doc. - **Capability seams are three packages**: when adding a swappable capability (an execution backend, a provider integration, …), split it into *interface* (abstract service + vocabulary types, e.g. `bash/`), *implementation* (a concrete subclass, e.g. `bash-local/`), and *consumer* (what the model/plugins see, e.g. `tool-bash/`). Implementations and consumers then evolve independently — a sandboxed executor replaces `bash-local` without touching tool schemas. The LLM seam follows the same shape (`llm/` is interface + consumer surface; adapters are implementations). See docs/architecture.md § "Capability seams" for when NOT to split. - **Explicit > implicit at package seams**: interface/vocabulary types spell out every field a consumer must supply — no optional field that the implementation silently fills with a hidden `?? default`. Put defaulting in the owning implementation as an explicit step (a `resolve(request): Spec` method that turns the optional-field request into the required-field spec), not smuggled inside `run()`/`start()`. Example: `dsh-bash` splits `BashExecRequest` (optional `workdir`/`timeoutMs`, model-facing) from `BashExecSpec` (required, what `run`/`start` act on); the tool layer calls `ctx.bash.resolve()` between them. The reader of a `BashExecSpec` never has to wonder where the working directory came from. +- **No hardcoded tunables in plugins — a deployment knob belongs on `Config`**: a number or string that two reasonable deployments could want set differently — a timeout, grace period, output/truncation cap, result-count limit, retry count, buffer size, model name, API base URL, user agent, filesystem path — is plugin configuration, not a bare literal or module constant. Expose it as a field on the plugin's schemastery `Config` with the shipped value as its `.default(…)`, document it in the package README, and validate the range where garbage would misbehave silently (see `assertPositiveFinite` in `dsh-bash-local`/`dsh-web-fetch-local`). A named `DEFAULT_*` constant does not make a value configurable, and neither does a test-only injection seam (`internals`) — the test is whether a `cordis.yml` deployment can change the value without a code edit. The rule is scoped to genuine tunables: protocol/wire constants (format versions, method names, event tags), semantic constants (exit codes, signal names, HTTP statuses), values pinned by an external spec, and security invariants (the credential-scrub env pattern) stay hardcoded — making those configurable invites misconfiguration, not flexibility. When unsure, ask who would ever set it: a deployer tuning the product (config) or only a maintainer changing the design (constant). The exemplar is `dsh-web-fetch-local`, whose every cap is a defaulted `Config` field. - **Opaque cross-boundary ids are branded, never bare `string`**: an identity that crosses a package seam and that a consumer must store-and-return but never parse (a backend-defined version token, a target key, a task/session/call id) is a `Branded<B>` from `@deepseek-ai/dsh-brand` with a same-named cast factory in the owning package — a zero-cost compile-time guard so semantically-distinct strings stop being interchangeable. Not every string needs it: author-readable names (`ToolName`) and closed code unions (`ErrorCode`) don't. See [Branded IDs everywhere they belong](docs/rfc/implemented/architecture/2026-06-20-branded-ids.md). - **An empty `catch` must name what it swallows and why nothing else can hit it**: a bare `catch {}` hides bugs. When you deliberately ignore a throw, the comment must (a) name the single expected failure, (b) say why ignoring it is correct — usually because the useful state was already captured *before* the `try` — and (c) make clear nothing else of consequence can reach the catch (ideally the `try` wraps a single statement). Example: the error-body `response.json()` parse in `dsh-llm-deepseek`'s adapter sets `code` + HTTP `status` from the status line before the `try`, so a malformed provider body can only cost a richer message, never the real error. - **Symmetry is usually more correct**: when two related values play parallel roles (a test fixture and its expected output, a request shape and its response shape, a buggy input and the test that checks the fix), give them parallel form — both named consts, or both inline, not one each way. Asymmetry is a smell that usually points at a missed extraction. From 774d460889b90ace04eabaf3a8fcff633b6e590a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:37:23 +0800 Subject: [PATCH 21/32] Expose audited hardcoded tunables as plugin config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The audit swept every packages/*/* plugin for the new AGENTS.md convention (no hardcoded tunables in plugins) and exposes each finding as a defaulted, validated Config field. Defaults are the previously hardcoded values throughout, so no deployment or golden changes. - tool-fs (had NO Config): readLimit, readMaxLineLength, readMaxBytes, readStreamMinSize. The caps thread through ReadToolCaps/ReadWindow — read-render already documented that the consumer applies the caps, so they become explicit per-request fields. - tool-web: searchMaxResults (WEB_SEARCH_MAX_RESULTS stays as the schemastery default). Also fixes the stale GREP_LIMIT references in search.ts and the web-capability-seam RFC (no such constant exists). - bash-local: graceMs (SIGTERM->SIGKILL escalation grace). The RunInternals.graceMs test seam is gone: graceMs is now a required SpawnSpec field filled from config, so tests exercise the real config path and the defaults live in exactly one place. - subagent-acp: disposeEofGraceMs / disposeGraceMs. The AcpRunSpec fields become required for the same one-defaulting-layer reason. - session-persistence-sqlite: journalMode ('wal' default; the rollback-journal modes serve filesystems where WAL's shared-memory files do not work, e.g. network mounts). - hooks-claude + hooks-codex: stderrSummaryMaxChars for the persisted hook/result stderr summary. The duplicated summarize() helpers merge into hook-protocol's summarizeStderr(stderr, maxChars), beside the HookResultRecord field it feeds, with the bound parameterized the same way runHook's defaultTimeoutMs already is. - compact-basic: charsPerToken for the token estimator (default 4, the English-text heuristic; CJK-heavy deployments need ~1-2 or compaction fires far too late). Also corrects the BasicCompactService class doc, which claimed defaults the required-field config never had. - fs-local: deletes the dead STREAM_MIN_SIZE constant and the dead FsIoInternals.streamMinSize seam — the read-routing bound lives in the consumer (tool-fs), where it is now config. This is item 1 of the proposed prune-write-only-fs-surface RFC, annotated accordingly. Every new field gets range validation (following the existing assertPositiveFinite pattern), a README row, and tests covering the configured behavior, the schema default, and load-time rejection. --- docs/core-data-structures/web.md | 2 +- .../2026-06-24-web-capability-seam.md | 2 +- .../2026-07-04-prune-write-only-fs-surface.md | 2 +- packages/bash/bash-local/README.md | 3 +- packages/bash/bash-local/src/index.ts | 12 +++- packages/bash/bash-local/src/run.ts | 11 ++- .../bash/bash-local/tests/executor.spec.ts | 27 +++++--- packages/bash/bash-local/tests/run.spec.ts | 3 +- packages/bash/tool-bash/tests/tools.spec.ts | 16 ++--- packages/compact/compact-basic/README.md | 1 + packages/compact/compact-basic/src/index.ts | 31 +++++---- packages/compact/compact-basic/src/types.ts | 31 +++++++-- .../compact-basic/tests/compact-basic.spec.ts | 17 +++++ packages/fs/fs-local/src/fsio.ts | 10 +-- packages/fs/fs-local/src/index.ts | 1 - packages/fs/tool-fs/README.md | 13 +++- packages/fs/tool-fs/package.json | 3 +- packages/fs/tool-fs/src/index.ts | 49 +++++++++++++- packages/fs/tool-fs/src/read-render.ts | 27 ++++---- packages/fs/tool-fs/src/read.ts | 44 ++++++++---- packages/fs/tool-fs/tests/read-render.spec.ts | 24 +++++-- packages/fs/tool-fs/tests/tools.spec.ts | 67 +++++++++++++++++++ packages/fs/tool-fs/tsconfig.json | 1 + packages/hooks/hook-protocol/src/events.ts | 12 ++++ packages/hooks/hook-protocol/src/index.ts | 2 +- .../hooks/hook-protocol/tests/events.spec.ts | 19 +++++- packages/hooks/hooks-claude/README.md | 1 + packages/hooks/hooks-claude/src/index.ts | 14 ++-- .../hooks/hooks-claude/tests/coverage.spec.ts | 17 ++++- packages/hooks/hooks-codex/README.md | 1 + packages/hooks/hooks-codex/src/index.ts | 13 ++-- .../hooks/hooks-codex/tests/coverage.spec.ts | 17 ++++- .../session-persistence-sqlite/README.md | 3 +- .../session-persistence-sqlite/src/index.ts | 21 ++++-- .../session-persistence-sqlite/src/schema.ts | 21 ++++-- .../tests/sqlite.spec.ts | 50 ++++++++++---- packages/subagent/subagent-acp/README.md | 2 + packages/subagent/subagent-acp/src/index.ts | 32 ++++++++- packages/subagent/subagent-acp/src/run.ts | 37 +++++----- .../subagent-acp/tests/subagent-acp.spec.ts | 18 ++++- packages/web/tool-web/README.md | 1 + packages/web/tool-web/src/index.ts | 22 +++++- packages/web/tool-web/src/search.ts | 14 ++-- packages/web/tool-web/tests/tool-web.spec.ts | 46 +++++++++++++ pnpm-lock.yaml | 3 + 45 files changed, 592 insertions(+), 171 deletions(-) diff --git a/docs/core-data-structures/web.md b/docs/core-data-structures/web.md index 1adde3bd75..42797a8d99 100644 --- a/docs/core-data-structures/web.md +++ b/docs/core-data-structures/web.md @@ -10,7 +10,7 @@ Search and fetch share no request schema and no business logic, but they are del ## Search request and result -The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `WEB_SEARCH_MAX_RESULTS`, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. +The model-facing tool argument is just a `query`; `maxResults` is a consumer-owned bound (`dsh-tool-web`'s `searchMaxResults` config, default `8`) passed through the seam and enforced on the way back — if a provider over-returns, the seam truncates `sources[]` and sets `truncated`. ```ts type-equiv interface WebSearchRequest { diff --git a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md index 55ada9d576..833cbeebb5 100644 --- a/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-06-24-web-capability-seam.md @@ -175,7 +175,7 @@ The first `web_search` model-facing tool should be small. The only model-facing - `query`: required string. -`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — a default of `8` (aligning with OpenCode's Exa default), as an exported constant mirroring `dsh-tool-fs`'s `READ_LIMIT` / `GREP_LIMIT` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam. +`max_results` is NOT exposed to the model in the first version. It is a `dsh-tool-web`-layer decision: the tool sets the result bound — the `searchMaxResults` plugin config, default `8` (aligning with OpenCode's Exa default), mirroring `dsh-tool-fs`'s `readLimit` — and passes it to the seam as `maxResults` on the `WebSearchRequest`. Keeping it off the model schema means the model just asks a question and the product controls how much context comes back; the field can be promoted to a model-facing argument later without breaking the seam. `maxResults` flows tool → seam → provider, and the bound is enforced on the way back: diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md b/docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md index 0a4bc14d89..604c1774d5 100644 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md +++ b/docs/rfc/proposed/simplification/2026-07-04-prune-write-only-fs-surface.md @@ -6,7 +6,7 @@ Status: proposed The [fs seam split](../../implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) moved read routing and policy out of the backend into `dsh-tool-fs` and `dsh-fs-policy`. Four pieces of surface kept the pre-split shape — populated on every call, read by nobody: -1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's is dead, and the knob's JSDoc claims a "read routing" override that does not exist. +1. **`STREAM_MIN_SIZE` + `FsIoInternals.streamMinSize` in `dsh-fs-local`** — *already removed by the no-hardcoded-tunables audit (the routing bound became `dsh-tool-fs`'s `readStreamMinSize` config); listed here for the record of the full prune, no work remains.* Originally (`packages/fs/fs-local/src/fsio.ts`, re-exported from `packages/fs/fs-local/src/index.ts`): zero readers anywhere, including fs-local's own source and tests. The backend has no read routing — `readWholeText`/`streamWholeText` are separate primitives the caller chooses between — and the real routing constant lives in the consumer (`packages/fs/tool-fs/src/read.ts`, compared against `info.size`). Two mirrors of the 10 MiB fact; the backend's is dead, and the knob's JSDoc claims a "read routing" override that does not exist. 2. **`FsTarget.inputPath`** (`packages/fs/fs/src/types.ts`): every backend and every test fake must fabricate a "diagnostics only" value with zero production readers — the policy plugin and every error message use `targetKey`/`displayPath`. The `listDir` producer exposes the semantic wobble: directory children get the bare entry name, which was nobody's "input". 3. **`FsEditOutcome.replacements` + `.replaceAll`** (`packages/fs/fs/src/types.ts`): `replacements` has zero production readers (the single-match policy itself stays — it is enforced by the `FS_AMBIGUOUS_EDIT`/`FS_EDIT_NOT_FOUND` throws inside the backend, whose error message keeps the internal count); `replaceAll` is read only by `formatEditOutput` in `packages/fs/tool-fs/src/edit.ts` — as an echo of the `replace_all` argument the tool already holds. Shrunk, `FsEditOutcome` becomes `{ version, before, after }`, parallel to `FsWriteOutcome`'s genuinely backend-discovered fields. 4. **`FileReadOutcome.limit` + `.version`** (`packages/fs/tool-fs/src/read-render.ts`): populated by the read tool, but `formatReadOutput` renders `offset`/`lines`/`totalLines`/`truncatedByBytes` only, and the `fs/observed` emit uses `info.version` directly rather than the outcome copy. diff --git a/packages/bash/bash-local/README.md b/packages/bash/bash-local/README.md index 109debc24c..c13a3ab923 100644 --- a/packages/bash/bash-local/README.md +++ b/packages/bash/bash-local/README.md @@ -12,6 +12,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L timeoutMs: 120000 # default foreground timeout maxTimeoutMs: 600000 # cap for per-call overrides maxOutputBytes: 64000 # per-stream in-memory cap; overflow spills to disk + graceMs: 3000 # SIGTERM→SIGKILL escalation grace on kills ``` ## Behavior (and where it came from) @@ -19,7 +20,7 @@ Local-subprocess implementation of the `@deepseek-ai/dsh-bash` executor seam: `L Design surveyed against the bash tools of Claude Code, OpenCode, Codex, and pi; the notable choices: - **Spawn per call, no shell state** — every call is a fresh non-login `bash -c` (deterministic; no rc files). All four surveyed tools spawn per call. `XXX(stateful-shell)` in `src/run.ts` records the two proven stateful designs (Claude Code's cwd-only persistence; Codex's PTY exec sessions) for when real workflows demand them. -- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after a 3s grace (OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. +- **Process-group kills with escalation** — children are spawned `detached` (own process group); kills send SIGTERM to the group, then SIGKILL after the `graceMs` grace (default 3s — OpenCode's escalation; pipelines and subshells die with the parent). ESRCH is tolerated; daemons that re-parent away from the group can still survive — same caveat as the surveyed tools. - **Tail-keep truncation + spill files** — output beyond `maxOutputBytes` keeps the in-memory TAIL (errors/results cluster at the end — pi/OpenCode rationale) while the FULL stream is appended to a temp file whose path is reported when available. If the final spill close reports a delayed writeback failure, the executor still returns the tail but withholds the path rather than advertising a possibly incomplete file. - **Model-friendly env + credential scrub** — `process.env` minus credential-shaped vars (`*KEY*`/`*SECRET*`/`*TOKEN*`), then `NO_COLOR=1 TERM=dumb PAGER=cat GIT_PAGER=cat` (Codex's hardcoded set) so pagers and ANSI color don't garble results. This scrub is the security control that keeps the harness's *ambient* credentials out of a spawned command. A spec's `env` is merged LAST (after the scrub), so a caller's explicit entry — a value it already holds — wins even on a credential-shaped name. The spec's `stdin`, when supplied, is written to the child and closed; with none supplied, fd 0 is `/dev/null` — the exact pre-seam default, so a command that probes stdin's file type is unaffected. Both `env`/`stdin` are set by in-process plugins (the hooks bridges); the model-facing tool doesn't expose them. See [the bash-stdin-env RFC](../../../docs/rfc/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-surface.md). - **Background tasks** — `start()` returns immediately, no timeout applies (Claude Code detaches timeouts when backgrounding), `readOutput()` is incremental with whole-stream byte offsets, and disposal kills everything. The spec's opaque `owner` token is stored on the tracked task and returned by `ownerOf(id)` — the executor never interprets it (the consumer's access policy does), and because it lives with the task here it survives a `tool-bash` HMR reload. diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index af4c47e71e..6df7b3da7b 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -17,7 +17,7 @@ import { Context } from 'cordis' import z from 'schemastery' import { BashExecutor, BashTaskId } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashRunResult, BashTask, BashTaskRead, OwnerToken } from '@deepseek-ai/dsh-bash' -import { runBash } from './run.ts' +import { DEFAULT_GRACE_MS, runBash } from './run.ts' import type { RunInternals, RunningBash } from './run.ts' export { DEFAULT_GRACE_MS, ENV_OVERRIDES, killGroup, OutputCollector, runBash } from './run.ts' @@ -33,6 +33,8 @@ export interface Config { maxTimeoutMs?: number /** Per-stream in-memory output cap; overflow spills to a temp file. */ maxOutputBytes?: number + /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + graceMs?: number } /** The shape after schemastery applied the defaults (cwd has none). */ @@ -57,7 +59,7 @@ interface TrackedTask extends BashTask { * Local-subprocess bash executor. Defaults follow the agent-tool survey * consensus: 120s default / 600s max timeout (Claude Code, OpenCode), 64KB * in-memory output with full-stream spill files (pi, OpenCode), - * process-group SIGTERM→SIGKILL kills (OpenCode). + * process-group SIGTERM→SIGKILL kills with a 3s grace (OpenCode). */ export class LocalBashExecutor extends BashExecutor { static Config: z<Config> = z.object({ @@ -65,11 +67,12 @@ export class LocalBashExecutor extends BashExecutor { timeoutMs: z.number().default(120_000), maxTimeoutMs: z.number().default(600_000), maxOutputBytes: z.number().default(64_000), + graceMs: z.number().default(DEFAULT_GRACE_MS), }) private tasks = new Map<BashTaskId, TrackedTask>() private nextTaskId = 1 - /** Test seam: timer/spill knobs forwarded to runBash. */ + /** Test seam: spill knobs forwarded to runBash. */ internals: RunInternals = {} /** Validated config (schemastery applied the defaults before construction). */ @@ -83,6 +86,7 @@ export class LocalBashExecutor extends BashExecutor { assertPositiveFinite('timeoutMs', this.config.timeoutMs) assertPositiveFinite('maxTimeoutMs', this.config.maxTimeoutMs) assertPositiveFinite('maxOutputBytes', this.config.maxOutputBytes) + assertPositiveFinite('graceMs', this.config.graceMs) ctx.effect(() => async () => { // Kill every live process group and WAIT for the processes to close so // nothing outlives the fiber (HMR safety) — a TERM-trapping child is @@ -132,6 +136,7 @@ export class LocalBashExecutor extends BashExecutor { cwd: spec.workdir, timeoutMs: spec.timeoutMs, maxOutputBytes: this.config.maxOutputBytes, + graceMs: this.config.graceMs, signal: spec.signal, stdin: spec.stdin, env: spec.env, @@ -150,6 +155,7 @@ export class LocalBashExecutor extends BashExecutor { cwd: spec.workdir, timeoutMs: 0, maxOutputBytes: this.config.maxOutputBytes, + graceMs: this.config.graceMs, signal: spec.signal, stdin: spec.stdin, env: spec.env, diff --git a/packages/bash/bash-local/src/run.ts b/packages/bash/bash-local/src/run.ts index 023ea0e3d1..489d380787 100644 --- a/packages/bash/bash-local/src/run.ts +++ b/packages/bash/bash-local/src/run.ts @@ -73,6 +73,8 @@ export interface SpawnSpec { timeoutMs: number /** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */ maxOutputBytes: number + /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */ + graceMs: number /** Abort signal — kills the process group when fired. */ signal?: AbortSignal | undefined /** @@ -100,15 +102,13 @@ export interface SpawnOutcome { stderr: CollectedOutput } -/** Injectable knobs so tests can exercise escalation/spill without long waits. */ +/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */ export interface RunInternals { - /** Grace period between SIGTERM and SIGKILL on the process group. */ - graceMs?: number /** Directory for spill files (defaults to the OS temp dir). */ spillDir?: string } -/** Default SIGTERM→SIGKILL grace period (matches OpenCode's 3s). */ +/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */ export const DEFAULT_GRACE_MS = 3_000 let spillCounter = 0 @@ -292,7 +292,6 @@ export interface RunningBash { * no inherited shell state); revisit when real workflows demand it. */ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash { - const graceMs = internals.graceMs ?? DEFAULT_GRACE_MS const spillDir = internals.spillDir ?? privateSpillDir() if (spec.signal?.aborted) { @@ -331,7 +330,7 @@ export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningB const kill = (): void => { if (graceTimer !== undefined) return // escalation already in flight killGroup(pid, 'SIGTERM') - graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, graceMs) + graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs) } if (spec.timeoutMs > 0) { diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index cf6d1c267e..ce89b2a0ae 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -11,9 +11,10 @@ const spillDir = mkdtempSync(join(tmpdir(), 'dsh-bash-exec-spec-')) async function setup(config: ConstructorParameters<typeof LocalBashExecutor>[1] = {}) { const ctx = new Context() - await ctx.plugin(LocalBashExecutor, config) + // A short kill grace via the REAL config path, so escalation tests stay fast. + await ctx.plugin(LocalBashExecutor, { graceMs: 200, ...config }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } return { ctx, bash } } @@ -80,12 +81,22 @@ describe('LocalBashExecutor.run', () => { await expect(setup({ timeoutMs: Number.NaN })).rejects.toThrow(/timeoutMs/) await expect(setup({ maxTimeoutMs: 0 })).rejects.toThrow(/maxTimeoutMs/) await expect(setup({ maxOutputBytes: -1 })).rejects.toThrow(/maxOutputBytes/) + await expect(setup({ graceMs: 0 })).rejects.toThrow(/graceMs/) const { bash } = await setup() expect(() => bash.resolve({ command: 'true', timeoutMs: Number.NaN })).toThrow(/request\.timeoutMs/) expect(() => bash.resolve({ command: 'true', timeoutMs: -1 })).toThrow(/request\.timeoutMs/) }) + it('kill escalation uses the configured graceMs (a TERM-trapping task dies by SIGKILL)', async () => { + const { bash } = await setup() // setup pins graceMs: 200 via config + const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' })) + await new Promise(resolve => setTimeout(resolve, 100)) + bash.kill(task.id) + await task.done + expect(task.signal).toBe('SIGKILL') + }) + it('per-call timeout takes precedence under the cap and kills on expiry', async () => { const { bash } = await setup({ timeoutMs: 60_000 }) const result = await bash.run(bash.resolve({ command: 'sleep 60', timeoutMs: 100 })) @@ -271,9 +282,9 @@ describe('LocalBashExecutor background tasks', () => { it('disposing with already-finished tasks only kills the running ones', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, {}) + const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } const finished = bash.start(bash.resolve({ command: 'true' })) await finished.done @@ -288,9 +299,9 @@ describe('LocalBashExecutor background tasks', () => { it('disposing the executor fiber kills running tasks (no orphans)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, {}) + const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } const listener = vi.fn() bash.onTaskDone(listener) @@ -337,9 +348,9 @@ describe('review fixes: lifecycle hardening', () => { it('dispose AWAITS a TERM-trapping process (SIGKILL escalation included)', async () => { const ctx = new Context() - const fiber = await ctx.plugin(LocalBashExecutor, {}) + const fiber = await ctx.plugin(LocalBashExecutor, { graceMs: 200 }) const bash = ctx.bash as LocalBashExecutor - bash.internals = { spillDir, graceMs: 200 } + bash.internals = { spillDir } const task = bash.start(bash.resolve({ command: 'trap \'\' TERM; sleep 60' })) await new Promise(resolve => setTimeout(resolve, 100)) diff --git a/packages/bash/bash-local/tests/run.spec.ts b/packages/bash/bash-local/tests/run.spec.ts index 3a1ff7c2c5..d2888e2fee 100644 --- a/packages/bash/bash-local/tests/run.spec.ts +++ b/packages/bash/bash-local/tests/run.spec.ts @@ -28,6 +28,7 @@ function spec(command: string, overrides: Partial<Parameters<typeof runBash>[0]> cwd: process.cwd(), timeoutMs: 0, maxOutputBytes: 64_000, + graceMs: 3_000, ...overrides, } } @@ -106,7 +107,7 @@ describe('runBash', () => { }) it('escalates to SIGKILL when SIGTERM is trapped', async () => { - const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60'), { graceMs: 200 }) + const running = runBash(spec('trap \'\' TERM; echo ready; sleep 60', { graceMs: 200 })) await waitForStdout(running, 'ready\n') running.kill() const result = await running.done diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 82b69fb133..a01bdc0c86 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -21,8 +21,8 @@ async function setup() { await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) return ctx } @@ -184,8 +184,8 @@ describe('bash tool', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const result = await call(ctx, 'bash', { command: 'for i in $(seq 1 100); do printf "line-%04d\\n" $i; done', description: 'test command' }) expect(text(result)).toContain('[output truncated; full output: ') @@ -316,8 +316,8 @@ describe('background tools', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { maxOutputBytes: 100, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } await ctx.plugin(ToolBash) const started = await call(ctx, 'bash', { command: 'for i in $(seq 1 200); do printf "line-%04d\\n" $i; done', description: 'test command', run_in_background: true }) @@ -568,8 +568,8 @@ describe('background task ownership (cross-session isolation)', () => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) - await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - ;(ctx.bash as LocalBashExecutor).internals = { spillDir, graceMs: 200 } + await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000, graceMs: 200 }) + ;(ctx.bash as LocalBashExecutor).internals = { spillDir } const fiber = await ctx.plugin(ToolBash) const a = fakeAgent('sess-a') diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index c195ae42fb..cbd1146634 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -32,6 +32,7 @@ Every knob is **required** except `auto` — there is no concrete data yet to ju | `maxTokens` | yes | Provider generation cap for the summarization call; may include reasoning tokens. | | `compactionRetries` | yes | Extra compaction attempts after the first if the compacted surface remains over threshold. | | `auto` | no (default `true`) | Register the `agent/pre-step` auto-compaction listener. Set `false` for manual-only. | +| `charsPerToken` | no (default `4`) | Token-estimator text density (estimated tokens = chars / `charsPerToken`; may be fractional). The default suits English text; CJK-heavy deployments should set ~1-2 or the estimate undershoots several-fold and compaction fires too late. | ## Usage diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index f53dace461..26c593a47f 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -148,9 +148,11 @@ function finishError(finish: FinishReason): Error | undefined { } /** - * Basic, dependency-light compaction backend. Defaults target a 128K context - * window, compacting at 80% utilization and retaining ~20K tokens of recent - * context. + * Basic, dependency-light compaction backend: estimates the surface's token + * footprint, summarizes the stale prefix through the model, and shadows it + * behind a durable checkpoint. Every threshold/budget knob is required config + * ({@link BasicCompactConfig}); the estimator's text density is the + * `charsPerToken` knob. */ export class BasicCompactService extends CompactService { static inject = ['llm'] @@ -207,24 +209,27 @@ export class BasicCompactService extends CompactService { // ---- Token estimation (overridable hooks) ---- - // TODO: char/4 is a coarse heuristic. Replace with an exact count — a real - // tokenizer, or the provider's post-response `usage` (input tokens) fed back - // as a correction — so threshold decisions match the model's actual budget. + // TODO: chars/charsPerToken is a coarse heuristic. Replace with an exact + // count — a real tokenizer, or the provider's post-response `usage` (input + // tokens) fed back as a correction — so threshold decisions match the + // model's actual budget. /** - * Estimate the token count of content blocks — char/4 with per-block - * overhead. Override in a subclass to plug in a real tokenizer. + * Estimate the token count of content blocks — chars divided by the + * `charsPerToken` config, with per-block overhead. Override in a subclass to + * plug in a real tokenizer. */ estimateContentTokens(blocks: readonly ContentBlock[]): number { + const { charsPerToken } = this.config let tokens = 0 for (const block of blocks) { switch (block.type) { case 'text': case 'reasoning': - tokens += Math.ceil(block.text.length / 4) + BLOCK_OVERHEAD + tokens += Math.ceil(block.text.length / charsPerToken) + BLOCK_OVERHEAD break case 'tool-call': - tokens += Math.ceil(block.name.length / 4) - + Math.ceil(block.arguments.length / 4) + tokens += Math.ceil(block.name.length / charsPerToken) + + Math.ceil(block.arguments.length / charsPerToken) + BLOCK_OVERHEAD break case 'tool-result': @@ -236,7 +241,7 @@ export class BasicCompactService extends CompactService { default: // Unknown block types (merge-extensible ContentBlockMap): // estimate conservatively via JSON stringify. - tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / 4) + tokens += BLOCK_OVERHEAD + Math.ceil(JSON.stringify(block).length / charsPerToken) } } return tokens @@ -266,7 +271,7 @@ export class BasicCompactService extends CompactService { total += this.estimateContentTokens(msg.content) total += ROLE_OVERHEAD } - if (systemPrompt) total += Math.ceil(systemPrompt.length / 4) + if (systemPrompt) total += Math.ceil(systemPrompt.length / this.config.charsPerToken) return total } diff --git a/packages/compact/compact-basic/src/types.ts b/packages/compact/compact-basic/src/types.ts index 98195d8883..2f10084ac3 100644 --- a/packages/compact/compact-basic/src/types.ts +++ b/packages/compact/compact-basic/src/types.ts @@ -10,10 +10,12 @@ */ /** - * Backend configuration. Every knob is REQUIRED except `auto`: there is no - * concrete data yet to justify default thresholds/budgets, so a consumer must - * state each value explicitly rather than inherit a guessed default. `auto` - * alone defaults to `true` (auto-compaction is the intended posture). + * Backend configuration. Every knob is REQUIRED except `auto` and + * `charsPerToken`: there is no concrete data yet to justify default + * thresholds/budgets, so a consumer must state each value explicitly rather + * than inherit a guessed default. `auto` alone defaults to `true` + * (auto-compaction is the intended posture), and `charsPerToken` defaults to + * the English-text heuristic its estimator was calibrated on. */ export interface BasicCompactConfig { /** Context window size in tokens. */ @@ -30,13 +32,21 @@ export interface BasicCompactConfig { compactionRetries: number /** Enable automatic compaction on the `agent/pre-step` seam (default true). */ auto?: boolean + /** + * Text density for the token estimator: estimated tokens = chars / + * `charsPerToken`. Defaults to 4 (typical English text). A CJK-heavy + * deployment should set ~1-2 — CJK runs at roughly 1-2 chars per token, so + * the default UNDERestimates several-fold and compaction fires far too late. + * May be fractional. + */ + charsPerToken?: number } -/** Resolved config with `auto` defaulted. */ +/** Resolved config with `auto` and `charsPerToken` defaulted. */ export type ResolvedConfig = Required<BasicCompactConfig> /** - * Default `auto` when unset and reject nonsensical numeric knobs. + * Default `auto`/`charsPerToken` when unset and reject nonsensical numeric knobs. * * Convergence is not a static config invariant: provider generation caps can be * spent on hidden or surfaced reasoning tokens, and the model may emit a summary @@ -46,13 +56,14 @@ export type ResolvedConfig = Required<BasicCompactConfig> * throwing if the surface still exceeds the threshold. */ export function resolveConfig(config: BasicCompactConfig): ResolvedConfig { - const resolved: ResolvedConfig = { auto: true, ...config } + const resolved: ResolvedConfig = { auto: true, charsPerToken: 4, ...config } assertPositiveInteger('contextWindow', resolved.contextWindow) assertRatio('thresholdRatio', resolved.thresholdRatio) assertNonNegativeInteger('retainTokens', resolved.retainTokens) assertPositiveInteger('maxTokens', resolved.maxTokens) assertNonNegativeInteger('compactionRetries', resolved.compactionRetries) + assertPositiveFinite('charsPerToken', resolved.charsPerToken) if (typeof resolved.summarizationModel !== 'string') { throw new Error('BasicCompactConfig: summarizationModel must be a string.') } @@ -74,6 +85,12 @@ function assertNonNegativeInteger(name: string, value: number): void { } } +function assertPositiveFinite(name: string, value: number): void { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { + throw new Error(`BasicCompactConfig: ${name} (${value}) must be a positive finite number.`) + } +} + function assertRatio(name: string, value: number): void { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0 || value > 1) { throw new Error(`BasicCompactConfig: ${name} (${value}) must be a number in (0, 1].`) diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index 1929e656be..7d5d67b6db 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -820,6 +820,19 @@ describe('BasicCompactService token estimation (char/4 heuristic)', () => { const svc = new BasicCompactService(new Context(), cfg({ auto: false })) expect(svc.estimateContentTokens([])).toBe(0) }) + + it('honors a configured charsPerToken (fractional densities included)', () => { + // 'this is a somewhat longer text block' = 36 chars. + const blocks: ContentBlock[] = [{ type: 'text', text: 'this is a somewhat longer text block' }] + // charsPerToken 2: ceil(36/2)+4 = 22 — a CJK-density config doubles the estimate. + const dense = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 2 })) + expect(dense.estimateContentTokens(blocks)).toBe(22) + // Fractional density is legal: ceil(36/1.5)+4 = 28. + const fractional = new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 1.5 })) + expect(fractional.estimateContentTokens(blocks)).toBe(28) + // The system-prompt term scales with the same knob: 36-char prompt at density 2 → ceil(36/2) = 18. + expect(dense.estimateTokens([], 'this is a somewhat longer text block')).toBe(18) + }) }) describe('BasicCompactService HMR safety', () => { @@ -862,6 +875,10 @@ describe('BasicCompactService config validation', () => { )).toThrow(/summarizationModel must be a string/) expect(() => new BasicCompactService(new Context(), cfg({ auto: 'no' } as unknown as Partial<BasicCompactConfig>))) .toThrow(/auto must be a boolean/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: 0 }))) + .toThrow(/charsPerToken .* positive finite number/) + expect(() => new BasicCompactService(new Context(), cfg({ auto: false, charsPerToken: Number.NaN }))) + .toThrow(/charsPerToken .* positive finite number/) }) it('accepts a large retain budget because convergence is enforced dynamically', () => { diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 980cb4764d..64754b2dd4 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -26,9 +26,6 @@ import { basename, dirname, join, resolve } from 'node:path' import { TextDecoder } from 'node:util' import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' -/** Files at or above this size stream their text; smaller files read whole. */ -export const STREAM_MIN_SIZE = 10 * 1024 * 1024 - const BINARY_SAMPLE_BYTES = 8192 function isENOENT(error: unknown): boolean { @@ -85,13 +82,10 @@ function versionOf(info: Stats): FsVersion { } /** - * Test seam: lets specs force the streaming read path (via a small - * `streamMinSize`) and pin the temp-file name (to prove exclusive-open - * behavior) without a 10 MB fixture or a name race. + * Test seam: lets specs pin the temp-file name (to prove exclusive-open + * behavior) without a name race. */ export interface FsIoInternals { - /** Override {@link STREAM_MIN_SIZE} for read routing. */ - streamMinSize?: number /** Override the generated private staging-dir name (relative to the target dir). */ tempDirName?: (writePath: string) => string /** Override the generated temp-file name (relative to the private staging dir). */ diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index af74847ed1..abd8d047e6 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -41,7 +41,6 @@ import { import type { FsIoInternals } from './fsio.ts' export { - STREAM_MIN_SIZE, applyLiteralEdit, listDirectory, probe, diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index dacef45590..fedd1ff7a2 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -11,11 +11,22 @@ await ctx.plugin(ToolFs) // this package — re `@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. +## Config + +All keys are optional; the defaults are the shipped read caps. + +| Key | Default | Meaning | +|---|---|---| +| `readLimit` | `2000` | Default and maximum lines returned by one `read` call (the tool schema advertises it as the `limit` default). | +| `readMaxLineLength` | `2000` | Characters kept per line before truncation (the suffix names the cap). | +| `readMaxBytes` | `51200` | Byte cap on one `read` call's selected lines; overflow ends the window with a "capped" footer. | +| `readStreamMinSize` | `10485760` | Files at or above this size (or with unknown size) stream instead of loading whole into memory. | + ## Tools (schemas per [the filesystem tool schemas RFC](../../../docs/rfc/implemented/feature/2026-06-17-filesystem-tool-schemas.md)) | Tool | Arguments | Behavior | |---|---|---| -| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at 2000 lines. | +| `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index a7f71ce6fa..7e7b78aa38 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -22,7 +22,8 @@ ], "license": "BSD-3-Clause", "dependencies": { - "diff": "^9.0.0" + "diff": "^9.0.0", + "schemastery": "^3.18.0" }, "peerDependencies": { "@deepseek-ai/dsh-fs": "^0.0.1", diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 0aaa0c1a7a..5cc87ba597 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -23,11 +23,14 @@ */ import type { Context } from 'cordis' -import { applyReadTool } from './read.ts' +import z from 'schemastery' +import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' +import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' export { READ_LIMIT, STREAM_MIN_SIZE, applyReadTool, parseReadArgs } from './read.ts' +export type { ReadToolCaps } from './read.ts' export { applyWriteTool, formatWriteOutput, parseWriteArgs } from './write.ts' export { applyEditTool, formatEditOutput, parseEditArgs } from './edit.ts' export { READ_MAX_BYTES, READ_MAX_LINE_LENGTH, buildWindow, formatReadOutput } from './read-render.ts' @@ -41,9 +44,49 @@ export const name = 'tool-fs' /** Services required by the filesystem tool suite. */ export const inject = ['tools', 'fs', 'systemPrompt'] +/** Plugin config (all optional — `Config` supplies the defaults). */ +export interface Config { + /** Default and maximum number of lines returned by one `read` call. */ + readLimit?: number + /** Maximum characters returned for a single line before truncation. */ + readMaxLineLength?: number + /** Maximum bytes returned for the selected lines of one `read` call. */ + readMaxBytes?: number + /** Files at or above this size stream instead of loading whole into memory. */ + readStreamMinSize?: number +} + +export const Config: z<Config> = z.object({ + readLimit: z.number().default(READ_LIMIT), + readMaxLineLength: z.number().default(READ_MAX_LINE_LENGTH), + readMaxBytes: z.number().default(READ_MAX_BYTES), + readStreamMinSize: z.number().default(STREAM_MIN_SIZE), +}) + +/** The shape after schemastery applied the defaults. */ +type ResolvedConfig = Required<Config> + +/** A read cap must be a positive finite number to bound output and memory. */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`tool-fs: ${name} must be a positive finite number`) + } +} + /** Register the full `read`/`write`/`edit` filesystem tool suite. */ -export function apply(ctx: Context): void { - applyReadTool(ctx) +export function apply(ctx: Context, config: Config): void { + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveFinite('readLimit', resolved.readLimit) + assertPositiveFinite('readMaxLineLength', resolved.readMaxLineLength) + assertPositiveFinite('readMaxBytes', resolved.readMaxBytes) + assertPositiveFinite('readStreamMinSize', resolved.readStreamMinSize) + applyReadTool(ctx, { + limit: resolved.readLimit, + maxLineLength: resolved.readMaxLineLength, + maxBytes: resolved.readMaxBytes, + streamMinSize: resolved.readStreamMinSize, + }) applyWriteTool(ctx) applyEditTool(ctx) } diff --git a/packages/fs/tool-fs/src/read-render.ts b/packages/fs/tool-fs/src/read-render.ts index 97a1384792..ba0f01f214 100644 --- a/packages/fs/tool-fs/src/read-render.ts +++ b/packages/fs/tool-fs/src/read-render.ts @@ -19,21 +19,22 @@ import { FsError } from '@deepseek-ai/dsh-fs' import type { FsVersion } from '@deepseek-ai/dsh-fs' -/** Maximum characters returned for a single line. */ +/** Default maximum characters returned for a single line (the `readMaxLineLength` config). */ export const READ_MAX_LINE_LENGTH = 2000 -/** Maximum bytes returned for selected file lines. */ +/** Default maximum bytes returned for selected file lines (the `readMaxBytes` config). */ export const READ_MAX_BYTES = 50 * 1024 -const READ_MAX_LINE_SUFFIX = `... (line truncated to ${READ_MAX_LINE_LENGTH} chars)` -const LINE_BUFFER_CAP = READ_MAX_LINE_LENGTH + 1 - /** Resolved read window. The consumer applies its defaults/caps before calling. */ export interface ReadWindow { /** 1-based first line to return. */ offset: number /** Maximum number of lines to return. */ limit: number + /** Maximum characters returned for a single line; overflow is truncated with a suffix. */ + maxLineLength: number + /** Maximum bytes of selected output; overflow stops the scan and marks `truncatedByBytes`. */ + maxBytes: number } /** One line returned from a text file. */ @@ -82,8 +83,8 @@ function newAccumulator(): WindowAccumulator { return { lines: [], totalLines: 0, outputBytes: 0, truncatedByBytes: false, done: false } } -function truncateLine(line: string): string { - return line.length > READ_MAX_LINE_LENGTH ? `${line.substring(0, READ_MAX_LINE_LENGTH)}${READ_MAX_LINE_SUFFIX}` : line +function truncateLine(line: string, maxLineLength: number): string { + return line.length > maxLineLength ? `${line.substring(0, maxLineLength)}... (line truncated to ${maxLineLength} chars)` : line } function lineByteSize(line: string, currentLineCount: number): number { @@ -94,9 +95,9 @@ function consumeLine(acc: WindowAccumulator, rawLine: string, request: ReadWindo acc.totalLines += 1 if (acc.totalLines < request.offset || acc.lines.length >= request.limit) return - const text = truncateLine(rawLine) + const text = truncateLine(rawLine, request.maxLineLength) const bytes = lineByteSize(text, acc.lines.length) - if (acc.outputBytes + bytes > READ_MAX_BYTES) { + if (acc.outputBytes + bytes > request.maxBytes) { acc.truncatedByBytes = true acc.done = true return @@ -121,7 +122,7 @@ function finish(acc: WindowAccumulator, request: ReadWindow, displayPath: string * Accepts an `AsyncIterable<string>` (a chunked `streamText`) or an * `Iterable<string>` (a whole-file `readText` wrapped as `[text]`), so one code * path serves both. Scans for newlines with a capped line buffer (a newline-free - * giant line is truncated, never buffered past {@link READ_MAX_LINE_LENGTH}), + * giant line is truncated, never buffered past `request.maxLineLength`), * enforces the byte cap, and throws `FS_NOT_FOUND` for an offset past EOF. */ export async function buildWindow( @@ -130,12 +131,14 @@ export async function buildWindow( displayPath: string, ): Promise<WindowResult> { const acc = newAccumulator() + // One char past the truncation point is enough to prove a line overflows. + const lineBufferCap = request.maxLineLength + 1 let lineBuffer = '' function appendToLineBuffer(segment: string): void { - if (lineBuffer.length >= LINE_BUFFER_CAP) return + if (lineBuffer.length >= lineBufferCap) return lineBuffer += segment - if (lineBuffer.length > LINE_BUFFER_CAP) lineBuffer = lineBuffer.slice(0, LINE_BUFFER_CAP) + if (lineBuffer.length > lineBufferCap) lineBuffer = lineBuffer.slice(0, lineBufferCap) } function flushLine(): void { diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index c984b53c9f..68f21231b2 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -23,12 +23,27 @@ import { buildWindow, formatReadOutput } from './read-render.ts' import type { FileReadOutcome } from './read-render.ts' import { sessionCwd } from './session-cwd.ts' -/** Default and maximum number of lines returned by one `read` call. */ +/** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ export const READ_LIMIT = 2000 -/** Files at or above this size stream; smaller files read whole into memory. */ +/** + * Default streaming threshold (the `readStreamMinSize` config): files at or + * above this size stream; smaller files read whole into memory. + */ export const STREAM_MIN_SIZE = 10 * 1024 * 1024 +/** Resolved read-tool caps — plugin config after defaulting (see `Config` in index.ts). */ +export interface ReadToolCaps { + /** Default and maximum number of lines returned by one call. */ + limit: number + /** Maximum characters returned for a single line. */ + maxLineLength: number + /** Maximum bytes returned for selected file lines. */ + maxBytes: number + /** Files at or above this size stream; smaller files read whole into memory. */ + streamMinSize: number +} + /** Validated `read` arguments after defaulting. */ interface ReadInput { filePath: string @@ -43,17 +58,17 @@ function parsePositiveInteger(value: number, name: string): number { return value } -/** Validate value constraints the schema DSL can't express. */ -export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }): ReadInput { +/** Validate value constraints the schema DSL can't express. `maxLimit` is the deployment's line cap. */ +export function parseReadArgs(args: { file_path: string; offset?: number; limit?: number }, maxLimit: number): ReadInput { if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') const offset = args.offset === undefined ? 1 : parsePositiveInteger(args.offset, 'offset') - const limit = args.limit === undefined ? READ_LIMIT : parsePositiveInteger(args.limit, 'limit') - if (limit > READ_LIMIT) throw new Error(`limit must be less than or equal to ${READ_LIMIT}`) + const limit = args.limit === undefined ? maxLimit : parsePositiveInteger(args.limit, 'limit') + if (limit > maxLimit) throw new Error(`limit must be less than or equal to ${maxLimit}`) return { filePath: args.file_path, offset, limit } } /** Register the `read` tool and its system-prompt guidance. */ -export function applyReadTool(ctx: Context): void { +export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { ctx.systemPrompt.section({ name: 'tool:read', order: 100, @@ -66,10 +81,10 @@ export function applyReadTool(ctx: Context): void { parameters: { file_path: { type: 'string', required: true, description: 'Path to read, resolved by the filesystem backend.' }, offset: { type: 'number', description: '1-based first line to return. Defaults to 1.' }, - limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${READ_LIMIT}.` }, + limit: { type: 'number', description: `Maximum number of lines to return. Defaults to ${caps.limit}.` }, }, async execute(args, exec): Promise<ContentBlock[]> { - const input = parseReadArgs(args) + const input = parseReadArgs(args, caps.limit) const cwd = sessionCwd(exec) const target = await ctx.fs.resolve(input.filePath, cwd !== undefined ? { cwd } : undefined) @@ -83,10 +98,14 @@ export function applyReadTool(ctx: Context): void { // Stream when the file is large OR size is unknown, so a size-less backend // never buffers an arbitrarily large file. - const chunks = info.size === undefined || info.size >= STREAM_MIN_SIZE + const chunks = info.size === undefined || info.size >= caps.streamMinSize ? await ctx.fs.streamText(target, exec.signal) : [await ctx.fs.readText(target, exec.signal)] - const window = await buildWindow(chunks, { offset: input.offset, limit: input.limit }, target.displayPath) + const window = await buildWindow( + chunks, + { offset: input.offset, limit: input.limit, maxLineLength: caps.maxLineLength, maxBytes: caps.maxBytes }, + target.displayPath, + ) const outcome: FileReadOutcome = { offset: input.offset, @@ -106,7 +125,8 @@ export function applyReadTool(ctx: Context): void { // appended (`Read foo.txt (5 - 8)`), `read` kind (icon), and a follow-along // location whose line is the read's offset (defaulting to 1). The window is // derived from the RAW args (offset/limit as the model passed them), NOT the - // tool's defaulted 1/READ_LIMIT, so an unbounded read shows a bare title. + // tool's defaulted 1/configured limit, so an unbounded read shows a bare + // title (and the presenter stays a pure function of args, config-free). presentCall(args): GenericCallView { const { offset, limit } = args const window = limit !== undefined && limit > 0 diff --git a/packages/fs/tool-fs/tests/read-render.spec.ts b/packages/fs/tool-fs/tests/read-render.spec.ts index b596a47465..c23ad79170 100644 --- a/packages/fs/tool-fs/tests/read-render.spec.ts +++ b/packages/fs/tool-fs/tests/read-render.spec.ts @@ -6,10 +6,11 @@ */ import { describe, expect, it } from 'vitest' -import { buildWindow, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' +import { buildWindow, READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from '@deepseek-ai/dsh-tool-fs' import type { ReadWindow } from '@deepseek-ai/dsh-tool-fs' -const READ_ALL: ReadWindow = { offset: 1, limit: 2000 } +const DEFAULT_CAPS = { maxLineLength: READ_MAX_LINE_LENGTH, maxBytes: READ_MAX_BYTES } +const READ_ALL: ReadWindow = { offset: 1, limit: 2000, ...DEFAULT_CAPS } /** Yield `text` as one chunk (whole-file read shape). */ async function* whole(text: string): AsyncIterable<string> { @@ -34,7 +35,7 @@ describe('buildWindow', () => { }) it('applies offset/limit', async () => { - const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2 }, 'f') + const result = await buildWindow(whole('one\ntwo\nthree\nfour'), { offset: 2, limit: 2, ...DEFAULT_CAPS }, 'f') expect(result.lines.map(l => l.number)).toEqual([2, 3]) expect(result.totalLines).toBe(4) }) @@ -62,7 +63,7 @@ describe('buildWindow', () => { }) it('rejects an offset past EOF', async () => { - await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1 }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(buildWindow(whole('one\ntwo'), { offset: 9, limit: 1, ...DEFAULT_CAPS }, 'f')).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) }) it('flushes a final line with no trailing newline', async () => { @@ -76,9 +77,22 @@ describe('buildWindow', () => { expect(result.totalLines).toBe(2) }) + describe('caps are per-request (the plugin config reaches the window)', () => { + it('truncates lines at a custom maxLineLength and names it in the suffix', async () => { + const result = await buildWindow(whole('abcdefghij'), { offset: 1, limit: 10, maxLineLength: 5, maxBytes: READ_MAX_BYTES }, 'f') + expect(result.lines[0]?.text).toBe('abcde... (line truncated to 5 chars)') + }) + + it('caps output at a custom maxBytes', async () => { + const result = await buildWindow(whole('aaaa\nbbbb\ncccc'), { offset: 1, limit: 10, maxLineLength: 2000, maxBytes: 9 }, 'f') + expect(result.lines.map(l => l.text)).toEqual(['aaaa', 'bbbb']) + expect(result.truncatedByBytes).toBe(true) + }) + }) + describe('chunked input (streamed read shape)', () => { it('windows identically when text arrives in small chunks', async () => { - const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1 }, 'f') + const result = await buildWindow(chunked('one\ntwo\nthree', 2), { offset: 2, limit: 1, ...DEFAULT_CAPS }, 'f') expect(result.lines).toEqual([{ number: 2, text: 'two' }]) expect(result.totalLines).toBe(3) }) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 6272ac5c9d..638ce2112b 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -495,3 +495,70 @@ describe('result-time contextual diff (meta + presentResult)', () => { expect(view).toEqual({ card: 'diff', title: 'Write a.txt', diffs: [{ path: 'a.txt', oldText: null, newText: 'y' }] }) }) }) + +describe('read caps are plugin config', () => { + async function setupWith(config: ToolFs.Config) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await ctx.plugin(FsPolicy) + await ctx.plugin(ToolFs, config) + return { ctx, fs: ctx.fs as FakeFs } + } + + it('a configured readLimit is both the default and the cap, and the schema names it', async () => { + const { ctx, fs } = await setupWith({ readLimit: 2 }) + fs.files.set('key:a.txt', 'one\ntwo\nthree\nfour') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(text(result)).toContain('(Showing lines 1-2 of 4. Use offset=3 to continue.)') + const overCap = await call(ctx, 'read', { file_path: 'a.txt', limit: 3 }) + expect(overCap.isError).toBe(true) + expect(text(overCap)).toContain('less than or equal to 2') + const readSchema = ctx.tools.schemas().find(s => s.name === 'read') + expect(JSON.stringify(readSchema)).toContain('Defaults to 2.') + }) + + it('a configured readMaxLineLength truncates lines at the configured length', async () => { + const { ctx, fs } = await setupWith({ readMaxLineLength: 4 }) + fs.files.set('key:a.txt', 'abcdefgh') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(text(result)).toContain('1: abcd... (line truncated to 4 chars)') + }) + + it('a configured readMaxBytes caps the window at the configured bytes', async () => { + const { ctx, fs } = await setupWith({ readMaxBytes: 9 }) + fs.files.set('key:a.txt', 'aaaa\nbbbb\ncccc') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(text(result)).toContain('Output capped.') + expect(text(result)).not.toContain('cccc') + }) + + it('a configured readStreamMinSize routes smaller files to the streaming path', async () => { + const { ctx, fs } = await setupWith({ readStreamMinSize: 5 }) + fs.files.set('key:a.txt', 'alpha\nbeta') + const readSpy = vi.spyOn(fs, 'readText') + const streamSpy = vi.spyOn(fs, 'streamText') + const result = await call(ctx, 'read', { file_path: 'a.txt' }) + expect(result.isError).toBe(false) + expect(streamSpy).toHaveBeenCalled() + expect(readSpy).not.toHaveBeenCalled() + }) + + it.each([ + ['readLimit', { readLimit: 0 }], + ['readMaxLineLength', { readMaxLineLength: -1 }], + ['readMaxBytes', { readMaxBytes: Number.NaN }], + ['readStreamMinSize', { readStreamMinSize: 0 }], + ] as const)('rejects a non-positive %s at load', async (name, config) => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(FakeFs) + await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive finite number`)) + }) + + it('has no default export (namespace plugin export shape)', () => { + expect('default' in ToolFs).toBe(false) + }) +}) diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index 6af16400c0..f0133b1d2b 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -8,6 +8,7 @@ "references": [ { "path": "../../../vendor/cosmokit" }, { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, { "path": "../../llm/llm" }, { "path": "../../core/tools" }, { "path": "../../core/system-prompt" }, diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index 0db75995c9..d7529b0f45 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -58,6 +58,18 @@ export function appendHookInvoked(session: Session, invocation: HookInvocation): }) } +/** + * Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed, + * `undefined` when empty, cut at `maxChars` with an ellipsis when over. The + * bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns + * the config default and passes it in. + */ +export function summarizeStderr(stderr: string, maxChars: number): string | undefined { + const t = stderr.trim() + if (t.length === 0) return undefined + return t.length > maxChars ? t.slice(0, maxChars) + '…' : t +} + /** Append a `hook/result` outcome event to `session` (pairs with a prior `hook/invoked`). */ export function appendHookResult(session: Session, record: HookResultRecord): void { session.append('hook/result', { diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index 686a1480ac..a99fd11b6f 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -34,5 +34,5 @@ export { runHook } from './runner.ts' export type { RunHookOptions, RunHookResult } from './runner.ts' export { mergeHookOutputs } from './merge.ts' export type { MergedDecision, MergedHookOutcome } from './merge.ts' -export { appendHookInvoked, appendHookResult } from './events.ts' +export { appendHookInvoked, appendHookResult, summarizeStderr } from './events.ts' export type { HookInvocation, HookResultRecord } from './events.ts' diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index f63ae2a9cb..9f705916b9 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { Session, SessionId } from '@deepseek-ai/dsh-session' -import { appendHookInvoked, appendHookResult } from '@deepseek-ai/dsh-hook-protocol' +import { appendHookInvoked, appendHookResult, summarizeStderr } from '@deepseek-ai/dsh-hook-protocol' describe('hook/* session events', () => { it('appendHookInvoked records a log-only hook/invoked (with matcher when present)', () => { @@ -59,3 +59,20 @@ describe('hook/* session events', () => { expect(result?.type === 'hook/result' && result.data.handlerId).toBe('pair-1') }) }) + +describe('summarizeStderr', () => { + it('returns undefined for empty/whitespace stderr', () => { + expect(summarizeStderr('', 500)).toBeUndefined() + expect(summarizeStderr(' \n\t ', 500)).toBeUndefined() + }) + + it('passes through a summary at or under the cap, trimmed', () => { + expect(summarizeStderr(' blocked: bad tool ', 500)).toBe('blocked: bad tool') + expect(summarizeStderr('abc', 3)).toBe('abc') + }) + + it('truncates past the cap with an ellipsis', () => { + expect(summarizeStderr('abcdef', 4)).toBe('abcd…') + expect(summarizeStderr('x'.repeat(600), 500)).toBe('x'.repeat(500) + '…') + }) +}) diff --git a/packages/hooks/hooks-claude/README.md b/packages/hooks/hooks-claude/README.md index ce1c6b090a..984a86ba18 100644 --- a/packages/hooks/hooks-claude/README.md +++ b/packages/hooks/hooks-claude/README.md @@ -13,6 +13,7 @@ const config: Config = { pluginRoot: '/path/to/plugin', // optional: replaces ${CLAUDE_PLUGIN_ROOT} in command strings projectDir: '/path/to/project', // optional: replaces ${CLAUDE_PROJECT_DIR} AND sets the hook env var; defaults to the session cwd when omitted defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none (CC default) + stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary } ``` diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 6151a11c44..263b201791 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -34,6 +34,7 @@ import { matchesMatcher, mergeHookOutputs, runHook, + summarizeStderr, type HookOutput, type MatcherGroup, type MergedHookOutcome, @@ -73,6 +74,8 @@ export interface Config { projectDir?: string /** Default per-hook timeout in ms when a hook sets none (CC default: 600000). */ defaultTimeoutMs?: number + /** Character cap for the `hook/result` event's persisted stderr summary. */ + stderrSummaryMaxChars?: number } export const Config: z<Config> = z.object({ @@ -80,6 +83,7 @@ export const Config: z<Config> = z.object({ pluginRoot: z.string(), projectDir: z.string(), defaultTimeoutMs: z.number().default(600_000), + stderrSummaryMaxChars: z.number().default(500), }) /** A stable per-handler id so an invoked/result pair correlates in the log. */ @@ -91,13 +95,6 @@ function nextHandlerId(point: string): string { /** The `{kind:'plugin'}` source stamped on every context this bridge injects. */ const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' } -/** Truncate a stderr blob for the `hook/result` summary field. */ -function summarize(stderr: string): string | undefined { - const t = stderr.trim() - if (t.length === 0) return undefined - return t.length > 500 ? t.slice(0, 500) + '…' : t -} - export function apply(ctx: Context, config: Config): void { // --- Parse the config ONCE at load. A read/parse failure is contained: the // bridge logs and registers nothing rather than crashing boot (a typo'd path @@ -119,6 +116,7 @@ export function apply(ctx: Context, config: Config): void { } const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000 + const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500 /** * Run every command hook configured for `point` whose matcher selects @@ -182,7 +180,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } if (session && opts.turn !== undefined) { - const stderrSummary = summarize(output.stderr) + const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars) appendHookResult(session, { turn: opts.turn, point, handlerId, decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index 63e2f611ce..f5091ce7a5 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -27,7 +27,7 @@ function hooks(d: string, h: unknown): string { writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') } -type HarnessOpts = { pluginRoot?: string; projectDir?: string } +type HarnessOpts = { pluginRoot?: string; projectDir?: string; stderrSummaryMaxChars?: number } async function harness(configPath: string, adapter: MockAdapter, opts: HarnessOpts = {}): Promise<Context> { const ctx = new Context() await ctx.plugin(LlmService) @@ -139,6 +139,21 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') + const path = hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: s }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'echo', {}), textResponse('done')]) + const ctx = await harness(path, adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'echo', description: 'e', parameters: {}, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]) + await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') }) }) diff --git a/packages/hooks/hooks-codex/README.md b/packages/hooks/hooks-codex/README.md index 72be33f57f..68de9a49ae 100644 --- a/packages/hooks/hooks-codex/README.md +++ b/packages/hooks/hooks-codex/README.md @@ -20,6 +20,7 @@ const config: Config = { configPath: '/path/to/.codex/hooks.json', // required model: 'deepseek-v4', // optional: stamped on every payload (Codex includes `model`) defaultTimeoutMs: 600_000, // optional: per-hook timeout when a hook sets none + stderrSummaryMaxChars: 500, // optional: char cap on the hook/result event's persisted stderr summary } ``` diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index a704a6af24..4f7d6c9ed9 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -27,6 +27,7 @@ import { matchesMatcher, mergeHookOutputs, runHook, + summarizeStderr, type HookOutput, type MatcherGroup, type MergedHookOutcome, @@ -49,12 +50,15 @@ export interface Config { model?: string /** Default per-hook timeout in ms when a hook sets none (Codex default: 600000). */ defaultTimeoutMs?: number + /** Character cap for the `hook/result` event's persisted stderr summary. */ + stderrSummaryMaxChars?: number } export const Config: z<Config> = z.object({ configPath: z.string().required(), model: z.string().default(''), defaultTimeoutMs: z.number().default(600_000), + stderrSummaryMaxChars: z.number().default(500), }) let handlerCounter = 0 @@ -64,12 +68,6 @@ function nextHandlerId(point: string): string { const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' } -function summarize(stderr: string): string | undefined { - const t = stderr.trim() - if (t.length === 0) return undefined - return t.length > 500 ? t.slice(0, 500) + '…' : t -} - export function apply(ctx: Context, config: Config): void { let parsed: CodexHookConfig = {} try { @@ -85,6 +83,7 @@ export function apply(ctx: Context, config: Config): void { } const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000 + const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500 const model = config.model ?? '' async function runPoint( @@ -140,7 +139,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } if (session && opts.turn !== undefined) { - const stderrSummary = summarize(output.stderr) + const stderrSummary = summarizeStderr(output.stderr, stderrSummaryMaxChars) appendHookResult(session, { turn: opts.turn, point, handlerId, decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 87032c98ce..861a2691e1 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -23,12 +23,12 @@ function hooks(d: string, h: unknown): string { writeFileSync(join(d, 'hooks.json'), JSON.stringify({ hooks: h })); return join(d, 'hooks.json') } -async function harness(configPath: string, adapter: MockAdapter): Promise<Context> { +async function harness(configPath: string, adapter: MockAdapter, opts: { stderrSummaryMaxChars?: number } = {}): Promise<Context> { const ctx = new Context() await ctx.plugin(LlmService); await ctx.plugin(SessionStore); await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry); await ctx.plugin(AgentRegistry); await ctx.plugin(AgentLoop, { agents: [] }) await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 }) - await ctx.plugin(HooksCodex, { configPath, model: 'm' }) + await ctx.plugin(HooksCodex, { configPath, model: 'm', ...opts }) ctx.llm.registerAdapter(['mock'], adapter) return ctx } @@ -204,6 +204,19 @@ describe('hooks-codex coverage — decision mapping paths', () => { agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) const res = events(agent).find(e => e.type === 'hook/result') expect(res?.type === 'hook/result' && res.data.stderrSummary?.endsWith('…')).toBe(true) + expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis + }) + + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { + const d = dir() + hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) + const adapter = new MockAdapter([toolCallResponse('c1', 'Bash', { command: 'x' }), textResponse('done')]) + const ctx = await harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: 40 }) + ctx.tools.register(defineTool({ name: 'Bash', description: 'b', parameters: { command: { type: 'string' } }, async execute() { return [{ type: 'text', text: 'ok' }] } })) + const agent = ctx.agentLoop.create(AgentId('a1'), { model: 'mock' }) + agent.send([{ type: 'text', text: 'go' }]); await waitForIdle(ctx, agent) + const res = events(agent).find(e => e.type === 'hook/result') + expect(res?.type === 'hook/result' && res.data.stderrSummary).toBe('x'.repeat(40) + '…') }) it('warns on a skipped async hook and a direct apply() defaults the timeout', async () => { diff --git a/packages/session-persistence/session-persistence-sqlite/README.md b/packages/session-persistence/session-persistence-sqlite/README.md index 064cc11fce..d7095633c9 100644 --- a/packages/session-persistence/session-persistence-sqlite/README.md +++ b/packages/session-persistence/session-persistence-sqlite/README.md @@ -8,7 +8,7 @@ A SQLite durable session-persistence backend — a second `SessionPersistence` i Each `SessionEvent` maps 1:1 onto a row in an `events` table `(session_id, seq, type, time, data, source_event_seqs, surface_op)` — `data` is the event payload as JSON text, so the row shape is the event verbatim (including `assistant/chunk`, keeping `seq` contiguous). The two `TEXT` columns `source_event_seqs` and `surface_op` are nullable; they store the event's optional surface-metadata fields (see [session surface](../../../docs/rfc/implemented/architecture/2026-06-18-session-surface.md)). Out-of-log metadata (`SessionHeader`) lives in a `sessions` row. A `sessions` row is written only by the first `append` — its existence is the lazy-materialization signal (`list` reports exactly the sessions that have a row), so no separate column is needed. -The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and `journal_mode = WAL`. The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). +The repo targets Node ≥ 24 (the root `engines` field), which includes the stable `node:sqlite` module. The database opens with `foreign_keys = ON` (so `ON DELETE CASCADE` drops a session's events with its row) and the configured `journal_mode` (default `wal`; pick a rollback-journal mode like `delete` on filesystems where WAL's shared-memory files do not work, e.g. network mounts). The table-layout version is stored in `PRAGMA user_version` and checked on open: a fresh database is stamped with the current `SCHEMA_VERSION`; a database written by any other, incompatible build (a non-current `user_version`, older or newer) is rejected rather than opened against an unknown layout — there is no migration (unreleased software). ## Contract semantics over rows @@ -21,6 +21,7 @@ The repo targets Node ≥ 24 (the root `engines` field), which includes the stab ```ts interface Config { path: string // SQLite database file path, or ':memory:' for an in-process DB + journalMode?: 'wal' | 'delete' | 'truncate' | 'persist' // journal_mode pragma; default 'wal' } ``` diff --git a/packages/session-persistence/session-persistence-sqlite/src/index.ts b/packages/session-persistence/session-persistence-sqlite/src/index.ts index 412c3b58fc..30387b4837 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/index.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/index.ts @@ -28,7 +28,7 @@ import { } from '@deepseek-ai/dsh-session-persistence' import type { Session, SessionEvent, SurfaceEventType, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { - openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, + type JournalMode, openDatabase, rowToMeta, scanRows, type EventRow, type SessionRow, } from './schema.ts' export { SCHEMA_VERSION } from './schema.ts' @@ -54,6 +54,13 @@ export interface Config { * dirs) on construction. */ path: string + /** + * SQLite `journal_mode` pragma. `wal` (the default) is the recorded + * durability model; pick a rollback-journal mode (`delete`/`truncate`/ + * `persist`) on filesystems where WAL's shared-memory files do not work + * (network mounts). See {@link JournalMode}. + */ + journalMode?: JournalMode } /** @@ -66,6 +73,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers static Config: z<Config> = z.object({ path: z.string().required(), + journalMode: z.union(['wal', 'delete', 'truncate', 'persist'] as const).default('wal'), }) /** @@ -83,18 +91,19 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers super(ctx) // Open the database asynchronously (the parent directory may need creating); // every hook awaits `ready` first. Opening synchronously would force a sync - // mkdir and block plugin apply. - this.ready = this.openDb(config.path) + // mkdir and block plugin apply. schemastery (static Config) has already + // filled `journalMode`; the cast records that runtime fact. + this.ready = this.openDb(config.path, (config as Required<Config>).journalMode) this.coordinator = new PersistenceCoordinator<number>(this.ctx, this) } - private async openDb(path: string): Promise<void> { + private async openDb(path: string, journalMode: JournalMode): Promise<void> { if (path !== ':memory:') { const abs = resolve(path) await mkdir(dirname(abs), { recursive: true, mode: 0o700 }) - this.db = openDatabase(abs) + this.db = openDatabase(abs, journalMode) } else { - this.db = openDatabase(path) + this.db = openDatabase(path, journalMode) } } diff --git a/packages/session-persistence/session-persistence-sqlite/src/schema.ts b/packages/session-persistence/session-persistence-sqlite/src/schema.ts index d8db0e087b..2ed6f5853c 100644 --- a/packages/session-persistence/session-persistence-sqlite/src/schema.ts +++ b/packages/session-persistence/session-persistence-sqlite/src/schema.ts @@ -45,10 +45,21 @@ export interface EventRow { surface_op: string | null } +/** + * Journal modes the backend will run under. `wal` is the default and the + * durability model the persistence ADR records; the rollback-journal modes + * (`delete`/`truncate`/`persist`) exist for filesystems where WAL's + * shared-memory files do not work (network mounts). `memory`/`off` are + * excluded: dropping journal durability silently contradicts what this + * backend promises. + */ +export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' + /** * Open the database at `path` and apply the schema + pragmas. `foreign_keys` - * makes `ON DELETE CASCADE` drop a session's events with its row; `journal_mode - * = WAL` matches the durability model the ADR records (the row shape maps 1:1 + * makes `ON DELETE CASCADE` drop a session's events with its row; the + * `journal_mode` pragma is set from the plugin's `journalMode` config (`wal` + * default — the durability model the ADR records; the row shape maps 1:1 * onto `SessionEvent`; opencode runs this exact shape on SQLite/WAL). * * The table-layout version is persisted in SQLite's `PRAGMA user_version` and @@ -66,10 +77,12 @@ export interface EventRow { * makes the version check reject both sibling v3 databases instead of opening * one against columns it does not have. */ -export function openDatabase(path: string): DatabaseSync { +export function openDatabase(path: string, journalMode: JournalMode): DatabaseSync { const db = new DatabaseSync(path) db.exec('PRAGMA foreign_keys = ON') - db.exec('PRAGMA journal_mode = WAL') + // journalMode is a closed in-code union (validated by the plugin Config), not + // user-controlled SQL — safe to interpolate (PRAGMA takes no bound params). + db.exec(`PRAGMA journal_mode = ${journalMode.toUpperCase()}`) // `PRAGMA user_version` always returns exactly one row { user_version }. const { user_version: onDisk } = db.prepare('PRAGMA user_version').get() as { user_version: number } if (onDisk !== 0 && onDisk !== SCHEMA_VERSION) { diff --git a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts index 7138718aa5..a6eda72685 100644 --- a/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session-persistence/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' +import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -53,7 +54,7 @@ runCoordinatorContract('sqlite', async (): Promise<CoordinatorFixture> => { // A row past the committed region whose `data` does not parse: scanRows // bounds the preserved prefix at it and returns its seq as tornFrom, which // the backend surfaces to the coordinator as the tornMarker to delete from. - const db = openDatabase(path) + const db = openDatabase(path, 'wal') const next = (db.prepare('SELECT COALESCE(MAX(seq), -1) + 1 AS n FROM events WHERE session_id = ?') .get(id) as { n: number }).n db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, ?, ?, ?, ?)') @@ -192,7 +193,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { await b1.ctx.sessionPersistence.append(m.id, oneTurnLog()) // seqs 0..5 await b1.dispose() // Hand-write an interrupted turn (turn/start seq 6, no turn/end). - const db = openDatabase(path) + const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') .run(m.id, 'turn/start', JSON.stringify({ turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })) db.close() @@ -204,7 +205,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { expect(loaded.events.at(-1)!.type).toBe('turn/end') // load() is mutating: the synthetic turn/end MUST be on disk so the stored log // is balanced and the cursor is truthful (contract: load closes, not defers). - const probe = openDatabase(path) + const probe = openDatabase(path, 'wal') const stored = probe.prepare('SELECT seq, type FROM events WHERE session_id = ? ORDER BY seq').all(m.id) as { seq: number; type: string }[] probe.close() expect(stored.map(r => r.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7]) @@ -237,21 +238,21 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { it('rejects opening a database whose schema version is not the current build (newer OR older)', async () => { const path = await freshDbPath() - openDatabase(path).close() // stamp user_version = SCHEMA_VERSION + openDatabase(path, 'wal').close() // stamp user_version = SCHEMA_VERSION // Bump user_version past what this build supports. - const dbNewer = openDatabase(path) + const dbNewer = openDatabase(path, 'wal') dbNewer.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`) dbNewer.close() - expect(() => openDatabase(path)).toThrow(/incompatible with this build/) + expect(() => openDatabase(path, 'wal')).toThrow(/incompatible with this build/) // A stale OLDER version (e.g. a pre-summary-drop v1 DB) is also rejected — // we do not migrate (unreleased software, no backward-compat). const olderPath = await freshDbPath() - openDatabase(olderPath).close() - const dbOlder = openDatabase(olderPath) + openDatabase(olderPath, 'wal').close() + const dbOlder = openDatabase(olderPath, 'wal') dbOlder.exec('PRAGMA user_version = 1') dbOlder.close() - expect(() => openDatabase(olderPath)).toThrow(/incompatible with this build/) + expect(() => openDatabase(olderPath, 'wal')).toThrow(/incompatible with this build/) }) it('rejects a sibling v3 database (the merge-collided version) rather than opening it against missing columns', async () => { @@ -261,11 +262,11 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // of this build's columns, so it MUST be rejected, not opened. Stamp a v3 // database and confirm the version check refuses it. const path = await freshDbPath() - openDatabase(path).close() // creates + stamps user_version = SCHEMA_VERSION (4) - const db = openDatabase(path) + openDatabase(path, 'wal').close() // creates + stamps user_version = SCHEMA_VERSION (4) + const db = openDatabase(path, 'wal') db.exec('PRAGMA user_version = 3') db.close() - expect(() => openDatabase(path)).toThrow(/schema version 3, incompatible with this build/) + expect(() => openDatabase(path, 'wal')).toThrow(/schema version 3, incompatible with this build/) }) it('a corrupt-JSON row in the uncommitted tail is discarded on load, not unloadable', async () => { @@ -281,7 +282,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { // unloadable; a torn tail must be discarded. scanRows finds the last // turn/end on the seq+type columns (never parsing tail `data`), so the // unparsable row after it bounds the preserved prefix and is deleted by load. - const db = openDatabase(path) + const db = openDatabase(path, 'wal') db.prepare('INSERT INTO events (session_id, seq, type, time, data) VALUES (?, 6, ?, 7, ?)') .run(m.id, 'turn/start', '{not valid json') db.close() @@ -370,6 +371,29 @@ describe('SessionPersistenceSqlite: edge cases', () => { await b2.dispose() }) + it('journalMode config reaches the database (default wal, rollback modes selectable)', async () => { + // :memory: databases always report journal_mode=memory, so probe file DBs. + const walPath = await freshDbPath() + const bWal = await backend(walPath) + await bWal.ctx.sessionPersistence.create(meta('jm-wal')) + expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal') + await bWal.dispose() + + const deletePath = await freshDbPath() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceSqlite, { path: deletePath, journalMode: 'delete' }) + await ctx.sessionPersistence.create(meta('jm-delete')) + // Probe through a second connection: journal_mode=delete is a per-database + // property only insofar as no WAL files exist — assert the world, not the + // backend's self-report (no -wal sidecar after writes in delete mode). + const db = openDatabase(deletePath, 'delete') + expect((db.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('delete') + db.close() + expect(existsSync(`${deletePath}-wal`)).toBe(false) + await fiber.dispose() + }) + it('HMR: a DIFFERENT session colliding with a materialized on-disk id is rejected', async () => { const path = await freshDbPath() // Instance 1 materializes a session and disposes. diff --git a/packages/subagent/subagent-acp/README.md b/packages/subagent/subagent-acp/README.md index 03990a7369..81bf886067 100644 --- a/packages/subagent/subagent-acp/README.md +++ b/packages/subagent/subagent-acp/README.md @@ -25,6 +25,8 @@ Unlike the in-process backends, the child does NOT share this cordis context — | `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<string,string> | `{}` | 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. | +| `disposeEofGraceMs` | number | `6000` | Dispose ladder tier 1: how long the child gets to quiesce on its own after stdin EOF (flush persistence, tear down its nested subprocesses) before SIGTERM. | +| `disposeGraceMs` | number | `3000` | Dispose ladder tier 2: grace between SIGTERM and the SIGKILL escalation. | ```yaml - id: subagent-acp diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index 037d32889e..cd425e763c 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -21,7 +21,7 @@ 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' +import { type AcpRunSpec, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, type PermissionPolicy, startAcpRun } from './run.ts' export const name = 'subagent-acp' export const inject = ['subagents'] @@ -52,6 +52,14 @@ export interface Config { * ambient secrets do not leak implicitly. */ env: Record<string, string> + /** + * Grace period (ms) for the child's EOF-driven quiesce on dispose — its + * window to flush persistence and tear down its own nested subprocesses + * before the parent escalates to a signal. + */ + disposeEofGraceMs?: number + /** Grace period (ms) between `SIGTERM` and the `SIGKILL` escalation on dispose. */ + disposeGraceMs?: number } export const Config: z<Config> = z.object({ @@ -61,8 +69,20 @@ export const Config: z<Config> = z.object({ cwd: z.string(), permission: z.union(['allow', 'reject'] as const).default('reject'), env: z.dict(z.string()).default({}), + disposeEofGraceMs: z.number().default(DEFAULT_DISPOSE_EOF_GRACE_MS), + disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS), }) +/** A dispose grace must be a positive finite number (it bounds the teardown wait). */ +function assertPositiveFinite(name: string, value: number): void { + if (!Number.isFinite(value) || value <= 0) { + throw new Error(`subagent-acp: ${name} must be a positive finite number`) + } +} + +/** The shape after schemastery applied the defaults (cwd has none). */ +type ResolvedConfig = Required<Omit<Config, 'cwd'>> & Pick<Config, 'cwd'> + /** * The ACP provider. Advertises NO start-time capabilities: an out-of-process * child cannot honor `outputSchema`/`maxDepth`/`toolFilter` (the service rejects @@ -71,7 +91,7 @@ export const Config: z<Config> = z.object({ 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) {} + constructor(readonly name: string, private readonly ctx: Context, private readonly config: ResolvedConfig) {} start(request: SubagentStartRequest) { const spec: AcpRunSpec = { @@ -80,6 +100,8 @@ class AcpProvider implements SubagentProvider { cwd: this.config.cwd ?? process.cwd(), permission: this.config.permission, env: this.config.env, + disposeEofGraceMs: this.config.disposeEofGraceMs, + disposeGraceMs: this.config.disposeGraceMs, 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. @@ -91,5 +113,9 @@ class AcpProvider implements SubagentProvider { } export function apply(ctx: Context, config: Config): void { - ctx.subagents.registerProvider(new AcpProvider(config.providerName, ctx, config)) + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveFinite('disposeEofGraceMs', resolved.disposeEofGraceMs) + assertPositiveFinite('disposeGraceMs', resolved.disposeGraceMs) + ctx.subagents.registerProvider(new AcpProvider(resolved.providerName, ctx, resolved)) } diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 06f7a9ece8..74291b7e65 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -73,16 +73,16 @@ export interface AcpRunSpec { /** * 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. + * its OWN nested subprocesses before the parent escalates to a signal. The + * plugin fills this from its `disposeEofGraceMs` config. */ - disposeEofGraceMs?: number + 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. + * {@link SubagentRun.dispose}. The plugin fills this from its + * `disposeGraceMs` config. */ - disposeGraceMs?: number + 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 @@ -94,19 +94,20 @@ export interface AcpRunSpec { } /** - * 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. + * Default grace for the child's EOF-driven quiesce on dispose (the + * `disposeEofGraceMs` config) — 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). */ +/** Default grace between SIGTERM and SIGKILL on dispose (the `disposeGraceMs` config; mirrors the bash executor). */ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 /** @@ -372,8 +373,8 @@ export function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpec): Su // 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 + const eofGraceMs = spec.disposeEofGraceMs + const graceMs = spec.disposeGraceMs // 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 diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 9819320ec5..e1e510d19d 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -8,7 +8,7 @@ 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' +import { acpStopReason, acpContentText, buildChildEnv, DEFAULT_DISPOSE_EOF_GRACE_MS, DEFAULT_DISPOSE_GRACE_MS, SENSITIVE_ENV_PATTERN, startAcpRun, toAcpPrompt, type AcpRunSpec } from '../src/run.ts' /** * Keyless integration tests for the ACP subagent backend. Each spawns a REAL @@ -171,7 +171,7 @@ describe('dsh-subagent-acp', () => { const run = startAcpRun( { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent, signal: controller.signal }, // `touch <sentinel>` — runs only if the process is actually spawned. - { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {} }, + { command: 'touch', args: [sentinel], cwd: tmp, permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, ) const result = await run.result expect(result.stopReason).toBe('aborted') @@ -390,7 +390,7 @@ describe('dsh-subagent-acp', () => { // 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: {} }, + { command: '/nonexistent/acp-agent-binary', args: [], cwd: process.cwd(), permission: 'reject', env: {}, disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS }, ) const result = await run.result // The seam contract: a child-level failure resolves error, never rejects. @@ -398,6 +398,16 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) + it('rejects a non-positive dispose grace at load', async () => { + for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { + const ctx = new Context() + await ctx.plugin(SubagentService) + await expect(ctx.plugin(acp, { providerName: 'acp', command: 'true', args: [], permission: 'reject', env: {}, ...bad })) + .rejects.toThrow(/subagent-acp: dispose(?:Eof)?GraceMs must be a positive finite number/) + await ctx.fiber.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) @@ -428,6 +438,8 @@ describe('dsh-subagent-acp', () => { cwd: process.cwd(), permission: 'reject', env: {}, + disposeEofGraceMs: DEFAULT_DISPOSE_EOF_GRACE_MS, + disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS, onError: (error, stopReason) => { errors.push({ message: error.message, stopReason }) }, }, ) diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index f57f38d0d5..e789720cbb 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -17,6 +17,7 @@ Each tool is registered independently; a product that wants only one disables th |---|---|---| | `search` | `true` | Register `web_search`. | | `fetch` | `true` | Register `web_fetch`. | +| `searchMaxResults` | `8` | Upper bound on sources returned by one `web_search` call (the seam truncates a longer provider list and flags it). | ```yaml - id: tool-web diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index df8029466a..c0191c023b 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -20,7 +20,7 @@ import type { Context } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-web' -import { applyWebSearchTool } from './search.ts' +import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' export { WEB_SEARCH_MAX_RESULTS, applyWebSearchTool, formatSearchOutput, parseSearchArgs, presentSearchCall } from './search.ts' @@ -38,13 +38,26 @@ export interface Config { search?: boolean /** Register `web_fetch`. Defaults to true. */ fetch?: boolean + /** Upper bound on sources returned by one `web_search` call. */ + searchMaxResults?: number } export const Config: z<Config> = z.object({ search: z.boolean().default(true), fetch: z.boolean().default(true), + searchMaxResults: z.number().default(WEB_SEARCH_MAX_RESULTS), }) +/** The shape after schemastery applies its defaults to every field. */ +type ResolvedConfig = Required<Config> + +/** The result cap must be a positive integer (it bounds a provider's source list). */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-web: ${name} must be a positive integer`) + } +} + /** * Register the enabled web tools. `search`/`fetch` default to true; a product * that wants only one disables the other in config. The tools' disposers are @@ -52,6 +65,9 @@ export const Config: z<Config> = z.object({ * teardown is needed. */ export function apply(ctx: Context, config: Config): void { - if (config.search !== false) applyWebSearchTool(ctx) - if (config.fetch !== false) applyWebFetchTool(ctx) + // schemastery (Config) has already filled every defaulted field. + const resolved = config as ResolvedConfig + assertPositiveInteger('searchMaxResults', resolved.searchMaxResults) + if (resolved.search) applyWebSearchTool(ctx, resolved.searchMaxResults) + if (resolved.fetch) applyWebFetchTool(ctx) } diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 6394d3f7e0..28e4e9a2e9 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -13,10 +13,10 @@ import type { WebSearchResult } from '@deepseek-ai/dsh-web' import type {} from '@deepseek-ai/dsh-system-prompt' /** - * Default upper bound on returned sources. Owned by the consumer (not the - * provider or model), mirroring `dsh-tool-fs`'s `READ_LIMIT`/`GREP_LIMIT`. The - * model just asks a question; the product controls how much context returns. - * The default `8` aligns with OpenCode's Exa default. + * Default upper bound on returned sources (the `searchMaxResults` config). + * Owned by the consumer (not the provider or model), mirroring `dsh-tool-fs`'s + * `READ_LIMIT`. The model just asks a question; the product controls how much + * context returns. The default `8` aligns with OpenCode's Exa default. */ export const WEB_SEARCH_MAX_RESULTS = 8 @@ -67,8 +67,8 @@ export function presentSearchCall(args: { query: string }): GenericCallView { return { card: 'generic', title: args.query, kind: 'search', rawInput: args.query } } -/** Register the `web_search` tool and its system-prompt guidance. */ -export function applyWebSearchTool(ctx: Context): void { +/** Register the `web_search` tool and its system-prompt guidance. `maxResults` is the deployment's source cap. */ +export function applyWebSearchTool(ctx: Context, maxResults: number): void { ctx.systemPrompt.section({ name: 'tool:web_search', order: 110, @@ -84,7 +84,7 @@ export function applyWebSearchTool(ctx: Context): void { async execute(args, exec): Promise<ContentBlock[]> { const input = parseSearchArgs(args) const result = await ctx.web.search( - { query: input.query, maxResults: WEB_SEARCH_MAX_RESULTS }, + { query: input.query, maxResults }, exec.signal ? { signal: exec.signal } : undefined, ) return [{ type: 'text', text: formatSearchOutput(result) }] diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index 7af1ce7c36..c253bebfef 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -15,6 +15,7 @@ import { presentFetchCall, renderBody, htmlToMarkdown, + WEB_SEARCH_MAX_RESULTS, } from '@deepseek-ai/dsh-tool-web' const available: WebProviderStatus = { available: true } @@ -279,3 +280,48 @@ describe('tool-web execution through the real registry', () => { await fiber.dispose() }) }) + +describe('searchMaxResults is plugin config', () => { + it('forwards the default cap to the seam when unconfigured', async () => { + const seen: { maxResults?: number | undefined } = {} + const provider: WebSearchProvider = { + id: 'stub-search', + status: () => available, + search: (request) => { seen.maxResults = request.maxResults; return Promise.resolve({ providerId: 'stub-search', query: 'q', sources: [], truncated: false }) }, + } + const { fiber, call } = await mountTools({ webConfig: { searchProvider: 'stub-search' }, search: provider }) + await call('web_search', { query: 'q' }) + expect(seen.maxResults).toBe(WEB_SEARCH_MAX_RESULTS) + await fiber.dispose() + }) + + it('forwards a configured cap to the seam, which enforces it', async () => { + const sources = Array.from({ length: 5 }, (_, i) => ({ url: `https://s${i}.test` })) + const provider: WebSearchProvider = { + id: 'stub-search', + status: () => available, + search: request => Promise.resolve({ providerId: 'stub-search', query: request.query, sources, truncated: false }), + } + const { fiber, call } = await mountTools({ config: { searchMaxResults: 2 }, webConfig: { searchProvider: 'stub-search' }, search: provider }) + const out = await call('web_search', { query: 'q' }) + expect(out.isError).toBe(false) + const body = out.content.map(b => b.text).join('') + expect(body).toContain('https://s1.test') + expect(body).not.toContain('https://s2.test') + expect(body).toContain('Showing the first 2 sources.') + await fiber.dispose() + }) + + it.each([ + ['zero', 0], + ['negative', -3], + ['fractional', 1.5], + ])('rejects a %s searchMaxResults at load', async (_label, value) => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + await ctx.plugin(WebService, {}) + await expect(ctx.plugin(ToolWeb, { searchMaxResults: value })) + .rejects.toThrow(/tool-web: searchMaxResults must be a positive integer/) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8045aa9cc3..a58d9cb72d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -323,6 +323,9 @@ importers: diff: specifier: ^9.0.0 version: 9.0.0 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-agent': specifier: workspace:^ From 3567d000c4516879d4359a13769174732968251b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:50:50 +0800 Subject: [PATCH 22/32] =?UTF-8?q?refactor(vocab):=20prune=20producer-less?= =?UTF-8?q?=20variants=20=E2=80=94=20cache=20hints,=20agent=20source,=20co?= =?UTF-8?q?ntinuation=20trigger?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The vocabulary maps grow by declaration merging, and the admission policy stated on TurnEndReasonMap is that a variant lands with its first emitter. Three declared items had no producer and no consumer: - CacheHint and the cache?: CacheHint fields on TextBlock/ToolResultBlock: nothing constructs a block with cache:, and neither adapter reads .cache — DeepSeek prompt caching is automatic (hints map OUT of responses, never IN). - MessageSourceMap.agent: zero constructors; the subagent backends send the parent prompt with no source (logs as user), and the envelope renderer interpolates source.kind without routing on it. - TurnTriggerMap.continuation: the loop structurally cannot emit it — continuation is further steps within a turn, never a new turn — and its only writer was an llm-replay test fixture that needed any non-message trigger (now an injection trigger). Each variant returns the day it gains a real producer, via the same merge-extensible maps. Docs updated in the same change: the MessageSourceMap paste in core.md, the TurnTriggerMap paste in session.md (manifest untouched — both symbols survive), the content-block vocabulary RFC's cache-hints consequence line, and the RFC moved to implemented/ and amended to shipped reality (the image block's own cache field had already left with the drop-image RFC). --- docs/core-data-structures/core.md | 1 - docs/core-data-structures/session.md | 1 - docs/rfc/README.md | 2 +- .../2026-06-11-content-block-vocabulary.md | 2 +- ...-prune-producerless-vocabulary-variants.md | 31 +++++++++++++++++++ ...-prune-producerless-vocabulary-variants.md | 31 ------------------- packages/core/session/src/types.ts | 1 - packages/llm/llm/src/types.ts | 6 ---- .../llm-replay/tests/llm-replay.spec.ts | 2 +- 9 files changed, 34 insertions(+), 43 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e2f3cd35f5..e8f5c05f10 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -107,7 +107,6 @@ Where a message came from is itself a merge-extensible sum type: interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } - agent: { kind: 'agent'; agentId: string } } ``` diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 900fdc54fa..2ef01596d4 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -164,7 +164,6 @@ Everything else (`turn/*`, `step/*`) is structural and does not project into a m ```ts type-equiv interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } - continuation: { kind: 'continuation' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `context/message` in a one-shot turn diff --git a/docs/rfc/README.md b/docs/rfc/README.md index f0987d2e65..0c6944bf40 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -56,7 +56,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | -| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | | [Prune write-only fields and a dead routing knob from the fs seam](proposed/simplification/2026-07-04-prune-write-only-fs-surface.md) | 2026-07-04 | | [Remove the `agent/steering` mirror emit](proposed/simplification/2026-07-04-remove-agent-steering-mirror.md) | 2026-07-04 | | [Share the app bins' boot glue instead of maintaining twin copies](proposed/simplification/2026-07-04-share-app-bin-boot-glue.md) | 2026-07-04 | @@ -120,6 +119,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | +| [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index d2d2162588..619ada6f13 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -16,6 +16,6 @@ In-session context injection (`context/message`, `steering/message`) renders as ## Consequences -- Reasoning, prefill, and cache hints have a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). +- Reasoning and prefill have a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md new file mode 100644 index 0000000000..b0fedc990e --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -0,0 +1,31 @@ +# RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violated that policy — each had no producer and no consumer, and two had not even a test: + +- **`CacheHint` and its `cache?: CacheHint` block fields** on `TextBlock`/`ToolResultBlock` (`packages/llm/llm/src/types.ts`; the image block carried a third such field, which left with it — see [the drop-image RFC](2026-07-04-drop-image-content-block.md)). Nothing constructed a block with `cache:` anywhere — src, tests, and doc pastes all came up empty — and neither adapter read `.cache`: DeepSeek prompt caching is automatic, so the adapters map `prompt_cache_hit_tokens` OUT of responses without ever sending a hint IN. This was Anthropic-style `cache_control` surface with no provider that could honor it. +- **`MessageSourceMap.agent`** (`{ kind: 'agent'; agentId: string }`, same file). Zero constructors, tests included. Its intended producer shipped without it: the subagent backends send the parent's prompt to the child with no `source`, so it logs as `{ kind: 'user' }`, and the generic envelope renderer interpolates `source.kind` without ever routing on it. +- **`TurnTriggerMap.continuation`** (`packages/core/session/src/types.ts`). The loop structurally cannot emit it — continuation happens *within* a turn as further steps, never as a new turn — and it constructs only `message` and `injection` triggers. The only writer was one hand-built test fixture needing an arbitrary non-message trigger (`packages/support/llm-replay/tests/llm-replay.spec.ts`), which an `injection` trigger serves equally; the only production trigger reader, the ACP bridge, filters on `kind === 'message'`. + +## Decision + +`CacheHint` with both `cache?` fields, the `agent` message-source variant, and the `continuation` turn-trigger variant are deleted. The llm-replay fixture uses an `injection` trigger (any non-`message` trigger serves its purpose). Updated in the same change: the type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) (`scripts/type-equiv.manifest.json` is untouched — both pasted symbols survive, each minus a member), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequence line that had named cache hints as having a home, per [implemented/AGENTS.md](../AGENTS.md). + +Each variant returns the day it gains a real producer, exactly as the maps are designed to grow: a caching feature re-adds `cache` together with the adapter that transmits it; subagent attribution re-adds `agent` together with the backend that stamps it and a consumer that routes on it; an auto-continue feature that genuinely starts new turns re-adds `continuation` with the plugin that emits it. + +## Why not keep them? + +The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md) listed "cache hints … have a home" as a design consequence, and reserved slots do advertise intent. But an empty slot is contract surface every implementation and consumer must consider (must my adapter honor `cache`? must my renderer route `agent` sources?), and the sibling map's own JSDoc already rejects reservation-without-emitter — `refusal` and `max_turn_requests` are named as variants to add *when something first emits them*, not declared in advance. Holding already-declared dead variants to the same standard makes the vocabulary mean something: if it is in the map, something produces it. + +## Acceptance criteria + +- `rg` for `CacheHint`, the `agent` message-source spelling, and the `continuation` trigger spelling returns only RFC records (this one, and [the drop-image RFC](2026-07-04-drop-image-content-block.md)'s account of the image block's own `cache` field). +- The core-data-structures pastes and the type-equiv manifest are in sync (`pnpm run doc-sync` green). +- The fixture asserts the same replay behavior with an `injection` trigger; the suite is green. + +## Risks + +None operational — nothing could construct these values. The mirror-event removals (recorded in [the boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lacked one. [The image-block removal](2026-07-04-drop-image-content-block.md) shipped first, taking the image block's `cache?` field with it; this change removed the two that remained. diff --git a/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md deleted file mode 100644 index 38565c9214..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ /dev/null @@ -1,31 +0,0 @@ -# RFC: Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger) - -Status: proposed - -## Problem - -The merge-extensible vocabulary maps are designed to grow by declaration merging, and the codebase already states the admission policy on `TurnEndReasonMap` (`packages/core/session/src/types.ts`): a variant like `refusal` is "deliberately omitted until" an adapter or loop first emits it. Three declared vocabulary items violate that policy — each has no producer and no consumer, and two have not even a test: - -- **`CacheHint` and the three `cache?: CacheHint` fields** on `TextBlock`/`ToolResultBlock`/`ImageBlock` (`packages/llm/llm/src/types.ts`). Nothing constructs a block with `cache:` anywhere — src, tests, and doc pastes all come up empty — and neither adapter reads `.cache`: DeepSeek prompt caching is automatic, so the adapters map `prompt_cache_hit_tokens` OUT of responses without ever sending a hint IN. This is Anthropic-style `cache_control` surface with no provider that can honor it. -- **`MessageSourceMap.agent`** (`{ kind: 'agent'; agentId: string }`, same file). Zero constructors, tests included. Its intended producer shipped without it: the subagent backends send the parent's prompt to the child with no `source`, so it logs as `{ kind: 'user' }`, and the generic envelope renderer interpolates `source.kind` without ever routing on it. The variant is pasted into [core.md](../../../core-data-structures/core.md). -- **`TurnTriggerMap.continuation`** (`packages/core/session/src/types.ts`). The loop structurally cannot emit it — continuation happens *within* a turn as further steps, never as a new turn — and it constructs only `message` and `injection` triggers. The only writer is one hand-built test fixture that needs an arbitrary non-message trigger (`packages/support/llm-replay/tests/llm-replay.spec.ts`); the only production trigger reader, the ACP bridge, filters on `kind === 'message'`. The variant is pasted into [session.md](../../../core-data-structures/session.md). - -## Proposal - -Delete `CacheHint` with its three `cache?` fields, the `agent` message-source variant, and the `continuation` turn-trigger variant. Switch the llm-replay fixture to an `injection` trigger (any non-`message` trigger serves its purpose). Update the type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) (and `scripts/type-equiv.manifest.json` where block identity shifts) in the same change, and amend the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md)'s consequence line naming cache hints as having a home, per [implemented/AGENTS.md](../../implemented/AGENTS.md). - -Each variant returns the day it gains a real producer, exactly as the maps are designed to grow: a caching feature re-adds `cache` together with the adapter that transmits it; subagent attribution re-adds `agent` together with the backend that stamps it and a consumer that routes on it; an auto-continue feature that genuinely starts new turns re-adds `continuation` with the plugin that emits it. - -## Why not keep them? - -The [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md) lists "cache hints … have a home" as a design consequence, and reserved slots do advertise intent. But an empty slot is contract surface every implementation and consumer must consider (must my adapter honor `cache`? must my renderer route `agent` sources?), and the sibling map's own JSDoc already rejects reservation-without-emitter — `refusal` and `max_turn_requests` are named as variants to add *when something first emits them*, not declared in advance. Holding already-declared dead variants to the same standard makes the vocabulary mean something: if it is in the map, something produces it. - -## Acceptance criteria - -- `rg` for `CacheHint`, the `agent` message-source spelling, and the `continuation` trigger spelling returns only this RFC. -- The core-data-structures pastes and the type-equiv manifest are in sync (`pnpm run doc-sync` green). -- The fixture asserts the same replay behavior with an `injection` trigger; the suite is green. - -## Risks - -None operational — nothing can construct these values today. The mirror-event removals (recorded in [the boundary-mirror RFC](../../implemented/simplification/2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](../../implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lack one. If the [image-block RFC](../../implemented/simplification/2026-07-04-drop-image-content-block.md) ships first, one of the three `cache?` fields leaves with it; the two proposals are independent and compose in either order. diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index b9a4c3b89b..27aaf580bc 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -91,7 +91,6 @@ export interface CreateSessionOptions { */ export interface TurnTriggerMap { message: { kind: 'message'; source: MessageSource } - continuation: { kind: 'continuation' } /** * An out-of-band context injection (`agent.inject()`) made while the agent * was idle. The loop wraps the injected `context/message` in a one-shot turn diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index fbfc7eb523..61d4769ffa 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -22,14 +22,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand' import type { CallId } from './brand.ts' -/** Cache hint attached to a content block (provider-interpreted). */ -export type CacheHint = 'ephemeral' - /** Plain text visible to the end user. */ export interface TextBlock { type: 'text' text: string - cache?: CacheHint } /** Reasoning / thinking content, distinct from visible text. */ @@ -54,7 +50,6 @@ export interface ToolResultBlock { toolCallId: CallId content: ContentBlock[] isError?: boolean - cache?: CacheHint } /** @@ -91,7 +86,6 @@ export interface Message { export interface MessageSourceMap { user: { kind: 'user' } plugin: { kind: 'plugin'; plugin: string } - agent: { kind: 'agent'; agentId: string } } export type MessageSource = MessageSourceMap[keyof MessageSourceMap] diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 2dd8357cc7..87cc62d165 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -118,7 +118,7 @@ describe('deriveReplayScript', () => { it('ignores non-assistant/chunk events', () => { let seq = 1 const events: SessionEvent[] = [ - { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'continuation' } } }, + { type: 'turn/start', seq: seq++, time: 0, data: { turn: 1, trigger: { kind: 'injection', source: { kind: 'user' } } } }, ...TEXT_CHUNKS.map(c => chunkEvent(seq++, 1, 1, c)), { type: 'turn/end', seq: seq++, time: 0, data: { turn: 1, reason: { kind: 'completed' } } }, ] From 9db3ce73138c92669de8b684a6ea828a324541a8 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 17:54:17 +0800 Subject: [PATCH 23/32] test(acp): assert the patch target's name; state the overlay's true failure modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review, with its own probes, showed the RFC overclaimed: the disable patch carried no name assertion despite the text crediting one, and an id rename is not fail-loud — the skipped patch's warning needs a logger the replay app deliberately lacks, and the resulting keyless adapter entry fails inside its fiber without reaching the unhandled-rejection guard (verified by a subprocess probe of the real installFailLoud + boot composition). The overlay now asserts name: dsh-llm-deepseek on the patch (a reused id can never disable the wrong plugin), and the RFC records the honest residual: an id rename degrades to config rot with replay output still correct (llm-replay owns the stream short-circuit), plus the insert-collision last-wins fact. --- .../testing/2026-07-04-single-source-acp-replay-config.md | 4 ++-- examples/acp-agent/cordis.snapshot.yml | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md index 56a4cfc552..c5f836b9f7 100644 --- a/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md +++ b/docs/rfc/implemented/testing/2026-07-04-single-source-acp-replay-config.md @@ -8,7 +8,7 @@ Status: implemented ## Decision -`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry by id and insert the `llm-replay` entry ([the vendored include plugin](../../../../vendor/include/src/index.ts)'s patch mechanism: by-id overrides with a name-assertion guard, plus top-level inserts). Every other entry — the app, the bash executor, the fs/subagent/todo tools, both hook bridges, the system prompt — is the live tree itself, loaded through the include, so replay exercises exactly what ships and an app-shape change lands once. The `dsh-acp-agent` bin is untouched (it still just selects this file for `DSH_SNAPSHOT=replay`); recording still boots `cordis.yml` directly; the bin's `assertEntriesLoaded` guard tolerates the disabled entry by design (a disabled entry is the one legitimate fiber-less state). +`cordis.snapshot.yml` is a declarative overlay, not a copy: its single entry mounts `@cordisjs/plugin-include` on `./cordis.yml` with `patches` that disable the `llm-deepseek` entry (matched by id AND asserted by `name`, so a reused id can never disable the wrong plugin) and insert the `llm-replay` entry ([the vendored include plugin](../../../../vendor/include/src/index.ts)'s patch mechanism: by-id overrides with an optional name assertion, plus top-level inserts). Every other entry — the app, the bash executor, the fs/subagent/todo tools, both hook bridges, the system prompt — is the live tree itself, loaded through the include, so replay exercises exactly what ships and an app-shape change lands once. The `dsh-acp-agent` bin is untouched (it still just selects this file for `DSH_SNAPSHOT=replay`); recording still boots `cordis.yml` directly; the bin's `assertEntriesLoaded` guard tolerates the disabled entry by design (a disabled entry is the one legitimate fiber-less state). One vendored-plugin fact the overlay depends on, deliberately: the include applies `patches` when it loads the file — its `refresh()`/`internal/update` paths re-read without re-patching — which is exactly enough for a one-shot replay boot (the replay app loads no `hmr` and nothing rewrites the config mid-run). The snapshot suite is the proof: all scenarios pass unchanged on the overlay, byte-identical goldens included. @@ -19,5 +19,5 @@ Keeping the full twin with a symmetry verify-gate was the recorded fallback — ## Consequences - A plugin added to `cordis.yml` is in the replay tree with no second edit; the drift class is structurally gone rather than gated. -- The overlay depends on entries carrying stable `id:`s — which the live config already does, and which the include's name-assertion patch guard makes checkable. +- The overlay depends on entries carrying stable `id:`s. The `name` assertion on the disable patch guards mis-targeting (a reused id skips the patch instead of disabling the wrong plugin). An id RENAME degrades the patch to a skip whose warning needs a logger the replay app deliberately lacks — the observable result is a futile keyless `llm-deepseek` entry alongside `llm-replay`, with replay output still correct (`llm-replay` owns the stream short-circuit); config rot for review to catch, not wrong snapshots. A top-level insert whose id collides with an existing entry resolves last-wins through the loader's id map — the current config has no collision, and a new patch line is where one would be introduced. - If a future replay tree needs a second divergence (another backend swapped), it is one more patch line, not a second fork of the file. diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index 0826c49b29..fd8a9dcad2 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -19,7 +19,14 @@ config: path: ./cordis.yml patches: + # The name is an assertion, not an override: the include skips the patch + # (warning if a logger exists) when the id points at a different plugin, + # so this can never disable the wrong entry. If cordis.yml ever RENAMES + # the id, the patch degrades to a skip — replay output stays correct + # (llm-replay still short-circuits the stream) but the stale patch and a + # futile keyless adapter entry linger until review catches them. - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' disabled: true - insert: - id: llm-replay From 75b97e7463496ea148308997a0723b0f1c03d509 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:04:26 +0800 Subject: [PATCH 24/32] docs(rfc): state the pruned-vocabulary RFC as current facts, not patch mechanics The Decision and Risks paragraphs described the change relative to the patch that landed it (what was updated together, what another change took first). An implemented RFC reads as standing truth: the pastes match the pruned maps, the manifest rows remain because the symbols survive, and the image block's cache field belongs to the drop-image RFC's record. --- ...4-prune-producerless-vocabulary-variants.md | 4 ++-- verdict | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 verdict diff --git a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md index b0fedc990e..d938f3eafb 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md +++ b/docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md @@ -12,7 +12,7 @@ The merge-extensible vocabulary maps are designed to grow by declaration merging ## Decision -`CacheHint` with both `cache?` fields, the `agent` message-source variant, and the `continuation` turn-trigger variant are deleted. The llm-replay fixture uses an `injection` trigger (any non-`message` trigger serves its purpose). Updated in the same change: the type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) (`scripts/type-equiv.manifest.json` is untouched — both pasted symbols survive, each minus a member), and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequence line that had named cache hints as having a home, per [implemented/AGENTS.md](../AGENTS.md). +`CacheHint`, its `cache?` block fields, the `agent` message-source variant, and the `continuation` turn-trigger variant are deleted: the shipped vocabulary carries none of them. The llm-replay fixture uses an `injection` trigger (any non-`message` trigger serves its purpose). The type-equiv pastes in [core.md](../../../core-data-structures/core.md) and [session.md](../../../core-data-structures/session.md) match the pruned maps — both symbols keep their rows in `scripts/type-equiv.manifest.json`, since each map survives minus a member — and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequences record cache hints as producer-gated rather than as having a home, per [implemented/AGENTS.md](../AGENTS.md). Each variant returns the day it gains a real producer, exactly as the maps are designed to grow: a caching feature re-adds `cache` together with the adapter that transmits it; subagent attribution re-adds `agent` together with the backend that stamps it and a consumer that routes on it; an auto-continue feature that genuinely starts new turns re-adds `continuation` with the plugin that emits it. @@ -28,4 +28,4 @@ The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-voca ## Risks -None operational — nothing could construct these values. The mirror-event removals (recorded in [the boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lacked one. [The image-block removal](2026-07-04-drop-image-content-block.md) shipped first, taking the image block's `cache?` field with it; this change removed the two that remained. +None operational — nothing could construct these values. The mirror-event removals (recorded in [the boundary-mirror RFC](2026-06-20-remove-agent-boundary-mirror-events.md) and [the stream-chunk RFC](2026-07-02-remove-stream-chunk-mirror.md)) touch only transient `agent/*` events, never the durable vocabulary, so there is no collision. Elsewhere in the vocabulary the admission policy already holds: `rejected`, `prompt/blocked`, and `hook/invoked`/`hook/result` each have live producers — this RFC extends the same bar to the three variants that lacked one. The image block's own `cache?` field belongs to [the drop-image RFC](2026-07-04-drop-image-content-block.md), which removed it together with the block; this RFC covers the two fields on the block types that remain. diff --git a/verdict b/verdict new file mode 100644 index 0000000000..d43803f7a9 --- /dev/null +++ b/verdict @@ -0,0 +1,18 @@ +(A) merge-blocker +- `docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md:15` and `docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md:31` still narrate this PR/change history instead of just the shipped/current state: "Updated in the same change", "`scripts/type-equiv.manifest.json` is untouched", "shipped first", and "this change removed". The review spec asks for current-state prose only, and the house rules prohibit process narration in docs/comments. Reword these as present facts about the live vocabulary/catalogs rather than mechanics of this patch. + +(B) non-blocking +- None. + +(C) nitpick +- None. + +Evidence checked +- Producer-less claims are accurate: exact `cache:` block constructors are gone except this RFC record (`rg "\bcache\s*:"`), exact `{ kind: 'agent' }` constructors are gone (`rg "\{\s*kind:\s*['\"]agent['\"]\s*[,}]"`), and exact `{ kind: 'continuation' }` constructors are gone (`rg "\{\s*kind:\s*['\"]continuation['\"]\s*[,}]"`). +- The loop constructs `message` at `packages/core/agent-loop/src/loop.ts:284` and `injection` at `packages/core/agent-loop/src/agent.ts:145`; production `trigger.kind` readers are the ACP message filters at `packages/ui/acp/src/index.ts:352` and `packages/ui/acp/src/index.ts:385`. +- The subagent prompt path sends without a source at `packages/subagent/subagent-inprocess/src/index.ts:155`, and missing sources default to `{ kind: 'user' }` at `packages/core/agent-loop/src/agent.ts:103`. +- The core-data-structures pastes no longer include the removed variants: `docs/core-data-structures/core.md:107` and `docs/core-data-structures/session.md:165`. +- The llm-replay fixture still proves `deriveReplayScript` ignores non-`assistant/chunk` events: the test includes `turn/start`/`turn/end` around chunks at `packages/support/llm-replay/tests/llm-replay.spec.ts:118`, while the implementation skips all non-chunk events at `packages/support/llm-replay/src/index.ts:195`. +- Checks run: `pnpm exec vitest run packages/support/llm-replay/tests/llm-replay.spec.ts` (51 passed), `pnpm run doc-sync` (passed), `git diff --check origin/simpl-a1-drop-image...HEAD` (passed), precise EOF check on touched files (passed), and `pnpm run typecheck` (passed). + +Verdict: NO-SHIP as it stands, due to the implemented RFC process-narration blocker above. From 2825aa6fdc0ab6232417c3002a76bfe0688fc271 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:06:30 +0800 Subject: [PATCH 25/32] chore: drop a stray review scratch file A reviewer's working notes (an untracked 'verdict' file at the repo root) were swept into the previous commit by a blanket git add; the file is no part of the change. --- verdict | 18 ------------------ 1 file changed, 18 deletions(-) delete mode 100644 verdict diff --git a/verdict b/verdict deleted file mode 100644 index d43803f7a9..0000000000 --- a/verdict +++ /dev/null @@ -1,18 +0,0 @@ -(A) merge-blocker -- `docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md:15` and `docs/rfc/implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md:31` still narrate this PR/change history instead of just the shipped/current state: "Updated in the same change", "`scripts/type-equiv.manifest.json` is untouched", "shipped first", and "this change removed". The review spec asks for current-state prose only, and the house rules prohibit process narration in docs/comments. Reword these as present facts about the live vocabulary/catalogs rather than mechanics of this patch. - -(B) non-blocking -- None. - -(C) nitpick -- None. - -Evidence checked -- Producer-less claims are accurate: exact `cache:` block constructors are gone except this RFC record (`rg "\bcache\s*:"`), exact `{ kind: 'agent' }` constructors are gone (`rg "\{\s*kind:\s*['\"]agent['\"]\s*[,}]"`), and exact `{ kind: 'continuation' }` constructors are gone (`rg "\{\s*kind:\s*['\"]continuation['\"]\s*[,}]"`). -- The loop constructs `message` at `packages/core/agent-loop/src/loop.ts:284` and `injection` at `packages/core/agent-loop/src/agent.ts:145`; production `trigger.kind` readers are the ACP message filters at `packages/ui/acp/src/index.ts:352` and `packages/ui/acp/src/index.ts:385`. -- The subagent prompt path sends without a source at `packages/subagent/subagent-inprocess/src/index.ts:155`, and missing sources default to `{ kind: 'user' }` at `packages/core/agent-loop/src/agent.ts:103`. -- The core-data-structures pastes no longer include the removed variants: `docs/core-data-structures/core.md:107` and `docs/core-data-structures/session.md:165`. -- The llm-replay fixture still proves `deriveReplayScript` ignores non-`assistant/chunk` events: the test includes `turn/start`/`turn/end` around chunks at `packages/support/llm-replay/tests/llm-replay.spec.ts:118`, while the implementation skips all non-chunk events at `packages/support/llm-replay/src/index.ts:195`. -- Checks run: `pnpm exec vitest run packages/support/llm-replay/tests/llm-replay.spec.ts` (51 passed), `pnpm run doc-sync` (passed), `git diff --check origin/simpl-a1-drop-image...HEAD` (passed), precise EOF check on touched files (passed), and `pnpm run typecheck` (passed). - -Verdict: NO-SHIP as it stands, due to the implemented RFC process-narration blocker above. From 48d25cdd44abe6cfea67b1bd2584049d7c84860a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:06:35 +0800 Subject: [PATCH 26/32] Fix review findings: validate the hooks cap, integer read caps, doc drift, config plumb-through test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Codex review pass on the draft caught four real gaps and two solid suggestions; all addressed except one pushed back on the merits: - hooks-claude/hooks-codex: stderrSummaryMaxChars was the one new knob with NO range validation — a negative/NaN cap would silently misbehave inside slice(). Both bridges now assert a positive integer at the TOP of apply() (before the config-file parse's early return, so a bad value fails the load loudly), with rejection tests. - tool-fs: the read caps count lines/chars/bytes, so positive-FINITE was too loose (a fractional readLimit would flow into windowing arithmetic and the schema description). All four now require a positive integer, matching tool-web's cap. - Doc drift the gates cannot catch: tool-web's README tools table still named WEB_SEARCH_MAX_RESULTS as the mechanism; compact-basic's README/module doc and the compaction-capability-seam RFC still described estimation as fixed char/4 rather than the charsPerToken default. - subagent-acp: the dispose graces were tested only at the startAcpRun level, so a regression that stopped threading plugin config into AcpRunSpec would have survived. A provider-path test now drives the trap-escalation scenario through ctx.subagents.start with small config graces and bounds dispose at 4s. Pushed back on: converting compact-basic's charsPerToken to a schemastery field. The package's whole config is deliberately hand-rolled (resolveConfig, every threshold REQUIRED with no default — a documented design posture); one schemastery field beside it would be incoherent. The knob is cordis.yml-reachable, defaulted, and validated, which is what the convention requires; migrating the package to schemastery wholesale is pre-existing config-surface hygiene out of this change's scope. --- .../2026-06-18-compaction-capability-seam.md | 2 +- packages/compact/compact-basic/README.md | 4 +-- packages/compact/compact-basic/src/index.ts | 3 +- packages/fs/tool-fs/src/index.ts | 16 +++++----- packages/fs/tool-fs/tests/tools.spec.ts | 5 +-- packages/hooks/hooks-claude/src/index.ts | 12 ++++++- .../hooks/hooks-claude/tests/coverage.spec.ts | 10 ++++++ packages/hooks/hooks-codex/src/index.ts | 12 ++++++- .../hooks/hooks-codex/tests/coverage.spec.ts | 10 ++++++ .../subagent-acp/tests/subagent-acp.spec.ts | 31 +++++++++++++++++++ packages/web/tool-web/README.md | 2 +- 11 files changed, 90 insertions(+), 17 deletions(-) diff --git a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md index 9e08df2fbd..f09dab5cd1 100644 --- a/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md +++ b/docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md @@ -17,7 +17,7 @@ Two forces shape the design. First, compaction is **swappable**: token counting Per the [capability-seams RFC](../../implemented/architecture/2026-06-13-capability-seams.md), compaction ships as separate packages so the contract, the algorithm, and (later) the consumer surface evolve independently: 1. **Interface** — `@deepseek-ai/dsh-compact`: an abstract `CompactService` owning the `ctx.compact` key, the `CompactionResult` vocabulary, and the `compact/*` session events. It declares `compactIfNeeded()` and `compactRegion()` as **abstract** — the contract states *what* compaction does, not *how*. -2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (char/4 + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). +2. **Implementation** — `@deepseek-ai/dsh-compact-basic`: a concrete `BasicCompactService` that owns the entire algorithm — token estimation (chars per token — the `charsPerToken` config, default 4 — + per-block overhead), the tail→head retention walk, summarization via `ctx.llm.stream()`, the surface replacement, the lock, and the `agent/pre-step` auto-compaction listener. A tokenizer-based or template-based backend is a sibling package (or a subclass overriding the two protected estimation/summarization hooks). 3. **Consumer** — deferred. A `/compact` tool and slash command will `inject: ['compact']` and call the contract; they are intentionally out of scope here so the seam settles first. ### The contract depends on `dsh-session` and `dsh-llm` — a deliberate deviation diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index cbd1146634..ba976d2833 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -1,6 +1,6 @@ # @deepseek-ai/dsh-compact-basic -The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a char/4 token heuristic, token-budget retention, and summarization routed through the agent request pipeline. +The **basic compaction backend**: a `BasicCompactService` implementing the `@deepseek-ai/dsh-compact` seam with a chars-per-token heuristic (the `charsPerToken` config, default 4), token-budget retention, and summarization routed through the agent request pipeline. This is the implementation tier of the compaction capability — see the [interface package](../compact/README.md) for the seam and the [capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md) for the design. @@ -8,7 +8,7 @@ This is the implementation tier of the compaction capability — see the [interf The abstract contract states only WHAT compaction does; this backend owns every HOW decision: -- **Token estimation** — `estimateContentTokens()`: char/4 with per-block structural overhead (`text`/`reasoning` = `ceil(len/4) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). +- **Token estimation** — `estimateContentTokens()`: chars divided by the `charsPerToken` config (default 4) with per-block structural overhead (`text`/`reasoning` = `ceil(len/charsPerToken) + 4`, `tool-call` from name + arguments, `tool-result` recursive, `image` = 85, unknown blocks via JSON length). - **Retention policy** — `compactIfNeeded()` walks the surface nodes tail→head summing per-node token estimates, and retains the smallest tail-run of WHOLE units (a closed step, or a single no-step node such as a pre-step `user/message` or inter-step `steering/message`) whose total reaches `retainTokens`; everything older is compacted. Retention is **turn-agnostic** — turn boundaries play no role, so a single runaway turn that alone exceeds the window compacts its OWN early closed steps rather than being retained verbatim (the failure mode that motivated dropping turn-protection: a tool-heavy turn must stay compactable or the harness dies exactly when compaction is needed). The only structural guard is **tool-pairing balance**: the compacted region's edges are balanced cuts on the surface (no unanswered tool-call crosses either edge), so it never splits a step's `assistant/message` tool-calls from their `tool/result`s. When the only compactable content left is an un-splittable open tail step, it declines (returns `null`) and retries once an older step closes. **Single-unit overflow is out of scope, by design**: if one retained unit (a single closed step, or a large pasted `user/message`) ALONE exceeds the budget, compaction cannot help and the call may go out over-budget — bounding an individual unit's size is a separate concern. `compactRegion()` enforces tool-pairing balance strictly, throwing on a boundary that would split a step. `dsh-session` exports `isToolPairingBalanced` for the check. - **Dynamic convergence** — no static summary-length config pretends to bound what the model will write. If framing/estimator/system overhead leaves the compacted surface above threshold, `compactIfNeeded()` re-compacts the head checkpoint up to `compactionRetries` extra times; if it still cannot get below threshold, it throws. A summary whose estimated stored size is not smaller than the shadowed content fails closed before it mutates the surface. - **Summarization** — `summarize()`: a `GenerateOptions` request assembled via `BlockAssembler` with a fixed system prompt that asks for a structured checkpoint (Primary Request and Intent · Key Technical Concepts · Files and Code · Errors and Fixes · Pending Tasks · Current Work · Next Step · Critical Context), every section mandatory, exact paths/commands/identifiers preserved. The request runs through the `agent/request` waterfall before `ctx.llm.stream()`, so router agents that choose the concrete model there also route compaction summaries. `maxTokens` is the provider-side generation cap; only text blocks from the model's reply are kept before the checkpoint is stored (reasoning is dropped so private chain-of-thought never leaks into the durable summary, and a stray `tool-call` is dropped so the synthesized `user/message` summary cannot land an orphaned call with no matching `tool-result`). The compacted region is flattened to a plain-text transcript first: text and reasoning contribute their text, and every non-text block (image, tool-call, tool-result, plugin-added types) contributes a type-tagged placeholder (`[image]`, `[tool-call: name(args)]`, …) so the summarizer is told what existed rather than silently dropping it. diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index 26c593a47f..a1a83cd407 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -2,7 +2,8 @@ * `BasicCompactService`: the first implementation of the * `@deepseek-ai/dsh-compact` seam. It owns the entire compaction strategy: * - * - **Token estimation** — char/4 heuristic with per-block structural overhead. + * - **Token estimation** — chars/`charsPerToken` heuristic (config, default 4) + * with per-block structural overhead. * - **Retention policy** — walk surface nodes tail→head, keep recent nodes up * to a token budget, compact everything older. The cutoff is snapped forward * to the next balanced tool-pairing boundary so a compacted region never diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 5cc87ba597..f5d0d9ef91 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -66,10 +66,10 @@ export const Config: z<Config> = z.object({ /** The shape after schemastery applied the defaults. */ type ResolvedConfig = Required<Config> -/** A read cap must be a positive finite number to bound output and memory. */ -function assertPositiveFinite(name: string, value: number): void { - if (!Number.isFinite(value) || value <= 0) { - throw new Error(`tool-fs: ${name} must be a positive finite number`) +/** Every read cap counts lines/chars/bytes — a positive integer, or windowing arithmetic misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`tool-fs: ${name} must be a positive integer`) } } @@ -77,10 +77,10 @@ function assertPositiveFinite(name: string, value: number): void { export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig - assertPositiveFinite('readLimit', resolved.readLimit) - assertPositiveFinite('readMaxLineLength', resolved.readMaxLineLength) - assertPositiveFinite('readMaxBytes', resolved.readMaxBytes) - assertPositiveFinite('readStreamMinSize', resolved.readStreamMinSize) + assertPositiveInteger('readLimit', resolved.readLimit) + assertPositiveInteger('readMaxLineLength', resolved.readMaxLineLength) + assertPositiveInteger('readMaxBytes', resolved.readMaxBytes) + assertPositiveInteger('readStreamMinSize', resolved.readStreamMinSize) applyReadTool(ctx, { limit: resolved.readLimit, maxLineLength: resolved.readMaxLineLength, diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index 638ce2112b..efad0a86f7 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -547,15 +547,16 @@ describe('read caps are plugin config', () => { it.each([ ['readLimit', { readLimit: 0 }], + ['readLimit', { readLimit: 2.5 }], ['readMaxLineLength', { readMaxLineLength: -1 }], ['readMaxBytes', { readMaxBytes: Number.NaN }], ['readStreamMinSize', { readStreamMinSize: 0 }], - ] as const)('rejects a non-positive %s at load', async (name, config) => { + ] as const)('rejects a non-positive or fractional %s at load', async (name, config) => { const ctx = new Context() await ctx.plugin(SystemPrompt) await ctx.plugin(ToolRegistry) await ctx.plugin(FakeFs) - await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive finite number`)) + await expect(ctx.plugin(ToolFs, config)).rejects.toThrow(new RegExp(`tool-fs: ${name} must be a positive integer`)) }) it('has no default export (namespace plugin export shape)', () => { diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 263b201791..06a405bf09 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -95,7 +95,18 @@ function nextHandlerId(point: string): string { /** The `{kind:'plugin'}` source stamped on every context this bridge injects. */ const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-claude' } +/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`hooks-claude: ${name} must be a positive integer`) + } +} + export function apply(ctx: Context, config: Config): void { + // Validate the cap BEFORE the config-file parse: a bad value must fail the + // load loudly, not be skipped by the parse-failure early return. + const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500 + assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) // --- Parse the config ONCE at load. A read/parse failure is contained: the // bridge logs and registers nothing rather than crashing boot (a typo'd path // must not take the agent down). --- @@ -116,7 +127,6 @@ export function apply(ctx: Context, config: Config): void { } const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000 - const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500 /** * Run every command hook configured for `point` whose matcher selects diff --git a/packages/hooks/hooks-claude/tests/coverage.spec.ts b/packages/hooks/hooks-claude/tests/coverage.spec.ts index f5091ce7a5..45ca8f113b 100644 --- a/packages/hooks/hooks-claude/tests/coverage.spec.ts +++ b/packages/hooks/hooks-claude/tests/coverage.spec.ts @@ -142,6 +142,16 @@ describe('hooks-claude coverage — empty/no-op outcomes and no-agent paths', () expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis }) + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + const path = hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(path, adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-claude: stderrSummaryMaxChars must be a positive integer/) + } + }) + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { const d = dir() const s = sh(d, 'long.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 4f7d6c9ed9..accc36714a 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -68,7 +68,18 @@ function nextHandlerId(point: string): string { const PLUGIN_SOURCE: MessageSource = { kind: 'plugin', plugin: 'hooks-codex' } +/** The summary cap bounds a persisted event field — a positive integer or the slice misbehaves silently. */ +function assertPositiveInteger(name: string, value: number): void { + if (!Number.isInteger(value) || value < 1) { + throw new Error(`hooks-codex: ${name} must be a positive integer`) + } +} + export function apply(ctx: Context, config: Config): void { + // Validate the cap BEFORE the config-file parse: a bad value must fail the + // load loudly, not be skipped by the parse-failure early return. + const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500 + assertPositiveInteger('stderrSummaryMaxChars', stderrSummaryMaxChars) let parsed: CodexHookConfig = {} try { const raw: unknown = JSON.parse(readFileSync(config.configPath, 'utf8')) @@ -83,7 +94,6 @@ export function apply(ctx: Context, config: Config): void { } const defaultTimeoutMs = config.defaultTimeoutMs ?? 600_000 - const stderrSummaryMaxChars = config.stderrSummaryMaxChars ?? 500 const model = config.model ?? '' async function runPoint( diff --git a/packages/hooks/hooks-codex/tests/coverage.spec.ts b/packages/hooks/hooks-codex/tests/coverage.spec.ts index 861a2691e1..040425cbbe 100644 --- a/packages/hooks/hooks-codex/tests/coverage.spec.ts +++ b/packages/hooks/hooks-codex/tests/coverage.spec.ts @@ -207,6 +207,16 @@ describe('hooks-codex coverage — decision mapping paths', () => { expect(res?.type === 'hook/result' && res.data.stderrSummary?.length).toBe(501) // default 500-char cap + ellipsis }) + it('rejects a non-positive or fractional stderrSummaryMaxChars at load', async () => { + const d = dir() + hooks(d, {}) + for (const bad of [0, -5, 1.5, Number.NaN]) { + const adapter = new MockAdapter([]) + await expect(harness(join(d, 'hooks.json'), adapter, { stderrSummaryMaxChars: bad })) + .rejects.toThrow(/hooks-codex: stderrSummaryMaxChars must be a positive integer/) + } + }) + it('the stderr summary cap is plugin config (stderrSummaryMaxChars)', async () => { const d = dir() hooks(d, { PreToolUse: [{ hooks: [{ type: 'command', command: sh(d, 'l.sh', '#!/usr/bin/env bash\nprintf "x%.0s" {1..600} >&2\nexit 2\n') }] }] }) diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index e1e510d19d..3eb12fac38 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -398,6 +398,37 @@ describe('dsh-subagent-acp', () => { await run.dispose() }) + it('plugin-config dispose graces reach the run (SIGKILL escalation through the provider)', async () => { + // Same trap scenario as the direct startAcpRun escalation test, but the + // graces arrive via the PLUGIN CONFIG through the registered provider — so a + // regression that stops threading config into AcpRunSpec (falling back to + // the 6s/3s defaults) blows past the 4000ms bound and fails loud. + const tmp = mkdtempSync(join(tmpdir(), 'acp-cfg-trap-')) + const ready = join(tmp, 'trap-armed') + try { + const ctx = new Context() + await ctx.plugin(SubagentService) + await ctx.plugin(acp, { + providerName: 'acp', + command: process.execPath, + args: ['--import', tsxLoader, mockServer], + permission: 'reject', + env: { MOCK_TRAP_SIGTERM: '1', MOCK_TEXT: 'x', MOCK_READY_FILE: ready, TSX_TSCONFIG_PATH: repoTsconfig }, + disposeEofGraceMs: 150, + disposeGraceMs: 150, + }) + const run = ctx.subagents.start('acp', { prompt: [{ type: 'text', text: 'p' }], parent: fakeParent }) + await waitForFile(ready) + await expect(Promise.race([ + run.dispose(), + new Promise((_r, reject) => { setTimeout(() => { reject(new Error('dispose did not return — config graces not threaded to the run')) }, 4000) }), + ])).resolves.toBeUndefined() + await ctx.fiber.dispose() + } finally { + rmSync(tmp, { recursive: true, force: true }) + } + }) + it('rejects a non-positive dispose grace at load', async () => { for (const bad of [{ disposeEofGraceMs: 0 }, { disposeGraceMs: -1 }, { disposeEofGraceMs: Number.NaN }]) { const ctx = new Context() diff --git a/packages/web/tool-web/README.md b/packages/web/tool-web/README.md index e789720cbb..f0bd66cdb9 100644 --- a/packages/web/tool-web/README.md +++ b/packages/web/tool-web/README.md @@ -8,7 +8,7 @@ Each tool is registered independently; a product that wants only one disables th | Tool | Args | Behavior | |---|---|---| -| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (`WEB_SEARCH_MAX_RESULTS = 8`) and passes it to the seam. | +| `web_search` | `query` (string) | Discovery. Returns an optional answer plus source URLs. `max_results` is **not** model-facing — the tool sets the bound (the `searchMaxResults` config, default 8) and passes it to the seam. | | `web_fetch` | `url` (string), `timeout_ms` (number, optional) | Retrieves a specific URL. HTML bodies are rendered to markdown-ish text; text bodies pass through. A non-2xx status is reported, not an error. | ## Config From cc15ef54ef9d1d88f3c3986c5d45390900fde1be Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:15:54 +0800 Subject: [PATCH 27/32] Sync remaining chars-per-token prose in overview READMEs The convergence pass found four summary-level sites still describing the estimator as fixed char/4: packages/README.md (twice), the compact group and interface READMEs, and compact-basic's package.json description. All now say chars-per-token with the charsPerToken default, matching the authoritative package README/module doc/RFC. --- packages/README.md | 4 ++-- packages/compact/README.md | 2 +- packages/compact/compact-basic/package.json | 2 +- packages/compact/compact/README.md | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/README.md b/packages/README.md index 2233123772..aec3a0a244 100644 --- a/packages/README.md +++ b/packages/README.md @@ -34,7 +34,7 @@ dsh-session ← dsh-llm, dsh-brand dsh-system-prompt ← dsh-llm dsh-agent ← dsh-llm, dsh-session, dsh-brand dsh-compact ← dsh-session, dsh-llm (abstract compaction seam; tool deferred) -dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (char/4 + token-budget retention backend) +dsh-compact-basic ← dsh-compact, dsh-session, dsh-llm, dsh-agent (chars-per-token + token-budget retention backend) dsh-tools ← dsh-llm, dsh-system-prompt, dsh-agent dsh-bash-local ← dsh-bash (BashExecutor impl) dsh-tool-bash ← dsh-bash, dsh-tools (bash tool schemas) @@ -89,7 +89,7 @@ The rule: **extension** plugins depend on interfaces, never on the concrete loop | `fs-policy/` | `fs` | Policy gate plugin: observed-state + read-before-edit + version-guarded write/edit via the `fs/*` event gate | (no service — `fs/*` listeners) | | `tool-fs/` | `fs` | Model-facing `read`/`write`/`edit` tools + executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`) | (registers on `ctx.tools`) | | `compact/` | `compact` | Abstract compaction seam + `compact/*` events + `CompactionResult` | `ctx.compact` | -| `compact-basic/` | `compact` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `compact-basic/` | `compact` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `web/` | `web` | Abstract web seam (search/fetch provider registries + selection + vocabulary + `WebError`) | `ctx.web` | | `web-search-exa/` | `web` | Exa-backed `WebSearchProvider` | (registers on `ctx.web`) | | `web-search-perplexity/` | `web` | Perplexity-backed `WebSearchProvider` | (registers on `ctx.web`) | diff --git a/packages/compact/README.md b/packages/compact/README.md index 10eaf1617a..08c3ddd707 100644 --- a/packages/compact/README.md +++ b/packages/compact/README.md @@ -5,7 +5,7 @@ A three-package capability seam (see [capability seams](../../docs/rfc/implement | Package | Role | ctx key | |---|---|---| | `compact/` | Abstract compaction seam (interface + `compact/*` events + `CompactionResult`) | `ctx.compact` | -| `compact-basic/` | A backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | +| `compact-basic/` | A backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | (registers `ctx.compact`) | | `tool-compact/` (deferred) | Model-facing `/compact` tool over `ctx.compact` | (registers on `ctx.tools`) | The interface lives at `compact/compact/`, the backend at `compact/compact-basic/`. Unlike the bash seam, it depends on `dsh-session` and `dsh-llm` — its verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so the contract cannot be expressed without naming them. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). A tokenizer- or template-based backend would replace `compact-basic` without touching the interface or the tool. diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index c019796e0d..e57e28a8c9 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-compact-basic", - "description": "Basic compaction backend (char/4 token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", + "description": "Basic compaction backend (chars-per-token estimation + token-budget retention + llm.generate() summarization) for the DeepSeek Harness", "version": "0.0.1", "private": true, "type": "module", diff --git a/packages/compact/compact/README.md b/packages/compact/compact/README.md index b6f3cc0920..424ee12bcf 100644 --- a/packages/compact/compact/README.md +++ b/packages/compact/compact/README.md @@ -7,7 +7,7 @@ This package is the interface tier of the compaction capability, split so each c | Package | Role | |---|---| | `@deepseek-ai/dsh-compact` (this) | the interface: abstract service + `compact/*` events + `CompactionResult` | -| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: char/4 estimation + token-budget retention + `llm.stream()` summarization | +| `@deepseek-ai/dsh-compact-basic` (deferred) | a backend: chars-per-token estimation (`charsPerToken`, default 4) + token-budget retention + `llm.stream()` summarization | | `@deepseek-ai/dsh-tool-compact` (deferred) | the model-facing `/compact` tool over `ctx.compact` | Unlike the bash seam, this interface depends on `@deepseek-ai/dsh-session` and `@deepseek-ai/dsh-llm` — the contract's verbs are defined over a `Session` and its output is the `ContentBlock` vocabulary, so they cannot be expressed without naming those packages. That deviation from the "interface depends only on cordis" guidance is intentional and recorded in the [compaction capability-seam RFC](../../../docs/rfc/implemented/feature/2026-06-18-compaction-capability-seam.md). From 5a8234643ab319b6f702f65f72c9c3f9efd7df30 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:38:39 +0800 Subject: [PATCH 28/32] =?UTF-8?q?refactor(llm):=20drop=20the=20inert=20req?= =?UTF-8?q?uest=20knobs=20=E2=80=94=20prefill=20and=20strict?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GenerateOptions.prefill had no production setter and both adapters rejected it with LlmError('UNSUPPORTED') — its entire observable behavior was two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. ToolSchema.strict was threaded through defineTool, the registry's schemas() allowlist, the deepseek wire mapping, a per-tool payload-patching pass in the pi-ai adapter, and a tool-catalog render row, yet no shipped tool set it and the internal endpoint story for strict mode was never built. Remove both fields end-to-end: the vocabulary in dsh-llm, the adapter guards and wire branches, the dsh-tools threading, the tool-catalog Strict row, the pinning tests, the core.md pastes, the adapter README rows, and the cookbook line that used prefill as the UNSUPPORTED example (now stated generically). The pi-ai payload fixup keeps the half with a job: pi-ai stamps strict:false on every serialized tool, so the fixup scrubs it unconditionally for wire parity with the hand-rolled twin (per-tool set/delete machinery gone). temperature/ stop/maxTokens are untouched — honored end-to-end by both adapters. Each knob returns with its first real producer: prefill with an adapter that implements chat-prefix completion, strict with a tool that wants it and a beta-endpoint story. RFC: docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md (moved from proposed/, amended to shipped reality); the content-block vocabulary RFC's consequence line now records prefill as producer-gated. --- docs/cookbook/adding-an-llm-adapter.md | 2 +- docs/core-data-structures/core.md | 3 -- docs/rfc/README.md | 2 +- .../2026-06-11-content-block-vocabulary.md | 2 +- .../2026-07-04-drop-inert-request-knobs.md | 33 ++++++++++++ .../2026-07-04-drop-inert-request-knobs.md | 33 ------------ packages/core/tools/src/index.ts | 17 +++---- packages/core/tools/src/schema.ts | 3 -- .../core/tools/tests/gen-tool-catalog.spec.ts | 11 ---- packages/core/tools/tests/tools.spec.ts | 50 ------------------- packages/llm/llm-deepseek/README.md | 2 - packages/llm/llm-deepseek/src/serialize.ts | 16 +----- packages/llm/llm-deepseek/src/types.ts | 2 - .../llm/llm-deepseek/tests/serialize.spec.ts | 19 ++----- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 34 ++++--------- packages/llm/llm-pi-ai/tests/adapter.spec.ts | 31 ++++-------- packages/llm/llm/src/types.ts | 3 -- scripts/gen-tool-catalog.ts | 1 - 19 files changed, 72 insertions(+), 196 deletions(-) create mode 100644 docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md delete mode 100644 docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md diff --git a/docs/cookbook/adding-an-llm-adapter.md b/docs/cookbook/adding-an-llm-adapter.md index aae6b0bb8b..fb59969e52 100644 --- a/docs/cookbook/adding-an-llm-adapter.md +++ b/docs/cookbook/adding-an-llm-adapter.md @@ -27,7 +27,7 @@ Registration is effect-based (HMR-safe); one adapter per model name — duplicat - Allocate block `index`es in first-seen stream order; reuse the index for every delta of the same block. - Errors have exactly two sanctioned paths: THROW from `stream()` (transport and protocol failures — use `LlmError` with a stable code), or end the stream with `finish {kind: 'error' | 'aborted'}` (provider in-band failures). Consumers handle both; pick per failure class and document it. - Honor `options.signal` (pass it to fetch / your SDK). -- `prefill` and other unsupported `GenerateOptions` fields: throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping. +- A `GenerateOptions` field your provider cannot honor (e.g. a `stop` list on a provider without stop sequences): throw `LlmError(..., 'UNSUPPORTED')` rather than silently dropping it. Provider-specific request knobs (thinking modes, effort levels) belong in the ADAPTER's Config, not in `GenerateOptions` — the core vocabulary stays provider-neutral. diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index e8f5c05f10..7f38abe985 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -130,8 +130,6 @@ interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] - /** Assistant prefix continuation (prefill). */ - prefill?: ContentBlock[] temperature?: number maxTokens?: number /** @@ -180,7 +178,6 @@ interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record<string, unknown> - strict?: boolean } ``` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 0c6944bf40..b5a295a7a9 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -52,7 +52,6 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | Title | First proposed | |---|---| | [Unify the agent id and the session id](proposed/simplification/2026-06-20-unify-agent-and-session-id.md) | 2026-06-20 | -| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](proposed/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Drop the unconsumed web observation surface — the `providers-change` event and the status methods](proposed/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) | 2026-07-04 | | [Fold the stdio UI helper into the stdio app](proposed/simplification/2026-07-04-fold-stdio-ui-helper.md) | 2026-07-04 | | [Prune dead core-spine surface — `SurfaceManager.invalidate()`, the loop-internal exports, `ToolExecutionResult.callId`](proposed/simplification/2026-07-04-prune-dead-core-spine-surface.md) | 2026-07-04 | @@ -119,6 +118,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Split the filesystem seam — provider text mutations plus the `dsh-fs-policy` plugin](implemented/simplification/2026-06-26-fsspec-style-fs-seam.md) | 2026-06-26 | | [Stop mirroring the token stream as an agent event](implemented/simplification/2026-07-02-remove-stream-chunk-mirror.md) | 2026-07-02 | | [Drop the `image` content block until a path can honor it](implemented/simplification/2026-07-04-drop-image-content-block.md) | 2026-07-04 | +| [Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path](implemented/simplification/2026-07-04-drop-inert-request-knobs.md) | 2026-07-04 | | [Prune producer-less vocabulary variants (block cache hints, the `agent` message source, the `continuation` turn trigger)](implemented/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) | 2026-07-04 | ### Architecture diff --git a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md index 619ada6f13..bc6d71e6e9 100644 --- a/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/docs/rfc/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -16,6 +16,6 @@ In-session context injection (`context/message`, `steering/message`) renders as ## Consequences -- Reasoning and prefill have a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). +- Reasoning has a home without provider contortions. Multimodal content deliberately has NO core block type: the core set is limited to blocks every shipping path honors, and a multimodal feature adds its block type through the merge-extensible map in the same coordinated change that maps it in the adapters, surfaces it in the UI bridges, and prices it in compaction — see [the drop-image RFC](../simplification/2026-07-04-drop-image-content-block.md). Block cache hints likewise have no core field: DeepSeek prompt caching is automatic, so no shipping adapter can transmit a hint; a caching feature adds a `cache` field together with the adapter that honors it — see [the producer-less-variants RFC](../simplification/2026-07-04-prune-producerless-vocabulary-variants.md). Assistant-prefix continuation (prefill) likewise has no request field: DeepSeek's chat-prefix completion is a Beta feature on a base URL neither shipping adapter targets, so a prefill feature adds `GenerateOptions.prefill` together with the adapter that honors it — see [the inert-request-knobs RFC](../simplification/2026-07-04-drop-inert-request-knobs.md). - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. - IDs that cross package boundaries are branded (`CallId`, `SessionId`, `AgentId`) — nominal typing at zero runtime cost. diff --git a/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md new file mode 100644 index 0000000000..3724d680f5 --- /dev/null +++ b/docs/rfc/implemented/simplification/2026-07-04-drop-inert-request-knobs.md @@ -0,0 +1,33 @@ +# RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path + +Status: implemented (proposed and accepted 2026-07-04) + +## Problem + +Two request-contract knobs rode the whole request pipeline, yet neither could do anything: + +- **`prefill`** (`packages/llm/llm/src/types.ts`) had no production setter — the loop assembles `model`/`system`/`tools`/`messages` plus `sessionId`/`signal`, and the compaction backend adds only `maxTokens` — and BOTH adapters rejected it: `packages/llm/llm-deepseek/src/serialize.ts` and `packages/llm/llm-pi-ai/src/adapter.ts` each threw `LlmError('UNSUPPORTED')` on a non-undefined `prefill`. The field's entire observable behavior was two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. +- **`strict`** (`ToolSchema`, same file) was threaded through `DefineToolOptions`/`defineTool` (`packages/core/tools/src/schema.ts`), the registry's `schemas()` allowlist (`packages/core/tools/src/index.ts`), the deepseek wire mapping (`packages/llm/llm-deepseek/src/serialize.ts`, whose wire-type note recorded that strict mode requires the `/beta` base URL the adapter does not use), a per-tool payload-patching pass in `packages/llm/llm-pi-ai/src/adapter.ts`, and a conditional `Strict:` row in the tool-catalog renderer (`scripts/gen-tool-catalog.ts`). No shipped tool set it — `rg` across every `tool-*` package src and `examples/` found zero `strict:` producers; the only setters were dsh-tools unit tests. + +Both knobs were adapter-symmetric, so removal shed them from both twins together — the [twin-adapter design](../architecture/2026-06-13-twin-llm-adapters.md) is untouched. + +## Decision + +- `prefill` is removed from `GenerateOptions`, along with both adapters' UNSUPPORTED guards, the tests pinning the throws, the paste line in [core.md](../../../core-data-structures/core.md), and the adapter README rows documenting the rejection. The cookbook's UNSUPPORTED guidance ([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md)) states the rule generically — a `GenerateOptions` field your provider cannot honor throws `LlmError(..., 'UNSUPPORTED')` — instead of using prefill as the example. The [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s consequences record prefill as producer-gated rather than as having a home, per [implemented/AGENTS.md](../AGENTS.md). +- `strict` is removed from `ToolSchema`, `DefineToolOptions`, `defineTool`, the `schemas()` allowlist, the deepseek serializer branch and its wire-type field, and the tool-catalog renderer's `Strict:` row. The pi-ai payload fixup is simplified to the unconditional scrub of pi-ai's own per-tool strict default (pi-ai stamps `strict: false` on every serialized tool; the hand-rolled twin sends no such field, so the scrub survives for wire parity, pinned by its serializer test). The setter tests and the core.md paste line are gone; both `GenerateOptions` and `ToolSchema` keep their rows in `scripts/type-equiv.manifest.json`, since each type survives minus a field. + +This RFC deliberately does NOT touch `temperature`, `stop`, or `maxTokens`: those are honored end-to-end by both adapters and are the natural first targets of a request-mutating hook plugin on `agent/request`. + +## Why not keep them? + +"An explicit UNSUPPORTED throw is honest contract behavior" — but a knob whose only implementation across both twins is rejection promises nothing, and deleting it upgrades the failure mode: an accidental setter becomes a compile error instead of a runtime throw. "Strict schema adherence is an officially documented provider feature with complete plumbing" — but a knob is not product surface until a shipped tool sets it AND an endpoint honors it; today neither is true. Each returns with its first real producer: `prefill` together with an adapter that implements chat-prefix completion (and a stated policy for adapters that do not), `strict` together with a tool that wants it and a beta-endpoint story. + +## Acceptance criteria + +- `rg prefill` returns only RFC records (this one and the [content-block vocabulary RFC](../architecture/2026-06-11-content-block-vocabulary.md)'s producer-gated consequence); a tool-schema-scoped `rg strict` returns only this RFC, the surviving pi-ai scrub, and unrelated prose such as `strictEqual`. +- Both adapters compile and their contract tests pass without the guards; the pi-ai fixup still scrubs the library's strict default (wire parity pinned by its serializer tests). +- Doc pastes and the type-equiv manifest in sync; `pnpm run doc-sync` green. + +## Risks + +The shipped hook bridges set no request fields at all, and a request-mutating plugin (an `agent/request` waterfall listener) would reach for `temperature`/`stop` (kept, working), not a field adapters reject. If chat-prefix completion or strict mode become product features, the re-add lands with the adapter/endpoint work, where the contract can say what actually happens rather than "everyone throws". diff --git a/docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md b/docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md deleted file mode 100644 index 60375ecf8c..0000000000 --- a/docs/rfc/proposed/simplification/2026-07-04-drop-inert-request-knobs.md +++ /dev/null @@ -1,33 +0,0 @@ -# RFC: Drop `GenerateOptions.prefill` and `ToolSchema.strict` — request knobs with no working end-to-end path - -Status: proposed - -## Problem - -Two request-contract knobs ride the whole request pipeline, yet neither can do anything today: - -- **`prefill`** (`packages/llm/llm/src/types.ts`) has no production setter — the loop assembles `model`/`system`/`tools`/`messages` plus `sessionId`/`signal`, and the compaction backend adds only `maxTokens` — and BOTH adapters reject it: `packages/llm/llm-deepseek/src/serialize.ts` and `packages/llm/llm-pi-ai/src/adapter.ts` each throw `LlmError('UNSUPPORTED')` on a non-undefined `prefill`. The field's entire observable behavior is two throws, each pinned by one adapter test. DeepSeek's chat-prefix completion is a Beta feature on a base URL neither adapter targets. -- **`strict`** (`ToolSchema`, same file) is threaded through `DefineToolOptions`/`defineTool` (`packages/core/tools/src/schema.ts`), the registry's `schemas()` allowlist (`packages/core/tools/src/index.ts`), the deepseek wire mapping (`packages/llm/llm-deepseek/src/serialize.ts`, whose wire-type note records that strict mode requires the `/beta` base URL the adapter does not use), and a per-tool payload-patching pass in `packages/llm/llm-pi-ai/src/adapter.ts`. No shipped tool sets it — `rg` across every `tool-*` package src and `examples/` finds zero `strict:` producers; the only setters are dsh-tools unit tests. - -Both knobs are adapter-symmetric, so removal sheds them from both twins together — the [twin-adapter design](../../implemented/architecture/2026-06-13-twin-llm-adapters.md) is untouched. - -## Proposal - -- Remove `prefill` from `GenerateOptions`, both adapters' UNSUPPORTED guards, the tests pinning the throws, the paste lines in [core.md](../../../core-data-structures/core.md), the adapter README rows documenting the rejection, and the cookbook line using prefill as the UNSUPPORTED example ([adding-an-llm-adapter.md](../../../cookbook/adding-an-llm-adapter.md)); amend the [content-block vocabulary RFC](../../implemented/architecture/2026-06-11-content-block-vocabulary.md)'s consequence line naming prefill as having a home, per [implemented/AGENTS.md](../../implemented/AGENTS.md). -- Remove `strict` from `ToolSchema`, `DefineToolOptions`, `defineTool`, and the `schemas()` allowlist; drop the deepseek serializer branch; simplify the pi-ai payload fixup to the unconditional scrub of pi-ai's own strict default (that half exists for wire parity with the hand-rolled twin and survives); drop the setter tests and the core.md paste line. - -This RFC deliberately does NOT touch `temperature`, `stop`, or `maxTokens`: those are honored end-to-end by both adapters and are the natural first targets of a request-mutating hook plugin on `agent/request`. - -## Why not keep them? - -"An explicit UNSUPPORTED throw is honest contract behavior" — but a knob whose only implementation across both twins is rejection promises nothing, and deleting it upgrades the failure mode: an accidental setter becomes a compile error instead of a runtime throw. "Strict schema adherence is an officially documented provider feature with complete plumbing" — but a knob is not product surface until a shipped tool sets it AND an endpoint honors it; today neither is true. Each returns with its first real producer: `prefill` together with an adapter that implements chat-prefix completion (and a stated policy for adapters that do not), `strict` together with a tool that wants it and a beta-endpoint story. - -## Acceptance criteria - -- `rg prefill` and a tool-schema-scoped `rg strict` return only this RFC (and unrelated prose such as `strictEqual`). -- Both adapters compile and their contract tests pass without the guards; the pi-ai fixup still scrubs the library's strict default (wire parity pinned by its serializer tests). -- Doc pastes and the type-equiv manifest in sync; `pnpm run doc-sync` green. - -## Risks - -The shipped hook bridges set no request fields at all, and a request-mutating plugin (an `agent/request` waterfall listener) would reach for `temperature`/`stop` (kept, working), not a field adapters reject. If chat-prefix completion or strict mode become product features, the re-add lands with the adapter/endpoint work, where the contract can say what actually happens rather than "everyone throws". diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 6265e645f9..a33edb430d 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -306,20 +306,19 @@ export class ToolRegistry extends Service { /** * Return all registered tool schemas — exactly the model-facing fields - * (`name`, `description`, `parameters`, and `strict` when set), as sent to the - * model via the system-prompt assembly. Constructed EXPLICITLY rather than by - * stripping known non-schema members: a `ToolDefinition` also carries - * `execute` and the optional `presentCall`/`presentResult` UI callbacks, and - * those (especially the functions) must never leak into a model request. An - * allowlist can't drift when a new non-schema member is added to the - * definition; a denylist (rest-destructure) would silently leak it. + * (`name`, `description`, `parameters`), as sent to the model via the + * system-prompt assembly. Constructed EXPLICITLY rather than by stripping + * known non-schema members: a `ToolDefinition` also carries `execute` and the + * optional `presentCall`/`presentResult` UI callbacks, and those (especially + * the functions) must never leak into a model request. An allowlist can't + * drift when a new non-schema member is added to the definition; a denylist + * (rest-destructure) would silently leak it. */ schemas(): ToolSchema[] { - return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({ + return [...this.store.values()].map(({ name, description, parameters }): ToolSchema => ({ name, description, parameters: structuredClone(parameters), - ...strict !== undefined ? { strict } : {}, })) } diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index 0539c1f07b..9149241225 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -312,8 +312,6 @@ export interface DefineToolOptions<S extends SchemaSpec> { * free for the same replay reason. See {@link ToolResultView}. */ presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined - /** Whether the tool requires structured output (default false). */ - strict?: boolean } /** @@ -355,7 +353,6 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): name: options.name, description: options.description, parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>, - ...options.strict !== undefined ? { strict: options.strict } : {}, async execute(args: unknown, exec: ToolExecution): Promise<ToolExecuteReturn> { // Validate the model-generated args before the typed body runs. On // mismatch we throw ToolArgsError; the registry turns it into an diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index f034c0529f..05236dd322 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -103,15 +103,4 @@ describe('gen-tool-catalog render', () => { expect(md).toContain('```json') expect(md).toContain('Source: [`packages/demo/tool-demo/src/index.ts`]') }) - - it('renders the strict flag when a schema sets it', () => { - const catalog: ToolCatalog = [ - { - pkg: '@deepseek-ai/dsh-tool-demo', - source: 'packages/demo/tool-demo/src/index.ts', - schemas: [{ name: 'demo', description: '', parameters: { type: 'object', properties: {} }, strict: true }], - }, - ] - expect(render(catalog)).toContain('Strict: `true`') - }) }) diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index ca63338258..09158b8398 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -62,18 +62,6 @@ describe('ToolRegistry', () => { expect(schema.execute).toBeUndefined() }) - it('schemas() preserves `strict` when set (allowlist keeps the model-facing fields)', async () => { - const ctx = await setup() - ctx.tools.register(defineTool({ - name: 'strict-tool', - description: 'd', - parameters: { x: { type: 'string', required: true } }, - strict: true, - async execute() { return [] }, - })) - expect(ctx.tools.schemas()[0]).toMatchObject({ name: 'strict-tool', strict: true }) - }) - it('executes a tool and returns its content', async () => { const ctx = await setup() ctx.tools.register(echoTool) @@ -611,44 +599,6 @@ describe('schema DSL edge cases', () => { }) }) - it('defineTool passes through strict flag when set to true', () => { - const tool = defineTool({ - name: 'strict-tool', - description: 'A strict tool', - parameters: { input: { type: 'string' } }, - strict: true, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect(tool.strict).toBe(true) - }) - - it('defineTool omits strict when not provided', () => { - const tool = defineTool({ - name: 'non-strict-tool', - description: 'A non-strict tool', - parameters: { input: { type: 'string' } }, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect('strict' in tool).toBe(false) - }) - - it('defineTool strict=false is included', () => { - const tool = defineTool({ - name: 'explicitly-non-strict', - description: 'Explicitly non-strict', - parameters: { input: { type: 'string' } }, - strict: false, - async execute(args) { - return [{ type: 'text' as const, text: args.input ?? '' }] - }, - }) - expect(tool.strict).toBe(false) - }) - it('handles enum and default together in one property', () => { const spec = { level: { type: 'string', enum: ['low', 'high'], default: 'low' }, diff --git a/packages/llm/llm-deepseek/README.md b/packages/llm/llm-deepseek/README.md index 66d6ae3e9b..71f38993ca 100644 --- a/packages/llm/llm-deepseek/README.md +++ b/packages/llm/llm-deepseek/README.md @@ -28,12 +28,10 @@ A second, independent implementation of the same seam exists in `@deepseek-ai/ds - Streaming only (`stream_options.include_usage` always on). `usage` may arrive attached to the finish chunk or as a trailing usage-only chunk — the translator defers both to `[DONE]`, so `usage` always precedes `finish` and nothing follows `finish`. - The first thinking-mode chunk carries `reasoning_content: ""` — handled (no spurious reasoning block). - **Reasoning passback rule**: on assistant turns that carried tool calls, `reasoning_content` is serialized back in history (required by the API in thinking mode); on tool-call-free turns it is dropped (ignored anyway — saves tokens). -- `strict` on tool schemas passes through (officially Beta; the public API wants the `/beta` base URL for it, the internal endpoint accepts it directly). - Cache accounting: `cacheReadTokens` ← `prompt_cache_hit_tokens` / `prompt_tokens_details.cached_tokens`; DeepSeek reports no cache-write metric. ## Limitations (MVP, documented deliberately) -- `prefill` throws `LlmError('UNSUPPORTED')` — DeepSeek's chat-prefix completion is a Beta feature on the `/beta` base URL; future work. - `tool_choice` is not mapped (not part of the core vocabulary). ## Errors diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index c4e9dcb0a0..bbca37223f 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -15,7 +15,6 @@ * @module dsh-llm-deepseek/serialize */ -import { LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -98,19 +97,8 @@ export function serializeMessages(messages: Message[]): WireMessage[] { return wire } -/** - * Build the full wire request. Throws `LlmError('UNSUPPORTED')` for - * `prefill` (DeepSeek's chat-prefix completion is a Beta feature on a - * different base URL — see README). - */ +/** Build the full wire request. */ export function serializeRequest(options: GenerateOptions, defaults: RequestDefaults = {}): WireRequest { - if (options.prefill !== undefined) { - throw new LlmError( - 'prefill is not supported by the DeepSeek adapter (Beta chat-prefix completion is future work)', - 'UNSUPPORTED', - ) - } - const messages: WireMessage[] = [] if (options.system !== undefined) { messages.push({ role: 'system', content: options.system }) @@ -123,8 +111,6 @@ export function serializeRequest(options: GenerateOptions, defaults: RequestDefa name: tool.name, description: tool.description, parameters: tool.parameters, - // strict is officially supported (Beta); pass the tool author's choice. - ...tool.strict !== undefined ? { strict: tool.strict } : {}, }, })) diff --git a/packages/llm/llm-deepseek/src/types.ts b/packages/llm/llm-deepseek/src/types.ts index 5c212897fd..072c43babb 100644 --- a/packages/llm/llm-deepseek/src/types.ts +++ b/packages/llm/llm-deepseek/src/types.ts @@ -78,8 +78,6 @@ export interface WireTool { name: string description: string parameters: Record<string, unknown> - /** Beta: strict schema adherence (official: requires the /beta base URL). */ - strict?: boolean } } diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 51d4788fe4..3e533f8e7c 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import { CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '@deepseek-ai/dsh-llm-deepseek' @@ -155,17 +155,17 @@ describe('serializeRequest', () => { expect(wire.stop).toEqual(['END']) }) - it('maps tools with strict passthrough', () => { + it('maps tools to the wire function shape', () => { const wire = serializeRequest(request({ messages: history, tools: [ { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } }, - { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true }, + { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } }, ], })) expect(wire.tools).toEqual([ { type: 'function', function: { name: 'a', description: 'A', parameters: { type: 'object', properties: {} } } }, - { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: {} }, strict: true } }, + { type: 'function', function: { name: 'b', description: 'B', parameters: { type: 'object', properties: { x: { type: 'string' } } } } }, ]) }) @@ -185,17 +185,6 @@ describe('serializeRequest', () => { expect(wire.thinking).toBeUndefined() expect(wire.reasoning_effort).toBeUndefined() }) - - it('rejects prefill with an UNSUPPORTED LlmError', () => { - expect(() => serializeRequest(request({ prefill: [{ type: 'text', text: 'Sure' }] }))) - .toThrow(LlmError) - try { - serializeRequest(request({ prefill: [] })) - expect.unreachable() - } catch (error) { - expect((error as LlmError).code).toBe('UNSUPPORTED') - } - }) }) describe('review fixes: assistant content shapes', () => { diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index ca34a8f586..accd1fbbe3 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -9,7 +9,7 @@ DeepSeek adapter for the harness LLM seam backed by [`@earendil-works/pi-ai`](ht - pi-ai hands tool-call `arguments` around as **parsed objects**; the harness keeps raw JSON strings. The adapter patches replay payloads back to the original raw strings before sending them, and re-stringifies parsed output tool calls at `block-end`. - pi-ai reports failures as **in-stream error events** (it never throws mid-stream); these map to `finish {kind:'error'|'aborted'}` chunks — the protocol's other sanctioned error path besides throwing (which llm-deepseek uses). - pi-ai folds reasoning tokens into `usage.output`; there is no separate reasoning count to map. -- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, per-tool `strict`, omitted reasoning effort, raw replayed tool arguments). +- pi-ai's options omit some DeepSeek/OpenAI-compatible details; the adapter uses its `onPayload` hook to preserve the harness contract (`stop`, scrubbing pi-ai's own per-tool `strict` default — the hand-rolled twin sends no such field — omitted reasoning effort, raw replayed tool arguments). ## Config @@ -31,7 +31,7 @@ pi-ai declares the openai/anthropic/google/mistral/AWS SDKs as install-time depe ## Limitations -Same MVP contract as llm-deepseek: `prefill` throws `UNSUPPORTED`, `tool_choice` is not mapped. +Same MVP contract as llm-deepseek: `tool_choice` is not mapped. ## Testing diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index e15cce8252..7d61e51eb4 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -13,9 +13,9 @@ import { stream as piStream } from '@earendil-works/pi-ai' import type { Model } from '@earendil-works/pi-ai' -import { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' +import { LlmAdapter } from '@deepseek-ai/dsh-llm' import { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, StreamChunk, ToolSchema } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { toPiContext, toStreamChunks } from './convert.ts' /** Reasoning levels surfaced by this adapter (DeepSeek wire: high|max). */ @@ -61,7 +61,7 @@ export function buildModel(modelId: string, options: PiAiAdapterOptions): Model< } type Payload = { - tools?: { function?: { name?: unknown; strict?: unknown } }[] + tools?: { function?: { strict?: unknown } }[] messages?: { role?: unknown tool_calls?: { id?: unknown; function?: { arguments?: unknown } }[] @@ -81,10 +81,6 @@ function rawToolArguments(options: GenerateOptions): Map<CallId, string> { return raw } -function strictByToolName(tools: ToolSchema[] | undefined): Map<string, boolean | undefined> { - return new Map((tools ?? []).map(tool => [tool.name, tool.strict])) -} - function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiAiReasoning | undefined): unknown { /* v8 ignore next -- pi-ai onPayload always receives an object; tolerate unusual future hooks defensively */ if (typeof payload !== 'object' || payload === null) return payload @@ -97,16 +93,13 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA body.stop = options.stop } - const strictByName = strictByToolName(options.tools) + // pi-ai stamps its own `strict` default on every serialized tool; the + // harness tool contract has no strict field and the hand-rolled twin sends + // none, so scrub it for wire parity. for (const tool of body.tools ?? []) { /* v8 ignore next -- malformed pi-ai payload guard: real tool entries always carry function */ if (tool.function === undefined) continue - const name = tool.function.name - /* v8 ignore next -- malformed pi-ai payload guard: real function entries always carry a string name */ - if (typeof name !== 'string') continue - const strict = strictByName.get(name) - if (strict === undefined) delete tool.function.strict - else tool.function.strict = strict + delete tool.function.strict } const rawById = rawToolArguments(options) @@ -131,9 +124,9 @@ function patchPayload(payload: unknown, options: GenerateOptions, reasoning: PiA * * Implementation notes: * - `onPayload` patches provider payload details pi-ai cannot express directly: - * stop sequences, per-tool strict, omitted reasoning effort, and raw replayed - * tool-call arguments. - * - `prefill` throws UNSUPPORTED (same contract as dsh-llm-deepseek). + * stop sequences, scrubbing pi-ai's own per-tool `strict` default (the + * hand-rolled twin sends no such field), omitted reasoning effort, and raw + * replayed tool-call arguments. * - pi-ai reports request failures as in-stream error events; convert.ts * maps them to `finish {kind:'error'|'aborted'}` chunks rather than * throwing — both are sanctioned StreamChunk error paths. @@ -144,13 +137,6 @@ export class PiAiAdapter extends LlmAdapter { } async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> { - if (options.prefill !== undefined) { - throw new LlmError( - 'prefill is not supported by the pi-ai adapter', - 'UNSUPPORTED', - ) - } - const model = buildModel(options.model, this.options) // Undefined config means "provider default" (DeepSeek: thinking ENABLED), // matching llm-deepseek's omission semantics. pi-ai derives the wire diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index 63f9f90456..5df4f50bf4 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -2,7 +2,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { CallId, LlmError } from '@deepseek-ai/dsh-llm' +import LlmService, { CallId } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { buildModel, PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { assemble } from './assemble.ts' @@ -149,26 +149,26 @@ describe('PiAiAdapter against a mock server', () => { expect(server.requests[0]).toMatchObject({ stop: ['END'] }) }) - it('preserves per-tool strict exactly through onPayload', async () => { + it('scrubs pi-ai\'s own per-tool strict default through onPayload', async () => { const server = await mockServer([{ events: textEvents }]) const ctx = await harness(server.url) await assemble(ctx,{ model: 'deepseek-v4-flash', messages: [], tools: [ - { name: 'strict_true', description: 'true', parameters: {}, strict: true }, - { name: 'strict_false', description: 'false', parameters: {}, strict: false }, - { name: 'strict_omitted', description: 'omitted', parameters: {} }, + { name: 'alpha', description: 'a', parameters: {} }, + { name: 'beta', description: 'b', parameters: {} }, ], }) + // pi-ai stamps `strict` on every serialized tool function; the harness + // contract has none and the hand-rolled twin sends no such field, so the + // payload fixup must have deleted it from every tool. const request = server.requests[0] as { tools: { function: { name: string; strict?: boolean } }[] } - expect(request.tools.map(tool => [tool.function.name, tool.function.strict])).toEqual([ - ['strict_true', true], - ['strict_false', false], - ['strict_omitted', undefined], - ]) - expect('strict' in request.tools[2]!.function).toBe(false) + expect(request.tools.map(tool => tool.function.name)).toEqual(['alpha', 'beta']) + for (const tool of request.tools) { + expect('strict' in tool.function).toBe(false) + } }) it('preserves raw replayed tool-call arguments in the provider payload', async () => { @@ -209,15 +209,6 @@ describe('PiAiAdapter against a mock server', () => { expect(result.finish).toMatchObject({ kind: 'error', code }) }) - it('rejects prefill with UNSUPPORTED', async () => { - const ctx = await harness('http://127.0.0.1:1') - await expect(assemble(ctx,{ - model: 'deepseek-v4-flash', - messages: [], - prefill: [{ type: 'text', text: 'Sure' }], - })).rejects.toThrow(LlmError) - }) - it('registers/unregisters models on the llm service (HMR safety)', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 61d4769ffa..7163ddc1d9 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -163,7 +163,6 @@ export interface ToolSchema { description: string /** JSON Schema object for the arguments. */ parameters: Record<string, unknown> - strict?: boolean } /** A single model request, fully assembled. */ @@ -174,8 +173,6 @@ export interface GenerateOptions { system?: string /** Tool schemas (adapters map to the provider's `tools` field). */ tools?: ToolSchema[] - /** Assistant prefix continuation (prefill). */ - prefill?: ContentBlock[] temperature?: number maxTokens?: number /** diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 3298c507f8..0ffc3565f4 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -220,7 +220,6 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES function renderTool(schema: ToolSchema, source: string): string[] { const out = [`### \`${schema.name}\``, ''] if (schema.description) out.push(schema.description, '') - if (schema.strict !== undefined) out.push(`Strict: \`${String(schema.strict)}\``, '') out.push('```json', JSON.stringify(schema.parameters, null, 2), '```', '') out.push(`Source: [\`${source}\`](../../${source})`, '') return out From a29bbe1453c643a5cdffec9746d62e751b7cb973 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:06:35 +0800 Subject: [PATCH 29/32] Add JSDoc completeness gate for the cordis surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen-cordis-catalog now hard-errors (aggregated, not fail-fast) when an event lacks description prose or a payload @param, or a public service method lacks JSDoc, a @param per parameter, a @returns on a non-void result, or an explicit return type annotation. The this receiver and the trailing waterfall next are exempt on events (mode machinery owned by @mode); a stale @param naming no real parameter errors, mirroring the @mode contradiction check. parseJsDoc now ends prose at the first block tag (standard JSDoc semantics), so the tags never change the rendered catalog — only Source: line pointers moved. Fills the ~139 gaps found across the 15 surface files, extends the spec with negative-path fixtures for every new guard plus the exemptions, records the decision as an implemented process RFC, and extends the AGENTS.md typed-events bullet with the authoring rule. Runs inside verify-cordis-catalog -> doc-sync, so CI and pre-push enforce it with zero new wiring. --- AGENTS.md | 2 +- docs/cordis-catalog/events-and-services.md | 64 +++---- docs/rfc/README.md | 1 + ...26-07-04-cordis-jsdoc-completeness-gate.md | 33 ++++ packages/bash/bash/src/index.ts | 40 +++- packages/compact/compact/src/index.ts | 1 + packages/core/agent-loop/src/index.ts | 8 + packages/core/agent/src/index.ts | 17 ++ packages/core/agent/src/types.ts | 36 ++++ .../agent/tests/gen-cordis-catalog.spec.ts | 147 ++++++++++++++- packages/core/session/src/index.ts | 24 ++- packages/core/system-prompt/src/index.ts | 7 + packages/core/tools/src/index.ts | 15 ++ packages/fs/fs/src/index.ts | 40 +++- packages/llm/llm/src/index.ts | 11 +- .../session-persistence/src/index.ts | 11 +- packages/subagent/subagent/src/index.ts | 18 +- packages/web/web/src/index.ts | 20 +- scripts/gen-cordis-catalog.ts | 171 ++++++++++++++++-- 19 files changed, 593 insertions(+), 73 deletions(-) create mode 100644 docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md diff --git a/AGENTS.md b/AGENTS.md index 19f279319a..4768fbd1a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Real-API tests and demos read `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_UR - Every npm package is `@deepseek-ai/dsh-<name>`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Cross-package imports use package names, never relative paths; in-package relative imports use explicit `.ts` extensions. Dev/test/demo run unbuilt via tsx + the root tsconfig `paths` map; building is only for consumers outside the repo. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. -- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag — the catalog generator hard-errors without it; mode semantics are in the [generated catalog](docs/cordis-catalog/events-and-services.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md). +- **Typed events via declaration merging**; extensible unions use the merge-extensible-map pattern (`ContentBlockMap`, `SessionEventMap`, …). Every new event's JSDoc carries an `@mode` tag and a `@param` per payload parameter (`this`/trailing `next` exempt); every public service-class method documents each parameter and non-void return (`@param`/`@returns`) — the catalog generator hard-errors otherwise ([completeness RFC](docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md)); mode semantics are in the [generated catalog](docs/cordis-catalog/events-and-services.md) header and [the catalog RFC](docs/rfc/implemented/process/2026-06-20-generated-cordis-catalog.md). - **Discriminated unions: `switch` on the tag**, not if-chains. Closed unions end with `default: assertNever(...)`; merge-extensible unions must NOT — handle known cases and fall through `default` with a comment. - **Waterfall listeners MUST call `next()`** to delegate; returning without it is the veto ([semantics](docs/architecture.md#cordis-waterfall-semantics-important)). - **Plugins, not loop changes**: new behavior goes on the documented extension seams; changing `agent-loop` requires updating docs/architecture.md. diff --git a/docs/cordis-catalog/events-and-services.md b/docs/cordis-catalog/events-and-services.md index ac7ea069b9..875a4e52fa 100644 --- a/docs/cordis-catalog/events-and-services.md +++ b/docs/cordis-catalog/events-and-services.md @@ -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:233`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:234`](../../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:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:241`](../../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:353`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:389`](../../packages/core/agent/src/types.ts) #### `agent/pre-step` — serial @@ -63,7 +63,7 @@ Serial (awaited in registration order), not a waterfall: a listener mutates the Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:305`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:319`](../../packages/core/agent/src/types.ts) #### `agent/prompt-submit` — waterfall @@ -75,7 +75,7 @@ Waterfall: decide what happens to ONE drained queued message before it becomes a Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-structures/core.md) · [MessageSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:315`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:332`](../../packages/core/agent/src/types.ts) #### `agent/queued` — emit @@ -87,7 +87,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:252`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) #### `agent/request` — waterfall @@ -99,7 +99,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:324`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:345`](../../packages/core/agent/src/types.ts) #### `agent/session-start` — emit @@ -111,7 +111,7 @@ The agent's session lifecycle began, fired once before its first turn. `source` Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:265`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:274`](../../packages/core/agent/src/types.ts) #### `agent/status` — emit @@ -123,7 +123,7 @@ Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive lifecycle Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:246`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:250`](../../packages/core/agent/src/types.ts) #### `agent/steering` — emit @@ -135,7 +135,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:347`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:379`](../../packages/core/agent/src/types.ts) #### `agent/step-result` — waterfall @@ -147,7 +147,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:330`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:355`](../../packages/core/agent/src/types.ts) #### `agent/turn-continuation` — waterfall @@ -159,7 +159,7 @@ Waterfall: override the turn-continuation decision via a typed ContinuationDecis Types: [Agent](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:340`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:368`](../../packages/core/agent/src/types.ts) ### `fs/*` @@ -173,7 +173,7 @@ Single-slot decision: produce the optional version guard for the next FileSystem Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:119`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:123`](../../packages/fs/fs/src/index.ts) #### `fs/observed` — emit @@ -185,7 +185,7 @@ Record that an actor observed a target at a version, after a successful read/wri Types: [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:131`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:138`](../../packages/fs/fs/src/index.ts) #### `fs/write-intent` — waterfall @@ -197,7 +197,7 @@ Single-slot decision: produce the write intent for the next FileSystem.writeText Types: [FsTarget](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:107`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:109`](../../packages/fs/fs/src/index.ts) ### `llm/*` @@ -211,7 +211,7 @@ Waterfall around every streaming model call (retry, caching, routing). Bound to Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:31`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:32`](../../packages/llm/llm/src/index.ts) ### `session/*` @@ -223,7 +223,7 @@ A session was created in the store. 'session/created'(session: Session): void ``` -Source: [`packages/core/session/src/index.ts:35`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:36`](../../packages/core/session/src/index.ts) #### `session/event` — emit @@ -235,7 +235,7 @@ An event was appended to a session log (sync, fire-and-forget). This is the per- Types: [SessionEvent](../core-data-structures/core.md) -Source: [`packages/core/session/src/index.ts:41`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:44`](../../packages/core/session/src/index.ts) #### `session/flush` — parallel @@ -245,7 +245,7 @@ Awaited durability checkpoint. The agent loop awaits `ctx.parallel('session/flus 'session/flush'(session: Session): Promise<void> | void ``` -Source: [`packages/core/session/src/index.ts:50`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:54`](../../packages/core/session/src/index.ts) ### `subagent/*` @@ -257,7 +257,7 @@ A subagent run settled — emitted when SubagentRun.result resolves (any stop re 'subagent/end'(info: SubagentRunEndInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:75`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:77`](../../packages/subagent/subagent/src/index.ts) #### `subagent/start` — emit @@ -267,7 +267,7 @@ A subagent run started — emitted after the provider is resolved and its capabi 'subagent/start'(info: SubagentRunInfo): void ``` -Source: [`packages/subagent/subagent/src/index.ts:69`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:70`](../../packages/subagent/subagent/src/index.ts) ### `system-prompt/*` @@ -279,7 +279,7 @@ Waterfall around prompt assembly — mutate or extend the PromptAssembly (sectio 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly> ``` -Source: [`packages/core/system-prompt/src/index.ts:24`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:26`](../../packages/core/system-prompt/src/index.ts) #### `system-prompt/change` — emit @@ -289,7 +289,7 @@ A section or tool provider was registered or unregistered (the assembly inputs c 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:30`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:32`](../../packages/core/system-prompt/src/index.ts) ### `tools/*` @@ -301,7 +301,7 @@ A tool was registered or unregistered (the available tool set changed). 'tools/change'(): void ``` -Source: [`packages/core/tools/src/index.ts:84`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:87`](../../packages/core/tools/src/index.ts) #### `tools/post-execute` — waterfall @@ -313,7 +313,7 @@ Waterfall AFTER a tool runs — where hook plugins inspect the result and accept Types: [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:79`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:82`](../../packages/core/tools/src/index.ts) #### `tools/pre-execute` — waterfall @@ -325,7 +325,7 @@ Waterfall BEFORE a tool runs — the gate where sandbox, permission, and hook pl Types: [ToolExecution](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:65`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:66`](../../packages/core/tools/src/index.ts) ### `web/*` @@ -444,7 +444,7 @@ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: F Types: [FsEditOutcome](../core-data-structures/filesystem.md) · [FsEditRequest](../core-data-structures/filesystem.md) · [FsInfo](../core-data-structures/filesystem.md) · [FsTarget](../core-data-structures/filesystem.md) · [FsVersion](../core-data-structures/filesystem.md) · [FsWriteIntent](../core-data-structures/filesystem.md) · [FsWriteOutcome](../core-data-structures/filesystem.md) -Source: [`packages/fs/fs/src/index.ts:165`](../../packages/fs/fs/src/index.ts) +Source: [`packages/fs/fs/src/index.ts:172`](../../packages/fs/fs/src/index.ts) ### `ctx.llm` — `LlmService` @@ -458,7 +458,7 @@ stream(options: GenerateOptions): AsyncIterable<StreamChunk> Types: [GenerateOptions](../core-data-structures/core.md) · [StreamChunk](../core-data-structures/llm-streaming.md) -Source: [`packages/llm/llm/src/index.ts:69`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:70`](../../packages/llm/llm/src/index.ts) ### `ctx.sessionPersistence` — `SessionPersistence` (abstract seam) @@ -497,7 +497,7 @@ get(id: SessionId): Session | undefined list(): Session[] ``` -Source: [`packages/core/session/src/index.ts:323`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:327`](../../packages/core/session/src/index.ts) ### `ctx.subagents` — `SubagentService` @@ -510,7 +510,7 @@ list(): string[] start(name: string, request: SubagentStartRequest): SubagentRun ``` -Source: [`packages/subagent/subagent/src/index.ts:121`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:123`](../../packages/subagent/subagent/src/index.ts) ### `ctx.systemPrompt` — `SystemPrompt` @@ -522,7 +522,7 @@ tools(provider: () => ToolSchema[]): () => void assemble(): Promise<PromptAssembly> ``` -Source: [`packages/core/system-prompt/src/index.ts:71`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:73`](../../packages/core/system-prompt/src/index.ts) ### `ctx.tools` — `ToolRegistry` @@ -537,7 +537,7 @@ async execute(exec: ToolExecution): Promise<ToolExecutionResult> Types: [ToolDefinition](../core-data-structures/tools.md) · [ToolExecution](../core-data-structures/tools.md) · [ToolExecutionResult](../core-data-structures/tools.md) -Source: [`packages/core/tools/src/index.ts:265`](../../packages/core/tools/src/index.ts) +Source: [`packages/core/tools/src/index.ts:268`](../../packages/core/tools/src/index.ts) ### `ctx.web` — `WebService` diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 2f34a92198..f5550e9378 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -168,6 +168,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | | [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 | | [Documentation tiers, budgets, and the ceiling gate](implemented/process/2026-07-04-doc-tiers-and-budgets.md) | 2026-07-04 | +| [JSDoc completeness gate for the cordis surface](implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md) | 2026-07-04 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md new file mode 100644 index 0000000000..c86fe5533f --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-04-cordis-jsdoc-completeness-gate.md @@ -0,0 +1,33 @@ +# RFC: JSDoc completeness gate for the cordis surface + +Status: implemented (accepted 2026-07-04) + +## Context + +The [generated cordis catalog](2026-06-20-generated-cordis-catalog.md) already walks every harness `interface Events` member and every `ctx.<key>` service class with the TypeScript compiler API, and already hard-errors on a missing `@mode` tag — a forcing function that made dispatch modes impossible to leave undocumented. Nothing equivalent guarded the rest of the JSDoc: a service method could ship with no doc at all, and no event or method documented its parameters or return value individually. A survey at adoption found 5 public service methods with no JSDoc and roughly 139 missing `@param`/`@returns` entries across 15 files — on the product API spine (`ctx.bash`, `ctx.fs`, `ctx.sessions`, …) and the cross-plugin event payload contracts, exactly the surface where "what does this argument mean" is the question a plugin author asks the IDE. + +The AGENTS.md rule ("every export has a JSDoc explaining semantics") is prose-checkable only by review; the repo's stated preference is to encode invariants in mechanical gates. The scope "cordis service functions and events" has a precise machine definition that only the catalog generator knows: events are the `interface Events` members inside `declare module 'cordis'`, and the service surface is the public methods of the class each `interface Context` key names. An ESLint rule cannot see that mapping; the generator computes it on every run. + +## Decision + +Extend `scripts/gen-cordis-catalog.ts` — the same walk, the same `@mode` precedent — to enforce JSDoc COMPLETENESS on everything it catalogs. `verify-cordis-catalog` runs inside `doc-sync`, which both CI and the lefthook pre-push hook already execute, so the gate needs zero new wiring (quality-gates principle: one source of truth). + +The contract: + +- **Events** need description prose plus a non-empty `@param` for every **payload parameter**. A payload parameter is a signature parameter that carries event data; the `this` receiver annotation and the trailing waterfall `next` are exempt — `next` is dispatch machinery whose semantics the `@mode waterfall` tag (and its structural cross-check) already owns, so restating it per event would be boilerplate. Documenting an exempt parameter anyway is allowed; only absence is checked. +- **Service classes** need class-level JSDoc, and every public method needs description prose, a non-empty `@param` per parameter, and a non-empty `@returns` unless the annotated return type is `void`/`Promise<void>` (where `@returns` stays optional — resolution timing can be worth documenting — but is never required). +- **Stale tags error**: an `@param` naming no real parameter is a violation, mirroring the `@mode`-contradicts-signature check. Tag descriptions must be non-empty; their semantic quality beyond that is review's job. +- **Explicitness the walk can check**: the gate is a pure-AST pass (no type checker), so a service method must annotate its return type (an inferred return cannot be classified) and surface parameters must be simple identifiers (a binding pattern has no name for `@param` to match). +- **Violations aggregate** into one error listing every offender — a remediation pass sees the whole list at once. The previously fail-fast `@mode` checks moved into the same aggregated report, with their message texts unchanged. + +The tags are **enforcement-only**: `parseJsDoc` now ends description prose at the first block tag (standard JSDoc semantics, which also stops multi-line tag descriptions from leaking into the catalog as prose), so `@param`/`@returns` never change the rendered catalog. Rendering them — restructuring the services section into per-method entries — was considered and deliberately deferred: source JSDoc plus IDE hover is where method docs are consumed, and the catalog stays an index. No escape-hatch tag exists; the surface is small and curated (12 services, 57 methods, 27 events at adoption), and the point is that the check cannot be waved off. + +Negative-path tests in `packages/core/agent/tests/gen-cordis-catalog.spec.ts` drive `collectEvents`/`collectServices` against synthetic fixtures to prove each guard fires and that the exemptions hold. The authoring rule lives in the root [AGENTS.md](../../../../AGENTS.md) conventions bullet alongside the `@mode` rule. + +## Consequences + +- A new event or service method cannot land with an undocumented parameter or result: the generator refuses to regenerate and `verify-cordis-catalog` fails pre-push and in CI. The ~139 gaps found at adoption were filled in the same change, so the gate landed green. +- The service surface must annotate return types explicitly and use identifier parameters. Neither constraint bound at adoption (every method already annotated; no destructured seam parameters existed); both are now load-bearing requirements a violating change will discover mechanically. +- The general AGENTS.md JSDoc rule ("one-liners when one line suffices") acquires a stricter carve-out on this surface: a one-line summary still suffices only when the method has no parameters and a void result. +- `@param` on `next` or `this` stays legal but unchecked — a deliberate asymmetry: the gate enforces the payload contract and refuses to demand boilerplate. +- The rendered catalog is unchanged by the tags (prose stops at the first block tag). If method-level rendering is wanted later, that is a catalog-design decision to take separately, not a gap in this gate. diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 01c5c081c3..b629f10e4d 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -77,16 +77,32 @@ export abstract class BashExecutor extends Service { * call this, then pass the result to {@link run}/{@link start} — keeping * defaulting in the implementation that owns the config while the seam type * stays explicit (no hidden `?? default` inside run/start). + * @param request - the caller's request; omitted fields get this + * implementation's defaults, capped fields are clamped. + * @returns the fully-specified spec to hand to {@link run}/{@link start}. */ abstract resolve(request: BashExecRequest): BashExecSpec - /** Run a command in the foreground; resolves when it finishes. */ + /** + * Run a command in the foreground; resolves when it finishes. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the outcome; nonzero exits, timeout kills, and abort kills + * resolve with a descriptive result rather than reject. + */ abstract run(spec: BashExecSpec): Promise<BashRunResult> - /** Start a background task and return its handle immediately. */ + /** + * Start a background task and return its handle immediately. + * @param spec - a resolved spec from {@link resolve}, never a raw request. + * @returns the live task handle; completion fires {@link onTaskDone}. + */ abstract start(spec: BashExecSpec): BashTask - /** Look up a background task by id. */ + /** + * Look up a background task by id. + * @param id - the task id to look up. + * @returns the tracked task, or undefined for an id this executor never issued. + */ abstract get(id: BashTaskId): BashTask | undefined /** @@ -101,24 +117,38 @@ export abstract class BashExecutor extends Service { * loudly at the subsequent {@link readOutput}/{@link kill} ("unknown task"). * Storing ownership in the executor (disposed with ITS fiber) — not in the * tool plugin — is what makes ownership survive a `tool-bash` HMR reload. + * @param id - the background task id to look up ownership for. + * @returns the token recorded at start, verbatim; undefined for an unknown + * id or a known-but-ownerless task. */ abstract ownerOf(id: BashTaskId): OwnerToken | undefined - /** All tracked background tasks (insertion order). */ + /** + * All tracked background tasks (insertion order). + * @returns every task this executor started, running or finished. + */ abstract list(): BashTask[] - /** Read output produced since the previous read. Throws for unknown ids. */ + /** + * Read output produced since the previous read. Throws for unknown ids. + * @param id - the task to read from. + * @returns the incremental read; consecutive reads never re-deliver output. + */ abstract readOutput(id: BashTaskId): BashTaskRead /** * Kill a running background task. Returns false when it had already * finished (no-op). Throws for unknown ids. + * @param id - the task to kill. + * @returns true when this call killed it, false when it had already finished. */ abstract kill(id: BashTaskId): boolean /** * Register a background-task completion listener (disposed with the * calling fiber). Listeners never fire after this service is disposed. + * @param listener - called exactly once per task completion. + * @returns the disposer that unregisters the listener. */ onTaskDone(listener: BashTaskListener): () => void { const dispose = this.ctx.effect(() => { diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index f5d03fe2ac..37c94920f3 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -141,6 +141,7 @@ export abstract class CompactService extends Service { * prior replace can leave the surface non-monotonic in seq order), or if * either boundary is not a balanced tool-pairing cut (would split a step's * tool-call/result pair). + * @returns what the compaction did (the replaced range and its summary node). */ abstract compactRegion( session: Session, diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index 9813dadda4..33672dc9b4 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -121,6 +121,9 @@ 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. + * @param id - the agent id; also seeds the generated session id. + * @param options - loop options (model, limits, …); defaults applied per option. + * @returns the running agent, owned by the calling fiber (no handle). */ create(id: AgentId, options: AgentOptions = {}): ReactLoopAgent { this.assertAgentIdFree(id) @@ -142,6 +145,9 @@ export class AgentLoop extends Service implements AgentFactory { * `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. + * @param options - agent id, caller-supplied session id, optional seed/meta, + * and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ createAgent(options: CreateAgentOptions): AgentHandle { // Check the agent id BEFORE preparing the session: register() would reject a @@ -168,6 +174,8 @@ export class AgentLoop extends Service implements AgentFactory { * configured. NOT hard-injected (that would make non-persistent demos pend * forever) — callers that need resume (ACP) inject `sessionPersistence`, so * by the time this runs the service exists. + * @param options - the persisted session id to reload, plus agent id/options. + * @returns the handle for the agent resumed on the reconstructed session. */ async resume(options: ResumeAgentOptions): Promise<AgentHandle> { // Read the service through `ctx.get('sessionPersistence')` — a direct diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 940a5c9db1..ec9ba796b9 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -126,6 +126,8 @@ export class AgentRegistry extends Service { * Register the agent-creation factory (the loop calls this on construction, * effect-scoped). Throws if a factory is already registered. Returns the * disposer; on dispose the factory slot is cleared. + * @param factory - the loop-owned factory {@link create}/{@link resume} delegate to. + * @returns the disposer that clears the factory slot. */ setFactory(factory: AgentFactory): () => void { const dispose = this.ctx.effect(() => { @@ -142,6 +144,8 @@ export class AgentRegistry extends Service { * agent): this constructs the agent and its session. Throws if no factory is * registered. Returns an {@link AgentHandle} — the owner disposes it to tear * down exactly this agent. + * @param options - agent id, session id/seed/metadata, and agent options. + * @returns the handle whose dispose tears down exactly this agent. */ create(options: CreateAgentOptions): AgentHandle { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -152,6 +156,8 @@ export class AgentRegistry extends Service { * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured. Returns an {@link AgentHandle}. + * @param options - the persisted session id plus agent id and options. + * @returns the handle for the resumed agent. */ async resume(options: ResumeAgentOptions): Promise<AgentHandle> { if (this.factory === undefined) throw new Error(NO_FACTORY_MESSAGE) @@ -162,6 +168,8 @@ export class AgentRegistry extends Service { * Register a live agent. Throws if an agent with the same id is already * registered. Emits `agent/created` on registration and `agent/disposed` * when the calling fiber is disposed. Returns the disposer. + * @param agent - the already-constructed agent to record in the store. + * @returns the disposer that removes the agent and emits `agent/disposed`. */ register(agent: Agent): () => void { const dispose = this.ctx.effect(function* (this: AgentRegistry) { @@ -200,10 +208,19 @@ export class AgentRegistry extends Service { return () => void dispose() } + /** + * Look up a live agent. + * @param id - the agent id to look up. + * @returns the agent, or undefined when no live agent has that id. + */ get(id: AgentId): Agent | undefined { return this.store.get(id) } + /** + * All live agents, in registration order. + * @returns a fresh array; mutating it does not affect the registry. + */ list(): Agent[] { return [...this.store.values()] } diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index dd01831130..cbc6743870 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -228,12 +228,14 @@ declare module 'cordis' { /** * An agent was registered in the {@link AgentRegistry} and is ready to * receive messages. + * @param agent - the newly registered agent, already resolvable in the registry. * @mode emit */ 'agent/created'(agent: Agent): void /** * An agent was disposed and removed from the registry; its fiber and any * in-flight turn have been torn down. + * @param agent - the agent that was torn down; its handle is now inert. * @mode emit */ 'agent/disposed'(agent: Agent): void @@ -241,12 +243,17 @@ declare module 'cordis' { * Agent status changed (`idle` ⇄ `running`, or → `disposed`). Drive * lifecycle off this transition, never off a status you just requested — * `send()` does not flip status to `running` before it returns. + * @param agent - the agent whose status flipped. + * @param status - the status just entered (the transition's destination). * @mode emit */ 'agent/status'(agent: Agent, status: AgentStatus): void /** * A message entered the agent's inbox (queued or steering). `source` is * the resolved source (defaults applied), not the caller's raw options. + * @param agent - the agent whose inbox received the message. + * @param content - the enqueued content blocks, verbatim. + * @param info - the resolved source plus whether it entered as steering. * @mode emit */ 'agent/queued'(agent: Agent, content: ContentBlock[], info: { source: MessageSource; steering: boolean }): void @@ -260,6 +267,8 @@ declare module 'cordis' { * so via `agent.inject()` (a `context/message` the first request sees), not * by returning a decision. Cannot block the session from starting; that gap * is deliberate (a bridge logs/injects, it does not gate startup). + * @param agent - the agent whose session lifecycle began. + * @param source - why the session started (fresh startup, resume, …). * @mode emit */ 'agent/session-start'(agent: Agent, source: SessionStartSource): void @@ -295,6 +304,11 @@ declare module 'cordis' { * listener needs to measure pressure (the system prompt counts toward the * budget). `signal` cancels any in-flight work a listener starts (e.g. a * summarization model call). + * @param agent - the agent about to open the step. + * @param turn - the already-open turn this step belongs to. + * @param step - the number of the step about to start. + * @param fullSystemPrompt - the assembled prompt, for measuring token pressure. + * @param signal - aborts in-flight listener work when the turn is torn down. * @mode serial */ // TODO: `fullSystemPrompt` is a smell on a generic per-step seam — compaction @@ -310,6 +324,9 @@ declare module 'cordis' { * turn, per drained message. Maps onto Claude Code's `UserPromptSubmit` hook. * Call `next()` to delegate to the default (allow unchanged), or return a * {@link PromptDecision} without calling `next()` to short-circuit. + * @param agent - the agent draining its inbox. + * @param content - the drained message's blocks, as queued. + * @param source - the message's resolved source. * @mode waterfall */ 'agent/prompt-submit'(agent: Agent, content: ContentBlock[], source: MessageSource, next: () => Promise<PromptDecision>): Promise<PromptDecision> @@ -319,12 +336,20 @@ declare module 'cordis' { * delegate, or return without it to short-circuit. For surface mutation that * must precede history derivation (compaction), use {@link agent/pre-step} * instead — by the time this fires, `options.messages` is already derived. + * @param agent - the agent making the model call. + * @param turn - the open turn number. + * @param step - the step whose request this is. + * @param options - the assembled request; listeners return a transformed copy. * @mode waterfall */ 'agent/request'(agent: Agent, turn: number, step: number, options: GenerateOptions, next: () => Promise<GenerateOptions>): Promise<GenerateOptions> /** * Waterfall: post-process the assembled assistant {@link Message} before * tool dispatch (validation, content rewriting, …). + * @param agent - the agent that received the step's response. + * @param turn - the open turn number. + * @param step - the step that produced the message. + * @param message - the assistant message as assembled from the stream. * @mode waterfall */ 'agent/step-result'(agent: Agent, turn: number, step: number, message: Message, next: () => Promise<Message>): Promise<Message> @@ -335,6 +360,9 @@ declare module 'cordis' { * Listeners force-continue (`/goal`, `/loop` — optionally attaching a * `reason` recorded as next-step steering) or force-stop (budget guards). * Call `next()` to delegate to the default, or return a decision to override. + * @param agent - the agent deciding whether to run another step. + * @param turn - the turn being continued or stopped. + * @param defaultDecision - what the loop would do absent an override. * @mode waterfall */ 'agent/turn-continuation'(agent: Agent, turn: number, defaultDecision: ContinuationDecision, next: () => Promise<ContinuationDecision>): Promise<ContinuationDecision> @@ -342,12 +370,20 @@ declare module 'cordis' { // ---- streaming + tool notifications (emit) ---- /** * Steering content was injected into a running turn. + * @param agent - the agent that absorbed the steering. + * @param turn - the running turn that received it. + * @param content - the injected blocks. + * @param source - the steering message's resolved source. * @mode emit */ 'agent/steering'(agent: Agent, turn: number, content: ContentBlock[], source: MessageSource): void /** * A step or turn errored. The loop reports a failure here (plus the logger) * even when the error has no in-turn position for a session `error` event. + * @param agent - the agent whose turn errored. + * @param turn - the turn in which the failure surfaced. + * @param step - the step at which the failure surfaced. + * @param error - the failure, verbatim. * @mode emit */ 'agent/error'(agent: Agent, turn: number, step: number, error: Error): void diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index ee2ce47699..2d8cd7764f 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -4,17 +4,20 @@ * The generated catalog is frozen by a regenerate-and-diff freshness gate, so * the freshness half is exercised by `pnpm run verify-cordis-catalog` in CI. * What a freshness diff CANNOT prove is that the generator REJECTS malformed - * source the way it promises to — a missing `@mode` tag, or a tag that - * contradicts the signature shape. These tests drive `collectEvents()` against - * synthetic fixture packages to prove each guard fires (and that a well-formed - * event passes), mirroring the drift-guard negative tests for verify-type-equiv. + * source the way it promises to — a missing `@mode` tag, a tag that + * contradicts the signature shape, or a JSDoc-completeness violation (missing + * prose, an undocumented parameter, a stale `@param`, a missing `@returns`, an + * unannotated return type). These tests drive `collectEvents()` / + * `collectServices()` against synthetic fixture packages to prove each guard + * fires (and that well-formed declarations pass), mirroring the drift-guard + * negative tests for verify-type-equiv. */ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { collectEvents } from '../../../../scripts/gen-cordis-catalog.ts' +import { collectEvents, collectServices } from '../../../../scripts/gen-cordis-catalog.ts' /** Write a fixture package exposing one `interface Events` block and return the * scan root to hand `collectEvents`. */ @@ -29,12 +32,31 @@ function fixtureRoot(eventsBlock: string): string { return root } +/** Write a fixture package exposing one `interface Context` entry (`ctx.fix` → + * `FixService`) plus the class source, and return the scan root to hand + * `collectServices`. */ +function serviceFixtureRoot(classSource: string): string { + const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) + const dir = join(root, 'packages', 'group', 'fix', 'src') + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'index.ts'), + `declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, + ) + return root +} + const roots: string[] = [] const make = (block: string): string => { const r = fixtureRoot(block) roots.push(r) return r } +const makeService = (classSource: string): string => { + const r = serviceFixtureRoot(classSource) + roots.push(r) + return r +} afterEach(() => { while (roots.length) rmSync(roots.pop()!, { recursive: true, force: true }) @@ -43,7 +65,7 @@ afterEach(() => { describe('gen-cordis-catalog collectEvents', () => { it('extracts a well-formed event with its @mode and JSDoc', () => { const events = collectEvents(make( - ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ' /**\n * A thing happened.\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', )) expect(events).toHaveLength(1) expect(events[0]).toMatchObject({ name: 'fix/happened', scope: 'fix', mode: 'emit', doc: 'A thing happened.' }) @@ -51,7 +73,7 @@ describe('gen-cordis-catalog collectEvents', () => { it('classifies a trailing-next signature as a waterfall', () => { const events = collectEvents(make( - ' /**\n * Intercept it.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>', + ' /**\n * Intercept it.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/intercept\'(x: number, next: () => Promise<number>): Promise<number>', )) expect(events[0]?.mode).toBe('waterfall') }) @@ -65,19 +87,124 @@ describe('gen-cordis-catalog collectEvents', () => { it('hard-errors when an event is missing its @mode tag', () => { expect(() => collectEvents(make( - ' /** No mode here. */\n \'fix/untagged\'(id: string): void', + ' /** No mode here. */\n \'fix/untagged\'(): void', ))).toThrow(/missing an @mode tag/) }) it('hard-errors when @mode contradicts a trailing-next (waterfall) shape', () => { expect(() => collectEvents(make( - ' /**\n * Mislabeled.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>', + ' /**\n * Mislabeled.\n * @param x - the value.\n * @mode emit\n */\n \'fix/wrong\'(x: number, next: () => Promise<number>): Promise<number>', ))).toThrow(/trailing 'next' parameter .* tagged '@mode emit'/) }) it('hard-errors when @mode waterfall has no trailing next to delegate to', () => { expect(() => collectEvents(make( - ' /**\n * Not actually a waterfall.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void', + ' /**\n * Not actually a waterfall.\n * @param id - which thing.\n * @mode waterfall\n */\n \'fix/nonext\'(id: string): void', ))).toThrow(/tagged '@mode waterfall' but has no trailing 'next'/) }) + + it('hard-errors on an undocumented payload parameter', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/is missing @param id/) + }) + + it('hard-errors on a stale @param naming no real parameter', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @param id - which thing.\n * @param ghost - not a parameter.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/@param ghost does not match any parameter/) + }) + + it('hard-errors on an @param with an empty description', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @param id\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/@param id has an empty description/) + }) + + it('hard-errors on an event whose JSDoc has no description prose', () => { + expect(() => collectEvents(make( + ' /**\n * @param id - which thing.\n * @mode emit\n */\n \'fix/happened\'(id: string): void', + ))).toThrow(/no description prose/) + }) + + it('exempts the `this` receiver and the trailing waterfall `next` from @param', () => { + const events = collectEvents(make( + ' /**\n * Scoped interception.\n * @param x - the value under interception.\n * @mode waterfall\n */\n \'fix/scoped\'(this: object, x: number, next: () => Promise<number>): Promise<number>', + )) + expect(events).toHaveLength(1) + }) + + it('aggregates every violation into one error instead of failing fast', () => { + expect(() => collectEvents(make( + ' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void', + ))).toThrow(/2 JSDoc completeness violation\(s\)[\s\S]*fix\/one[\s\S]*fix\/two/) + }) +}) + +describe('gen-cordis-catalog collectServices', () => { + const WELL_FORMED = `/** Fixture service. */ +export class FixService { + /** + * Do the thing. + * @param id - which thing to do. + * @returns the outcome of doing it. + */ + run(id: string): string { return id } + + /** Fire and forget (void needs no @returns). */ + poke(): void {} + + /** Flush (Promise<void> needs no @returns either). */ + flush(): Promise<void> { return Promise.resolve() } +}` + + it('extracts a well-formed service with its methods and class JSDoc', () => { + const services = collectServices(makeService(WELL_FORMED)) + expect(services).toHaveLength(1) + expect(services[0]).toMatchObject({ key: 'fix', type: 'FixService', abstract: false, doc: 'Fixture service.' }) + expect(services[0]?.methods).toHaveLength(3) + }) + + it('hard-errors on a public method with no JSDoc at all', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n run(id: string): string { return id }\n}', + ))).toThrow(/ctx\.fix\.run .* has no JSDoc/) + }) + + it('hard-errors on an undocumented method parameter', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/ctx\.fix\.run .* is missing @param id/) + }) + + it('hard-errors on a missing @returns for a non-void return type', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/is missing @returns \(return type: string\)/) + }) + + it('hard-errors on an unannotated (inferred) return type', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n */\n run(id: string) { return id }\n}', + ))).toThrow(/no return type annotation/) + }) + + it('hard-errors on a service class with no JSDoc', () => { + expect(() => collectServices(makeService( + 'export class FixService {\n /** Fire and forget. */\n poke(): void {}\n}', + ))).toThrow(/class FixService has no JSDoc/) + }) + + it('hard-errors on a stale method @param', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param ghost - not a parameter.\n */\n poke(): void {}\n}', + ))).toThrow(/@param ghost does not match any parameter/) + }) + + it('ignores private/protected/static members (not the ctx.<key> surface)', () => { + const services = collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}', + )) + expect(services[0]?.methods).toHaveLength(0) + }) }) diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index fee21664ff..fa9bbb5ba4 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -30,12 +30,15 @@ declare module 'cordis' { interface Events { /** * A session was created in the store. + * @param session - the session just entered and announced. * @mode emit */ 'session/created'(session: Session): void /** * An event was appended to a session log (sync, fire-and-forget). This is * the per-append feed a UI or invariant plugin tails. + * @param session - the session whose log grew. + * @param event - the appended event, exactly as recorded. * @mode emit */ 'session/event'(session: Session, event: SessionEvent): void @@ -45,6 +48,7 @@ declare module 'cordis' { * plugins (JSONL, SQLite) drain their write-behind buffers here and on * fiber dispose. Awaited (parallel), not a waterfall: every listener runs * and the loop waits for all of them, but none can veto. + * @param session - the session whose buffered events must reach durable storage. * @mode parallel */ 'session/flush'(session: Session): Promise<void> | void @@ -342,6 +346,9 @@ export class SessionStore extends Service { * {@link prepare} + {@link enter} + {@link announce} (see `dsh-agent-loop`'s * `startOwned`). * + * @param id - the session id; omitted, the store mints `session-<n>`. + * @param options - seed events and/or creation metadata for the header. + * @returns the live session, already entered and announced. * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path (storage backends key directories off it). */ @@ -367,6 +374,9 @@ export class SessionStore extends Service { * chain rather than as racing sibling effects — which would detach `onAppend` * before the loop's closing `session/flush`, dropping the closing events. * + * @param id - the session id; omitted, the store mints `session-<n>`. + * @param options - seed events and/or creation metadata for the header. + * @returns the constructed session, NOT yet in the store. * @throws if a session with `id` already exists, or if `meta.cwd` is a * non-absolute path. */ @@ -404,6 +414,8 @@ export class SessionStore extends Service { * the two back-to-back so they never trip this, but the public seam cannot * assume that. * + * @param session - a {@link prepare}d session not yet in the store. + * @returns the detach disposer (`onAppend = undefined` + store removal). * @throws if a session with this id is already in the store. */ enter(session: Session): () => void { @@ -418,15 +430,25 @@ export class SessionStore extends Service { /** Emit `session/created` for an {@link enter}ed session. Separate from * {@link enter} so the caller can yield the detach disposer first (rollback - * safety — see {@link enter}). */ + * safety — see {@link enter}). + * @param session - the entered session to announce to listeners. */ announce(session: Session): void { this.ctx.emit('session/created', session) } + /** + * Look up a live session. + * @param id - the session id to look up. + * @returns the session, or undefined when no live session has that id. + */ get(id: SessionId): Session | undefined { return this.store.get(id) } + /** + * All live sessions, in creation order. + * @returns a fresh array; mutating it does not affect the store. + */ list(): Session[] { return [...this.store.values()] } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index a5e6e4ed78..37dc10b9ee 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -19,6 +19,8 @@ declare module 'cordis' { * Waterfall around prompt assembly — mutate or extend the * {@link PromptAssembly} (sections + tool schemas) before it is rendered. * Bound to the {@link SystemPrompt} service; call `next()` to delegate. + * @param assembly - the assembly built from the registered sections and + * tool providers; listeners may mutate it or return a replacement. * @mode waterfall */ 'system-prompt/assemble'(this: SystemPrompt, assembly: PromptAssembly, next: () => Promise<PromptAssembly>): Promise<PromptAssembly> @@ -80,6 +82,8 @@ export class SystemPrompt extends Service { * Contribute a text section to the system prompt. Order is determined by * `section.order` (ascending). The section is removed when the calling * fiber is disposed. Emits `system-prompt/change` on register/unregister. + * @param section - the section to contribute (name, order, text or provider). + * @returns the disposer that removes the section. */ section(section: PromptSection): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -105,6 +109,8 @@ export class SystemPrompt extends Service { * Contribute a tool-schema provider that is evaluated at each assembly * call (so it can reflect the live registry state). The provider is * removed when the calling fiber is disposed. Emits `system-prompt/change`. + * @param provider - evaluated at every {@link assemble} for fresh schemas. + * @returns the disposer that removes the provider. */ tools(provider: () => ToolSchema[]): () => void { const dispose = this.ctx.effect(function* (this: SystemPrompt) { @@ -132,6 +138,7 @@ export class SystemPrompt extends Service { * listeners the opportunity to mutate or replace the assembly before it * reaches the model. Await the result before reading the assembly values — * waterfall listeners may be async. + * @returns the assembly after the waterfall has run. */ assemble(): Promise<PromptAssembly> { const assembly: PromptAssembly = { diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 6265e645f9..063e14708e 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -60,6 +60,7 @@ declare module 'cordis' { * tool body never runs. Input rewrite is deliberately NOT offered here (see * {@link PreToolDecision}); `ask` degrades to deny until the permission * system lands (`FIXME(permissions)`). + * @param exec - the pending call (name, parsed arguments, caller agent). * @mode waterfall */ 'tools/pre-execute'(this: ToolRegistry, exec: ToolExecution, next: () => Promise<PreToolDecision>): Promise<PreToolDecision> @@ -74,6 +75,8 @@ declare module 'cordis' { * `execute`'s outer try/catch (and the tool body keeps its own inner * try/catch, so a thrown tool still reaches `post-execute` as an `isError` * result). + * @param exec - the call that just ran (name, parsed arguments, caller agent). + * @param result - the dispatch outcome a listener may accept, replace, or block. * @mode waterfall */ 'tools/post-execute'(this: ToolRegistry, exec: ToolExecution, result: ToolExecutionResult, next: () => Promise<PostToolDecision>): Promise<PostToolDecision> @@ -277,6 +280,9 @@ export class ToolRegistry extends Service { * registered. The tool's schema (minus the `execute` function) is * automatically contributed to the system-prompt assembly. Disposed * with the calling fiber. Emits `tools/change` on register/unregister. + * @param definition - the tool's schema plus its execute (and optional + * presentation) functions. + * @returns the disposer that unregisters the tool. */ register(definition: ToolDefinition): () => void { const dispose = this.ctx.effect(function* (this: ToolRegistry) { @@ -300,6 +306,11 @@ export class ToolRegistry extends Service { return () => void dispose() } + /** + * Look up a registered tool. + * @param name - the tool name as registered. + * @returns the definition, or undefined when no tool has that name. + */ get(name: string): ToolDefinition | undefined { return this.store.get(name) } @@ -313,6 +324,7 @@ export class ToolRegistry extends Service { * those (especially the functions) must never leak into a model request. An * allowlist can't drift when a new non-schema member is added to the * definition; a denylist (rest-destructure) would silently leak it. + * @returns one deep-cloned schema per registered tool, in registration order. */ schemas(): ToolSchema[] { return [...this.store.values()].map(({ name, description, parameters, strict }): ToolSchema => ({ @@ -334,6 +346,9 @@ export class ToolRegistry extends Service { * still inspect. If the tool is not registered, the result is an `isError` * carrying a `UNKNOWN_TOOL` structured error. A thrown {@link HarnessError} * surfaces its `{ name, code }` on the result. + * @param exec - the call to run (name, parsed arguments, caller agent, signal). + * @returns the final result after both waterfalls; failures resolve as + * `isError` results, never rejections. */ async execute(exec: ToolExecution): Promise<ToolExecutionResult> { try { diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index db0135ba80..1e0ab03b85 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -102,6 +102,8 @@ declare module 'cordis' { * chain. The slot is first-wins: the first non-`next()` decider (registration * order, or `prepend`) occupies it; a second decider is a misconfiguration, * not layering. `actor` is the opaque tool-execution context, never read here. + * @param target - the resolved target about to be written. + * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/write-intent'(target: FsTarget, actor: object | undefined, next: () => FsWriteIntent | undefined | Promise<FsWriteIntent | undefined>): Promise<FsWriteIntent | undefined> @@ -114,6 +116,8 @@ declare module 'cordis' { * `{ version: vObserved }`, or throws `FS_NOT_OBSERVED` if the actor is unset * or has not observed the target. Does NOT call `next()`: one decision, * first-wins (see {@link Events.'fs/write-intent'}). + * @param target - the resolved target about to be edited. + * @param actor - the opaque tool-execution context the decider keys off. * @mode waterfall */ 'fs/edit-intent'(target: FsTarget, actor: object | undefined, next: () => { version: FsVersion } | undefined | Promise<{ version: FsVersion } | undefined>): Promise<{ version: FsVersion } | undefined> @@ -126,6 +130,9 @@ declare module 'cordis' { * await listener promises — async or fallible audit/telemetry does not * belong here. No listener ⇒ nothing recorded. `actor` is the opaque * tool-execution context. + * @param target - the target that was read/written/edited. + * @param version - the version the actor now holds as its observation. + * @param actor - the observing tool-execution context; undefined records nothing useful. * @mode emit */ 'fs/observed'(target: FsTarget, version: FsVersion, actor: object | undefined): void @@ -180,13 +187,26 @@ export abstract class FileSystem extends Service { * caller's per-session workspace (`exec.agent.session.header.cwd`) without the * provider depending on `dsh-agent`/`dsh-session`. Mirrors how `dsh-tool-bash` * defaults a bash `workdir` to the session cwd. + * @param path - the path to resolve; relative paths resolve against `opts.cwd`. + * @param opts - `cwd` overrides the backend's default base for relative paths. + * @returns the stable target; the same file yields the same `targetKey`. */ abstract resolve(path: string, opts?: { cwd?: string }): Promise<FsTarget> - /** Return target metadata, or `undefined` when the target does not exist. */ + /** + * Return target metadata, or `undefined` when the target does not exist. + * @param target - the resolved target to stat. + * @param signal - aborts the metadata round-trip. + * @returns metadata only, never content; undefined for an absent target. + */ abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> - /** Read the whole regular text file as a single decoded string. */ + /** + * Read the whole regular text file as a single decoded string. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @returns the full decoded UTF-8 content. + */ abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string> /** @@ -194,12 +214,18 @@ export abstract class FileSystem extends Service { * semantics as {@link readText}, for large files). The backend owns * cross-chunk UTF-8 decoding and binary rejection so the policy layer never * touches raw bytes. + * @param target - the resolved target to read. + * @param signal - aborts the stream, including between chunks. + * @returns the chunk iterable, decoded and validated like {@link readText}. */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. + * @param target - the resolved directory target. + * @param signal - aborts the listing. + * @returns one entry per direct child, in stable name order. */ abstract listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> @@ -208,6 +234,11 @@ export abstract class FileSystem extends Service { * create-vs-replace decision and stale guard when supplied; OMITTING it is an * unconditional create-or-overwrite (the bare provider — no version guard, no * read-first requirement). Atomic either way. + * @param target - the resolved target to write. + * @param content - the full new file content. + * @param expected - the write intent guarding the write; omit for unconditional. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the write produced. */ abstract writeText(target: FsTarget, content: string, expected?: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome> @@ -217,6 +248,11 @@ export abstract class FileSystem extends Service { * matching; OMITTING it edits the current content unconditionally (no version * guard). Either way applies the replacement and writes atomically — one * mutation critical section — and a missing target reports `FS_STALE_VERSION`. + * @param target - the resolved target to edit. + * @param edit - the literal search/replace request. + * @param expected - the version guard; omit for an unconditional edit. + * @param signal - aborts before the atomic rename takes effect. + * @returns the outcome, including the version the edit produced. */ abstract editText(target: FsTarget, edit: FsEditRequest, expected?: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome> } diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 320838a8a6..890a5a132e 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -26,6 +26,7 @@ declare module 'cordis' { * Waterfall around every streaming model call (retry, caching, routing). * Bound to the {@link LlmService}; call `next()` to reach the resolved * adapter's stream, or yield your own chunks to short-circuit. + * @param options - the full request; listeners may rewrite it before delegating. * @mode waterfall */ 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk> @@ -77,6 +78,9 @@ export class LlmService extends Service { * Register an adapter for the given model names. Throws `LlmError` with code * `DUPLICATE_ADAPTER` if any model already has an adapter (all-or-nothing). * Disposed with the fiber. + * @param models - every model name this adapter should serve. + * @param adapter - the adapter that streams calls for those models. + * @returns the disposer that unregisters all of them. */ registerAdapter(models: string[], adapter: LlmAdapter): () => void { const dispose = this.ctx.effect(function* (this: LlmService) { @@ -95,7 +99,10 @@ export class LlmService extends Service { return () => void dispose() } - /** Model names with a registered adapter. */ + /** + * Model names with a registered adapter. + * @returns the registered names, in registration order. + */ models(): string[] { return [...this.adapters.keys()] } @@ -110,6 +117,8 @@ export class LlmService extends Service { * Stream one model call as raw chunks (token-level deltas). Throws * `LlmError` with code `NO_ADAPTER` if no adapter is registered for * `options.model`. Dispatches through the `llm/stream` waterfall. + * @param options - the full request; `options.model` selects the adapter. + * @returns the chunk stream, possibly wrapped by `llm/stream` listeners. */ stream(options: GenerateOptions): AsyncIterable<StreamChunk> { return this.ctx.waterfall(this, 'llm/stream', options, () => { diff --git a/packages/session-persistence/session-persistence/src/index.ts b/packages/session-persistence/session-persistence/src/index.ts index f28bc06d5b..5e1af6e563 100644 --- a/packages/session-persistence/session-persistence/src/index.ts +++ b/packages/session-persistence/session-persistence/src/index.ts @@ -105,6 +105,7 @@ export abstract class SessionPersistence extends Service { * until the first {@link append} (lazy materialization), in which case a * created-but-never-appended session is absent from {@link list} * — abandoned sessions leave nothing behind. + * @param meta - the immutable header (id, version, cwd, lineage) to record. */ abstract create(meta: SessionHeader): Promise<void> @@ -114,6 +115,8 @@ export abstract class SessionPersistence extends Service { * contracts: the first event's `seq` MUST equal the stored next-seq (after * `load` has durably closed any interrupted turn). Rejects non-JSON- * serializable `event.data` with an error naming the offending event type. + * @param id - the session the batch belongs to. + * @param events - the contiguous batch to persist, in seq order. */ abstract append(id: SessionId, events: readonly SessionEvent[]): Promise<void> @@ -138,10 +141,16 @@ export abstract class SessionPersistence extends Service { * COMMITTED region (at or before the last real `turn/end`) makes the session * unloadable (reject). Rejects an unknown format `version`. See the session-persistence RFC for * the crash-recovery contract. + * @param id - the persisted session to reload. + * @returns the header plus the event log, ending on a balanced `turn/end` — + * immediately usable as a session seed. */ abstract load(id: SessionId): Promise<{ meta: SessionHeader; events: SessionEvent[] }> - /** Lightweight listing from metadata, without a full-log parse. */ + /** + * Lightweight listing from metadata, without a full-log parse. + * @returns one header per materialized session. + */ abstract list(): Promise<SessionHeader[]> } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index 926c22d0c8..b0514edcac 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -64,12 +64,14 @@ declare module 'cordis' { * 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']}. + * @param info - which provider started which child agent. * @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']}. + * @param info - the run identity plus stop reason and final output. * @mode emit */ 'subagent/end'(info: SubagentRunEndInfo): void @@ -129,6 +131,8 @@ export class SubagentService extends Service { * 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). + * @param provider - the provider; its `name` is the registry key. + * @returns the disposer that unregisters the provider. */ registerProvider(provider: SubagentProvider): () => void { const dispose = this.ctx.effect(function* (this: SubagentService) { @@ -145,12 +149,19 @@ export class SubagentService extends Service { return () => void dispose() } - /** Look up a registered provider by name (`undefined` if absent). */ + /** + * Look up a registered provider by name (`undefined` if absent). + * @param name - the provider name as registered. + * @returns the provider, or undefined when the name is unknown. + */ getProvider(name: string): SubagentProvider | undefined { return this.providers.get(name) } - /** The names of all registered providers (insertion order). */ + /** + * The names of all registered providers (insertion order). + * @returns the registered provider names. + */ list(): string[] { return [...this.providers.keys()] } @@ -162,6 +173,9 @@ export class SubagentService extends Service { * 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. + * @param name - the provider to run on. + * @param request - the child's prompt, capabilities, and options. + * @returns the live run (its `result` resolves when the child settles). */ start(name: string, request: SubagentStartRequest): SubagentRun { const provider = this.providers.get(name) diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index 50150f6961..76c8065b30 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -129,6 +129,8 @@ export class WebService extends Service { * if its id is already registered for search. Returns a disposer; emits * `web/providers-change` after a successful register and again on dispose. * Disposed with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. */ registerSearchProvider(provider: WebSearchProvider): () => void { return this.registerProvider(this.searchProviders, provider) @@ -139,6 +141,8 @@ export class WebService extends Service { * if its id is already registered for fetch. Returns a disposer; emits * `web/providers-change` after a successful register and again on dispose. * Disposed with the calling fiber. + * @param provider - the provider; its `id` is the registry key. + * @returns the disposer that unregisters the provider. */ registerFetchProvider(provider: WebFetchProvider): () => void { return this.registerProvider(this.fetchProviders, provider) @@ -165,7 +169,10 @@ export class WebService extends Service { return () => void dispose() } - /** Search-capability selection status, derived live (never stored). */ + /** + * Search-capability selection status, derived live (never stored). + * @returns which provider would serve a search right now, or why none would. + */ searchStatus(): WebCapabilityStatus { return resolveStatus({ providers: this.searchProviders, @@ -173,7 +180,10 @@ export class WebService extends Service { }) } - /** Fetch-capability selection status, derived live (never stored). */ + /** + * Fetch-capability selection status, derived live (never stored). + * @returns which provider would serve a fetch right now, or why none would. + */ fetchStatus(): WebCapabilityStatus { return resolveStatus({ providers: this.fetchProviders, @@ -186,6 +196,9 @@ export class WebService extends Service { * time with the selection rules above; throws {@link WebError} when the * capability cannot run. The seam enforces `request.maxResults` on the result: * if the provider over-returns, `sources[]` is truncated and `truncated` set. + * @param request - the query plus result-shaping options. + * @param exec - the tool-execution context, forwarded to the provider. + * @returns the provider's results, capped to `request.maxResults`. */ async search(request: WebSearchRequest, exec?: WebExecContext): Promise<WebSearchResult> { const provider = resolveProvider({ @@ -200,6 +213,9 @@ export class WebService extends Service { * Retrieve one URL through the selected provider. Resolves the provider at * call time with the selection rules above; throws {@link WebError} when the * capability cannot run. A non-2xx response is a result, not a throw. + * @param request - the URL plus retrieval options. + * @param exec - the tool-execution context, forwarded to the provider. + * @returns the retrieval outcome; non-2xx responses resolve descriptively. */ async fetch(request: WebFetchRequest, exec?: WebExecContext): Promise<WebFetchResult> { const provider = resolveProvider({ diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index c57f42ed8f..62cf2bbdc6 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -26,7 +26,18 @@ * Every harness event MUST carry an `@mode emit|waterfall|parallel|serial` tag * — the generator hard-errors on a missing tag, and where the signature shape is * conclusive (a trailing `next: () => …` parameter is structurally a waterfall) - * it asserts the tag agrees and hard-errors on a contradiction. The INHERITED + * it asserts the tag agrees and hard-errors on a contradiction. Beyond the tag, + * the walk enforces JSDoc COMPLETENESS on the whole harness surface (the + * jsdoc-completeness-gate RFC): every event and public service method carries + * description prose; every payload parameter has a non-empty `@param` (`this` + * receivers and the trailing waterfall `next` are exempt — next's semantics are + * documented once by the mode); a service method with a non-`void`/ + * `Promise<void>` return carries a non-empty `@returns` and needs an EXPLICIT + * return type annotation (a pure-AST walk cannot classify an inferred return); + * a stale `@param` naming no real parameter errors. Violations aggregate into + * ONE error listing every offender. The tags are enforcement-only: parseJsDoc + * stops prose at the first block tag, so they never change the rendered + * catalog. The INHERITED * tier (cordis core + loader/hmr/timer) is pinned vendor source a plugin author * also sees; it is rendered tersely (name + one-line + source pointer) from a * curated table in this script, NOT elevated to the harness tier's prominence. @@ -147,8 +158,10 @@ function rawJsDoc(text: string, node: ts.Node): string { * present). Output obeys the repo's markdown conventions so the generated file * passes verify-md-wrap: each prose paragraph collapses to ONE physical line, * and a `-` bullet list is preserved with each item on its own single line - * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`; `@`-tag lines - * other than `@mode` end the current prose run. + * (continuation lines folded in). `{@link Foo}` unwraps to `Foo`. Description + * prose ends at the FIRST block tag (standard JSDoc semantics): tag lines and + * their continuation lines are never prose, so `@param`/`@returns` blocks are + * invisible to the rendered catalog. */ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { const inner = raw @@ -157,6 +170,7 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { .split('\n') .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) let mode: Mode | null = null + let inTags = false const blocks: string[] = [] let para: string[] = [] let list: string[] = [] @@ -178,8 +192,9 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { } for (const line of inner) { const m = /^@mode\s+(emit|waterfall|parallel|serial)\s*$/.exec(line) - if (m) { mode = m[1] as Mode; continue } - if (line.startsWith('@')) { flushPara(); continue } // other tags end the prose + if (m) { mode = m[1] as Mode; flushPara(); inTags = true; continue } + if (line.startsWith('@')) { flushPara(); inTags = true; continue } + if (inTags) continue // block-tag territory: continuations are never prose if (line.trim() === '') { flushPara(); continue } if (/^-\s+/.test(line)) { // A list item starts: a pending paragraph (e.g. an intro line directly @@ -197,6 +212,60 @@ function parseJsDoc(raw: string): { doc: string; mode: Mode | null } { return { doc, mode } } +/** + * Parse the block tags of a raw JSDoc comment for the completeness checks: + * every `@param name — description` entry plus the `@returns` description. + * Standard JSDoc block-tag semantics — a tag's description runs across + * continuation lines until the next tag or a blank line, and the `-`/`—` + * separator after a param name is optional. `[name]` optional-brackets unwrap + * to `name`. Rendering never sees these: parseJsDoc stops prose at the first + * block tag. + */ +function parseTags(raw: string): { params: Map<string, string>; returns: string | null } { + const inner = raw + .replace(/^\/\*\*/, '') + .replace(/\*\/$/, '') + .split('\n') + .map(l => l.replace(/^\s*\*?\s?/, '').replace(/\s+$/, '')) + const params = new Map<string, string>() + let returns: string | null = null + let sink: ((text: string) => void) | null = null + for (const line of inner) { + const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line) + if (param) { + const name = (param[1] ?? '').replace(/^\[|\]$/g, '') + let acc = param[2] ?? '' + params.set(name, acc) + sink = (t) => { acc = acc ? `${acc} ${t}` : t; params.set(name, acc) } + continue + } + const ret = /^@returns?(?:\s+[-—–]?\s*(.*))?$/.exec(line) + if (ret) { + let acc = ret[1] ?? '' + returns = acc + sink = (t) => { acc = acc ? `${acc} ${t}` : t; returns = acc } + continue + } + if (line.startsWith('@') || line.trim() === '') { sink = null; continue } + sink?.(line.trim()) + } + return { params, returns } +} + +/** + * Throw one aggregate error for every completeness violation a walk collected. + * Aggregation (vs the fail-fast the @mode check used to do) is deliberate: a + * remediation pass sees the whole list at once instead of replaying the gate + * once per offender. + */ +function reportViolations(violations: string[]): void { + if (violations.length === 0) return + throw new Error( + `gen-cordis-catalog: ${violations.length} JSDoc completeness violation(s) (see AGENTS.md):\n` + + violations.map(v => ` ${v}`).join('\n'), + ) +} + /** Find the `declare module 'cordis'` body in a source file, or null. */ function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { for (const stmt of sf.statements) { @@ -215,10 +284,13 @@ function memberSignature(member: ts.TypeElement | ts.ClassElement, sf: ts.Source return sig.replace(/\s*;?\s*$/, '').replace(/\s+/g, ' ').trim() } -/** Walk every harness `interface Events` block and extract its events. - * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ +/** Walk every harness `interface Events` block and extract its events, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a missing/ + * contradicted `@mode`, missing description prose, or an undocumented payload + * parameter. `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectEvents(scanRoot: string = root): EventEntry[] { const entries: EventEntry[] = [] + const violations: string[] = [] for (const rel of globSync('packages/*/*/src/*.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') @@ -232,33 +304,63 @@ export function collectEvents(scanRoot: string = root): EventEntry[] { if (!ts.isMethodSignature(member)) continue const name = ts.isStringLiteral(member.name) ? member.name.text : member.name.getText(sf) const signature = memberSignature(member, sf) - const { doc, mode } = parseJsDoc(rawJsDoc(text, member)) + const raw = rawJsDoc(text, member) + const { doc, mode } = parseJsDoc(raw) const src = pointer(rel, sf, member) + const where = `event '${name}' (${src})` if (!mode) { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) + violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`) } // Conclusive structural check: a trailing `next: () => …` parameter is a // waterfall. (emit vs parallel vs serial is not structurally // distinguishable, so it is trusted from the tag.) const last = member.parameters.at(-1) const hasNext = !!last && last.name.getText(sf) === 'next' - if (hasNext && mode !== 'waterfall') { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) + if (mode && hasNext && mode !== 'waterfall') { + violations.push(`${where} has a trailing 'next' parameter (structurally a waterfall) but is tagged '@mode ${mode}'. Fix the tag or the signature.`) } - if (!hasNext && mode === 'waterfall') { - throw new Error(`gen-cordis-catalog: event '${name}' (${src}) is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) + if (mode && !hasNext && mode === 'waterfall') { + violations.push(`${where} is tagged '@mode waterfall' but has no trailing 'next' parameter. A waterfall delegates via next().`) } - entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) + if (!doc) violations.push(`${where} has no description prose. Say what happened / what a listener may do, above the block tags.`) + // Payload parameters need a non-empty @param each. Exempt the `this` + // receiver annotation (not payload) and the trailing waterfall `next` + // (mode machinery, documented once by @mode semantics). Documenting an + // exempt parameter anyway is allowed — only absence is checked. + const { params } = parseTags(raw) + for (const p of member.parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the event surface needs simple identifier parameters so @param can name them.`) + continue + } + const pname = p.name.text + if (pname === 'this' || (hasNext && p === last)) continue + const desc = params.get(pname) + if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) + } + for (const tag of params.keys()) { + if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } + if (mode) entries.push({ name, scope: name.split('/')[0] ?? name, signature, mode, doc, source: src }) } } } + reportViolations(violations) return entries } -/** Walk every harness `interface Context` block + its service class. +/** Walk every harness `interface Context` block + its service class, hard- + * erroring (aggregated) on any JSDoc-completeness violation: a class or public + * method without JSDoc prose, an undocumented parameter, a stale `@param`, a + * missing `@returns` on a non-void method, or an inferred (unannotated) return + * type the pure-AST walk cannot classify. * `scanRoot` defaults to the repo root; tests pass a fixture dir. */ export function collectServices(scanRoot: string = root): ServiceEntry[] { const entries: ServiceEntry[] = [] + const violations: string[] = [] for (const rel of globSync('packages/*/*/src/index.ts', { cwd: scanRoot }).sort()) { const abs = resolve(scanRoot, rel) const text = readFileSync(abs, 'utf8') @@ -284,6 +386,8 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { ) if (!cls) continue // a Pick-mixin member (e.g. timer helpers), not a class here const abstract = cls.modifiers?.some(m => m.kind === ts.SyntaxKind.AbstractKeyword) ?? false + const clsDoc = parseJsDoc(rawJsDoc(text, cls)).doc + if (!clsDoc) violations.push(`service ctx.${key} (${pointer(rel, sf, cls)}): class ${type} has no JSDoc.`) const methods: string[] = [] for (const member of cls.members) { if (!ts.isMethodDeclaration(member)) continue @@ -300,17 +404,52 @@ export function collectServices(scanRoot: string = root): ServiceEntry[] { const memberName = member.name.getText(sf) if (memberName.startsWith('[')) continue // computed/symbol members methods.push(memberSignature(member, sf)) + const where = `service method ctx.${key}.${memberName} (${pointer(rel, sf, member)})` + const raw = rawJsDoc(text, member) + if (!raw) { violations.push(`${where} has no JSDoc.`); continue } + if (!parseJsDoc(raw).doc) violations.push(`${where} has no description prose above its block tags.`) + const { params, returns } = parseTags(raw) + // Every parameter needs a non-empty @param; a `this` receiver + // annotation is not payload and is exempt. + for (const p of member.parameters) { + if (!ts.isIdentifier(p.name)) { + violations.push(`${where}: parameter '${p.name.getText(sf)}' is a binding pattern; the service surface needs simple identifier parameters so @param can name them.`) + continue + } + const pname = p.name.text + if (pname === 'this') continue + const desc = params.get(pname) + if (desc === undefined) violations.push(`${where} is missing @param ${pname}.`) + else if (!desc.trim()) violations.push(`${where}: @param ${pname} has an empty description.`) + } + for (const tag of params.keys()) { + if (!member.parameters.some(p => ts.isIdentifier(p.name) && p.name.text === tag)) { + violations.push(`${where}: @param ${tag} does not match any parameter (stale tag?).`) + } + } + // A non-void result needs a non-empty @returns. The return type must be + // ANNOTATED: a pure-AST walk cannot classify an inferred return. On a + // `void`/`Promise<void>` method @returns stays optional (resolution + // timing can be worth documenting), never required. + const rt = member.type?.getText(sf).replace(/\s+/g, ' ') + if (rt === undefined) { + violations.push(`${where} has no return type annotation; annotate it explicitly so the gate can classify the result.`) + } else if (!/^(void|Promise<void>)$/.test(rt)) { + if (returns === null) violations.push(`${where} is missing @returns (return type: ${rt}).`) + else if (!returns.trim()) violations.push(`${where}: @returns has an empty description.`) + } } entries.push({ key, type, abstract, - doc: parseJsDoc(rawJsDoc(text, cls)).doc, + doc: clsDoc, methods, source: pointer(rel, sf, cls), }) } } + reportViolations(violations) return entries.sort((a, b) => a.key.localeCompare(b.key)) } From d752eed88b2a4777856d0439839a749cd74d6806 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 19:18:11 +0800 Subject: [PATCH 30/32] Cover the remaining gate guards with focused tests Codex review: the RFC claims the fixtures prove each guard fires, but the binding-pattern guards (events + services), the service no-prose branch, and the empty-@param/@returns-description branches had no focused tests. Add the five missing cases; every violation branch in the generator now has a matching fixture. --- .../agent/tests/gen-cordis-catalog.spec.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/core/agent/tests/gen-cordis-catalog.spec.ts b/packages/core/agent/tests/gen-cordis-catalog.spec.ts index 2d8cd7764f..9eac8587a4 100644 --- a/packages/core/agent/tests/gen-cordis-catalog.spec.ts +++ b/packages/core/agent/tests/gen-cordis-catalog.spec.ts @@ -134,6 +134,12 @@ describe('gen-cordis-catalog collectEvents', () => { expect(events).toHaveLength(1) }) + it('hard-errors on a binding-pattern parameter @param cannot name', () => { + expect(() => collectEvents(make( + ' /**\n * A thing happened.\n * @mode emit\n */\n \'fix/destructured\'({ id }: { id: string }): void', + ))).toThrow(/is a binding pattern/) + }) + it('aggregates every violation into one error instead of failing fast', () => { expect(() => collectEvents(make( ' /** First. */\n \'fix/one\'(): void\n /** Second. */\n \'fix/two\'(): void', @@ -201,6 +207,30 @@ export class FixService { ))).toThrow(/@param ghost does not match any parameter/) }) + it('hard-errors on a method whose JSDoc is tags with no description prose', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * @param id - which thing.\n * @returns the outcome.\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/no description prose above its block tags/) + }) + + it('hard-errors on a method @param with an empty description', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Fire and forget.\n * @param id\n */\n poke(id: string): void {}\n}', + ))).toThrow(/@param id has an empty description/) + }) + + it('hard-errors on an @returns with an empty description', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n * @param id - which thing.\n * @returns\n */\n run(id: string): string { return id }\n}', + ))).toThrow(/@returns has an empty description/) + }) + + it('hard-errors on a binding-pattern method parameter @param cannot name', () => { + expect(() => collectServices(makeService( + '/** Fixture service. */\nexport class FixService {\n /**\n * Do the thing.\n */\n run({ id }: { id: string }): void {}\n}', + ))).toThrow(/is a binding pattern/) + }) + it('ignores private/protected/static members (not the ctx.<key> surface)', () => { const services = collectServices(makeService( '/** Fixture service. */\nexport class FixService {\n private hidden(id: string): string { return id }\n protected hook(): void {}\n static helper(): void {}\n}', From 0b97b2f7c103811638a41f45e9c09ca3a08dfd0a Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 22:00:12 +0800 Subject: [PATCH 31/32] =?UTF-8?q?restore=20hook/result=20durationMs=20?= =?UTF-8?q?=E2=80=94=20review=20keeps=20wall-clock=20audit=20timing=20dura?= =?UTF-8?q?ble?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverses item 3 of the tighten-hook-protocol-contract RFC per review: a persistence log is written for future readers, and hook wall-clock runtime is audit signal (which hook made a turn slow). runHook keeps its injected now clock and RunHookResult wrapper, the bridges pass the measured duration through HookResultRecord, the snapshot normalizer keeps its replay scrub, and the hook fixtures carry the field again. The RFC records the reversal; the other three prunes stand. --- docs/core-data-structures/session.md | 4 +- ...26-07-04-tighten-hook-protocol-contract.md | 17 ++++--- .../2026-07-04-hook-snapshot-matrix.md | 2 +- .../tests/snapshot-normalize.spec.ts | 16 ++++--- .../acp-agent/tests/snapshot-normalize.ts | 10 ++++- .../hook-cc-posttool-block/session.jsonl | 6 +-- .../hook-cc-posttool-context/session.jsonl | 2 +- .../hook-cc-pretool-ask/session.jsonl | 2 +- .../hook-cc-pretool-deny/session.jsonl | 2 +- .../hook-cc-promptsubmit-block/session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-cc-stop-continue/session.jsonl | 4 +- .../hook-codex-posttool-block/session.jsonl | 10 ++--- .../hook-codex-posttool-context/session.jsonl | 2 +- .../hook-codex-pretool-block/session.jsonl | 2 +- .../session.jsonl | 2 +- .../session.jsonl | 2 +- .../hook-codex-stop-continue/session.jsonl | 4 +- packages/hooks/hook-protocol/README.md | 6 +-- packages/hooks/hook-protocol/src/events.ts | 3 ++ packages/hooks/hook-protocol/src/index.ts | 2 +- packages/hooks/hook-protocol/src/runner.ts | 24 ++++++++-- packages/hooks/hook-protocol/src/types.ts | 6 ++- .../hooks/hook-protocol/tests/events.spec.ts | 18 ++++---- .../hooks/hook-protocol/tests/runner.spec.ts | 45 ++++++++++--------- packages/hooks/hooks-claude/src/index.ts | 6 +-- packages/hooks/hooks-codex/src/index.ts | 6 +-- 27 files changed, 122 insertions(+), 85 deletions(-) diff --git a/docs/core-data-structures/session.md b/docs/core-data-structures/session.md index 14433d73f5..4d2b47c05c 100644 --- a/docs/core-data-structures/session.md +++ b/docs/core-data-structures/session.md @@ -228,8 +228,8 @@ A plugin may declaration-merge extra `SessionEventMap` types. These are **log-on | Event | Payload | Role | |---|---|---| -| `hook/invoked` | `{ turn, point, dialect, matcher?, handlerId }` | A hook command was invoked at a hook `point` (`PreToolUse`, `Stop`, …). `dialect` is the bridge (`claude`/`codex`); `matcher` the matcher-group pattern that selected it (absent for match-all); `handlerId` correlates with the result. | -| `hook/result` | `{ turn, point, handlerId, decision, exitCode?, stderrSummary? }` | The decided outcome, paired by `handlerId`. `decision` is the neutral outcome `appendHookResult` derives from the parsed output (the hook's decision, else `'stop'` on `continue:false`, else `'pass'`); `exitCode` absent when the hook could not run; `stderrSummary` the trimmed stderr truncated to 500 chars (the block-reason source on exit 2). | +| `hook/invoked` | `{ turn, point, dialect, matcher?, handlerId }` | A hook command was invoked at a hook `point` (`PreToolUse`, `Stop`, …). `dialect` is the bridge (`claude`/`codex`/`native`); `matcher` the matcher-group pattern that selected it (absent for match-all); `handlerId` correlates with the result. | +| `hook/result` | `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }` | The decided outcome, paired by `handlerId`. `decision` is the resolved neutral outcome (`deny`/`allow`/`block`/`stop`/`pass`/…); `exitCode` absent when the hook could not run; `stderrSummary` the truncated block-reason source. | The mid-turn hook points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn, so their `hook/*` records are turn-enclosed by construction. `SessionStart` gets no `hook/*` record — its injected `context/message` is the durable evidence — because it has no open turn to enclose one (see the hooks RFC). diff --git a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md index 90be3c790b..9ee422b130 100644 --- a/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md +++ b/docs/rfc/implemented/simplification/2026-07-04-tighten-hook-protocol-contract.md @@ -4,29 +4,28 @@ Status: implemented (proposed and accepted 2026-07-04) ## Problem -Five pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test: +Four pieces of the `dsh-hook-protocol`/bridge contract missed the discipline the [subagent-observe-enrich RFC](../feature/2026-06-30-subagent-observe-enrich.md) records — it dropped an `agentType` lifecycle field for lacking a consumer, and these failed the same test: 1. **`HookDialect`'s `'native'` variant** (`packages/hooks/hook-protocol/src/types.ts`) had zero producers — the bridges stamp `'claude'` and `'codex'`; the only `'native'` constructor anywhere was the lib's own unit test. The field's own JSDoc defines `dialect` as "the bridge that ran it", and native is not a bridge: the [interception-seams RFC](../feature/2026-06-30-interception-seams.md) records that native hooks are not a package and that "a native plugin can already use the typed Decisions" without the durable hook log, and the flagship native-plugin worked example asserts exactly that (no `hook/*` events at all). 2. **`HookOutput.suppressOutput`** (same file) was parsed by the codec and discarded on every path: no bridge branch, no merge fold, no warn, no deferred-list row — uniquely among its parsed-but-unhonored siblings, each of which carries a stated deferral (`updatedInput` → a logged warn plus the [pre-tool-input-rewrite proposal](../../proposed/feature/2026-06-30-pre-tool-input-rewrite.md); `systemMessage` → a logged warn plus a README deferred row; `continue`/`stopReason` → a `TODO(hook-continue-false)` anchor plus the `'stop'` decision record). Structurally there is nothing to suppress: hook stdout never enters any transcript (context flows only via `additionalContext`; the log records only `decision`/`stderrSummary`), so a hook author setting `suppressOutput: true` got silent nothing with no warn. -3. **`hook/result.durationMs`** was durable timing telemetry with no reader. Both bridges wrote it, and the ACP snapshot normalizer scrubbed it to `0` because wall-clock hook runtime is replay noise (`examples/acp-agent/tests/snapshot-normalize.ts`); the remaining consumers were tests and the goldens that existed because the field existed. Deterministic provenance fields (`point`, `matcher`, `turn`, `handlerId`) earn their durability as audit facts; a nondeterministic field that replay must erase and nothing reads earns neither its bytes nor its special-case scrub. -4. **`defaultTimeoutMs` was double-defaulted in both bridge configs** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`) — the same two-homes-for-one-literal shape the ACP bridge's `TODO(double-default)` flags, for a knob no shipped config set; the per-hook `timeoutSec` is the real timeout surface. -5. **The `hook/result` semantics lived in the bridges, twice, not in the lib that owns the event.** `summarize()` — the 500-character stderr truncation rule — was byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so was the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declared `hook/result`, documented `stderrSummary` as "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently. +3. **`defaultTimeoutMs` was double-defaulted in both bridge configs with a floating literal** — a schema `.default(600_000)` AND a `?? 600_000` fallback (`packages/hooks/hooks-claude/src/index.ts`, `packages/hooks/hooks-codex/src/index.ts`), two homes per bridge for one protocol-level constant, so the bridges could silently drift apart on the shared default. *The proposal's original remedy — delete the knob outright — was overtaken by the no-hardcoded-tunables audit, which kept the knob as the explicit bridge-owned config (and added `stderrSummaryMaxChars` beside it); what remained to fix was the literal's home.* +4. **The `hook/result` semantics lived in the bridges, twice, not in the lib that owns the event.** `summarize()` — the stderr truncation rule — was byte-identical in `packages/hooks/hooks-claude/src/index.ts` and `packages/hooks/hooks-codex/src/index.ts`, and so was the decision-string rule `output.decision ?? (output.continue === false ? 'stop' : 'pass')`; yet `dsh-hook-protocol` declared `hook/result`, documented `stderrSummary` as "truncated" without owning the truncation, and documented the decision values without owning the mapping. If one bridge drifted (a different cap, a different fallback), the shared durable event's semantics would fork silently. ## What shipped -`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `durationMs` is gone from the `hook/result` event, the bridge appends, the docs, and the snapshot normalizer's special-case scrub; with no duration to measure, `runHook`'s injected `now` clock and its single-purpose `RunHookResult` wrapper went too — `runHook` returns the `HookOutput` directly. The committed hook fixtures (`session.jsonl`, which double as the expected-log goldens) had the field stripped mechanically; the stdout goldens never carried it. The bridges' `defaultTimeoutMs` config knob is replaced by one reference-default constant, `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms), exported from the lib's runner and applied inside `runHook`; `RunHookOptions` lost the field entirely, and the per-hook `timeoutSec` stays the override surface. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput`, and `appendHookResult` derives `stderrSummary` (500-character truncation) and the decision string from it; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`). +`HookDialect` is `'claude' | 'codex'`, its JSDoc names the two bridges, and the lib's unit test constructs a `'codex'` invocation. `suppressOutput` is gone from `HookOutput`, the codec's parse, the codec tests, and the parsed-superset lists in the lib README and the [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) (amended per [implemented/AGENTS.md](../AGENTS.md)). `hook/result.durationMs` stays: review judged wall-clock hook runtime worth its bytes as durable audit timing (which hook made a turn slow), so `runHook` keeps its injected `now` clock and `RunHookResult` wrapper, the bridges keep passing the measured duration through `HookResultRecord`, and the snapshot normalizer keeps scrubbing the one nondeterministic field to `0` for replay. On the tunables, the no-hardcoded-tunables audit set the shape this change keeps: `defaultTimeoutMs` and `stderrSummaryMaxChars` stay explicit bridge configs, and `RunHookOptions.defaultTimeoutMs` stays a required parameter the bridge passes in. What this change adds is one home per literal: the reference defaults live in the lib as `DEFAULT_HOOK_TIMEOUT_MS` (600 000 ms, exported from the runner) and `DEFAULT_STDERR_SUMMARY_MAX_CHARS` (500, exported from the events module), and both bridges' schema defaults and `??` fallbacks read those constants instead of restating the numbers. The `hook/result` semantics live in the lib: `HookResultRecord` carries the decoded `HookOutput` plus the bridge's `stderrSummaryMaxChars`, and `appendHookResult` derives `stderrSummary` (via the exported `summarizeStderr(stderr, maxChars)`) and the decision string from them; both bridges deleted their private copies, and the derived values are byte-identical to what the bridges wrote (the goldens prove it — their only diff is the dropped `durationMs`). Rider: `BLOCKING_EXIT_CODE` is a codec-internal const, no longer exported (it had zero importers; even the codec tests spell the literal `2`). ## Why not keep them? -The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; and durable telemetry that replay must scrub is a cost with no buyer. Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events; a trace viewer that reads timings — as live diagnostics or a deliberately durable telemetry event designed for it). On item 5, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. +The [hook-protocol-lib RFC](../feature/2026-06-30-hook-protocol-lib.md) deliberately recorded "parses the full CC superset" — the strongest counterargument was that this proposal re-litigates decisions that RFC records. But parsing a field whose value can never influence anything is not protocol faithfulness, it is a reader trap; a dialect variant that the design's own thesis says will never be stamped is vocabulary without an interpreter; Each returns trivially with its first real consumer (a transcript surface with hook stdout to suppress; a native-provenance feature that logs hook events). On `durationMs` the review reached the opposite verdict: a persistence log is written for future readers, and wall-clock hook timing is audit signal worth carrying before a reader exists — so it stays, with replay normalization as the accepted cost. On item 4, the lib RFC chose per-bridge explicitness over a parameterized engine — but that choice governed payload construction and Decision mapping; the semantics of the SHARED durable event are precisely the "primitives where duplication would actually be dangerous" that the same RFC assigns to the lib. ## Acceptance criteria - `HookDialect` is two-valued; `rg "'native'"` in the hooks packages returns nothing. -- `suppressOutput` and `durationMs` appear nowhere in source, parsed-field doc lists, or the normalizer; the hook fixtures carry no `durationMs` (the refresh was a mechanical field-strip, not a re-record). -- Both bridge configs lost `defaultTimeoutMs`; the reference default lives once, in the lib (`DEFAULT_HOOK_TIMEOUT_MS`); per-hook `timeoutSec` still overrides it. +- `suppressOutput` appears nowhere in source, parsed-field doc lists, or the normalizer; `durationMs` stays on `hook/result` (and in the fixtures), with the normalizer's replay scrub intact. +- Both bridge configs keep `defaultTimeoutMs`/`stderrSummaryMaxChars` (the audit's explicit-tunables shape), but the literals `600_000` and `500` each live once, in the lib's `DEFAULT_HOOK_TIMEOUT_MS`/`DEFAULT_STDERR_SUMMARY_MAX_CHARS`; per-hook `timeoutSec` still overrides the timeout. - One definition each of the truncation rule and the decision-string rule, in `dsh-hook-protocol`'s `appendHookResult`, exercised by both bridges' suites. ## Risks -The `dialect`, `suppressOutput`, `defaultTimeoutMs`, and semantics changes are invisible on the wire and in the goldens; the `durationMs` removal churned the hook fixtures once (a mechanical field-strip — the field was already normalized to a constant). The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. +The `dialect`, `suppressOutput`, tunables, and semantics changes are invisible on the wire and in the goldens. The cost was churn in `dsh-hook-protocol` and both bridges — cheap under the pre-release stance, and cheaper than letting two copies of a durable event's semantics age apart. diff --git a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md index 0a7c0fd4ec..f8ec029d22 100644 --- a/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md +++ b/docs/rfc/implemented/testing/2026-07-04-hook-snapshot-matrix.md @@ -27,7 +27,7 @@ Thirteen scenarios under `examples/acp-agent/tests/snapshots/`, naming `hook-<di - **Authored, no model turn** (keyless, no sidecar — the derived replay script is empty; the `rejected` turn carrying `hook/*` events is compared): `hook-cc-promptsubmit-block`, `hook-codex-promptsubmit-block`. - **Recorded against the real API, hook active during recording** (the model's reaction to the decision is part of the captured transcript, replayed keyless thereafter): `hook-{cc,codex}-promptsubmit-context` (allow + additionalContext fold), `hook-cc-pretool-deny` / `hook-codex-pretool-block` (deny → `isError` tool result), `hook-cc-pretool-ask` (ask → degrades to deny with the approval-required reason), `hook-{cc,codex}-posttool-block` (block with feedback), `hook-{cc,codex}-posttool-context` (accept + additionalContext), `hook-{cc,codex}-stop-continue` (a blocking Stop hook forces one extra step via steering). -Each hook command emits only FIXED LITERAL strings (no timestamps/pids/`$RANDOM`/cwd echoes), and a `hook/result` carries only deterministic fields (`decision`/`exitCode`/`stderrSummary`), so the hook events need no scrubbing beyond the normalizer's generic `time` zeroing. The `Stop` scenarios self-limit with a marker file (`.stop_fired`) so the force-continue does not loop — the `stop_hook_active` loop-guard is still a bridge `TODO`, so an unconditional Stop hook would force-continue every step. +Each hook command emits only FIXED LITERAL strings (no timestamps/pids/`$RANDOM`/cwd echoes); the snapshot normalizer scrubs the one volatile field a `hook/result` carries (`durationMs`). The `Stop` scenarios self-limit with a marker file (`.stop_fired`) so the force-continue does not loop — the `stop_hook_active` loop-guard is still a bridge `TODO`, so an unconditional Stop hook would force-continue every step. ### Three hook points are deliberately NOT snapshotted diff --git a/examples/acp-agent/tests/snapshot-normalize.spec.ts b/examples/acp-agent/tests/snapshot-normalize.spec.ts index be540c9857..bfe29af8a5 100644 --- a/examples/acp-agent/tests/snapshot-normalize.spec.ts +++ b/examples/acp-agent/tests/snapshot-normalize.spec.ts @@ -91,14 +91,20 @@ describe('normalizeSessionLog', () => { expect(out).toContain('{{sessionId}}') }) - it('leaves event data fields untouched beyond the time zeroing (no per-event field scrubs)', () => { + it('zeroes a hook/result durationMs (run-to-run noise) but keeps its decision', () => { const ev = JSON.stringify({ type: 'hook/result', seq: 2, time: 5, - data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2 }, + data: { turn: 1, point: 'UserPromptSubmit', handlerId: 'h', decision: 'block', exitCode: 2, durationMs: 37 }, }) const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) - expect(out).toContain('"decision":"block"') - expect(out).toContain('"exitCode":2') - expect(out).toContain('"time":0') + expect(out).toContain('"durationMs":0') + expect(out).not.toContain('37') + expect(out).toContain('"decision":"block"') // the decision is the behavior — kept + }) + + it('leaves a non-hook event durationMs untouched (only hook/result is scrubbed)', () => { + const ev = JSON.stringify({ type: 'tool/result', seq: 2, time: 5, data: { durationMs: 88 } }) + const out = normalizeSessionLog(`${header({})}\n${ev}\n`, ctx) + expect(out).toContain('"durationMs":88') }) }) diff --git a/examples/acp-agent/tests/snapshot-normalize.ts b/examples/acp-agent/tests/snapshot-normalize.ts index db0d493535..8150057fa4 100644 --- a/examples/acp-agent/tests/snapshot-normalize.ts +++ b/examples/acp-agent/tests/snapshot-normalize.ts @@ -8,7 +8,8 @@ * Scrubbed: `randomUUID()` session ids → `{{sessionId}}`; the temp `mkdtemp` * cwd → `{{cwd}}` (it appears in terminal-card `_meta` and the log header); * JSON-RPC request `id` → a stable per-transcript sequence; the log's per-event - * `time` (epoch ms) and header `createdAt` → 0. NOT scrubbed: the log's `seq` + * `time` (epoch ms) and header `createdAt` → 0; a `hook/result` event's + * `durationMs` (wall-clock hook runtime) → 0. NOT scrubbed: the log's `seq` * (deterministic — `seq = log.length`, part of the event-log contract). * * See docs/rfc/implemented/testing/2026-06-19-acp-snapshot-tests.md. @@ -97,6 +98,13 @@ export function normalizeSessionLog(rawLog: string, ctx: NormalizeContext): stri } else if ('time' in record) { // Event line: zero the epoch-ms timestamp; keep seq (deterministic). record.time = 0 + // A hook/result carries the hook's wall-clock runtime (`data.durationMs`), + // which is run-to-run noise like `time` — zero it so the golden reflects + // the hook's decision/exit, not how long the shell took. + if (record.type === 'hook/result' && record.data !== null && typeof record.data === 'object') { + const data = record.data as Record<string, unknown> + if ('durationMs' in data) data.durationMs = 0 + } } return scrubValue(record, ctx) as Record<string, unknown> }) diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl index 1cf98ff718..a9bcac1b03 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl @@ -61,7 +61,7 @@ {"type":"assistant/message","seq":59,"time":1783095159852,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run the command `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":91,"cacheReadTokens":1664,"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,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58],"surfaceOp":"append"} {"type":"tool/call","seq":60,"time":1783095159852,"data":{"turn":1,"step":1,"callId":"call_00_e9zAlNQhIVFKzoStUuWI7161","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":61,"time":1783095159867,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":62,"time":1783095159875,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead"}} +{"type":"hook/result","seq":62,"time":1783095159875,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":7.870597999999973}} {"type":"tool/result","seq":63,"time":1783095159875,"data":{"turn":1,"step":1,"callId":"call_00_e9zAlNQhIVFKzoStUuWI7161","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[60],"surfaceOp":"append"} {"type":"step/end","seq":64,"time":1783095159875,"data":{"turn":1,"step":1}} {"type":"step/start","seq":65,"time":1783095159876,"data":{"turn":1,"step":2}} @@ -166,7 +166,7 @@ {"type":"assistant/message","seq":164,"time":1783095161879,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The tool output was rejected by policy. I need to rerun with a description/summary. Let me try again - I already included a description. Let me check if maybe the description needs to be more descriptive, or if there's another issue.\n\nLet me try again with a slightly different approach to see what happens."},{"type":"tool-call","id":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}],"usage":{"inputTokens":187,"outputTokens":133,"cacheReadTokens":1664,"reasoningTokens":65}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":165,"time":1783095161879,"data":{"turn":1,"step":2,"callId":"call_00_SFRulyHdhTsyfllc6Gro3716","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo the string HELLO\"}"}} {"type":"hook/invoked","seq":166,"time":1783095161893,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":167,"time":1783095161902,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead"}} +{"type":"hook/result","seq":167,"time":1783095161902,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":8.773494000000028}} {"type":"tool/result","seq":168,"time":1783095161902,"data":{"turn":1,"step":2,"callId":"call_00_SFRulyHdhTsyfllc6Gro3716","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[165],"surfaceOp":"append"} {"type":"step/end","seq":169,"time":1783095161903,"data":{"turn":1,"step":2}} {"type":"step/start","seq":170,"time":1783095161903,"data":{"turn":1,"step":3}} @@ -282,7 +282,7 @@ {"type":"assistant/message","seq":280,"time":1783095163935,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"It seems the tool output is being rejected by policy repeatedly. Let me try a different command or approach - perhaps the issue is unrelated to my command. Let me try with a longer description or a different format.\n\nActually, I think the policy rejection might be about the content/output being shown. Let me try running it and just report what I know.\n\nLet me try one more time."},{"type":"tool-call","id":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":216,"outputTokens":144,"cacheReadTokens":1792,"reasoningTokens":77}},"sourceEventSeqs":[171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,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],"surfaceOp":"append"} {"type":"tool/call","seq":281,"time":1783095163935,"data":{"turn":1,"step":3,"callId":"call_00_9CQs2NzrhnwYjdvmjsR10424","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} {"type":"hook/invoked","seq":282,"time":1783095163944,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:3","matcher":"bash"}} -{"type":"hook/result","seq":283,"time":1783095163951,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead"}} +{"type":"hook/result","seq":283,"time":1783095163951,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by policy: rerun with a summary instead","durationMs":6.553152000000409}} {"type":"tool/result","seq":284,"time":1783095163951,"data":{"turn":1,"step":3,"callId":"call_00_9CQs2NzrhnwYjdvmjsR10424","content":[{"type":"text","text":"tool output rejected by policy: rerun with a summary instead"}],"isError":true},"sourceEventSeqs":[281],"surfaceOp":"append"} {"type":"step/end","seq":285,"time":1783095163951,"data":{"turn":1,"step":3}} {"type":"step/start","seq":286,"time":1783095163951,"data":{"turn":1,"step":4}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl index aa28dfdeb9..053ce1c251 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095113150,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095113150,"data":{"turn":1,"step":1,"callId":"call_00_upvgqMKJ4hJck9LxQn0p5500","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095113166,"data":{"turn":1,"point":"PostToolUse","dialect":"claude","handlerId":"claude:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095113175,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0}} +{"type":"hook/result","seq":60,"time":1783095113175,"data":{"turn":1,"point":"PostToolUse","handlerId":"claude:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":8.75206100000014}} {"type":"tool/result","seq":61,"time":1783095113175,"data":{"turn":1,"step":1,"callId":"call_00_upvgqMKJ4hJck9LxQn0p5500","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"context/message","seq":62,"time":1783095113176,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/end","seq":63,"time":1783095113176,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl index 727e349208..b0e7a5f00f 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095043785,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095043786,"data":{"turn":1,"step":1,"callId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095043786,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095043800,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0}} +{"type":"hook/result","seq":60,"time":1783095043800,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"ask","exitCode":0,"durationMs":12.982377999999699}} {"type":"tool/result","seq":61,"time":1783095043800,"data":{"turn":1,"step":1,"callId":"call_00_YmAP6EwhDlZ4q9v2uPdB5865","content":[{"type":"text","text":"Error: bash requires manual approval in this session"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"step/end","seq":62,"time":1783095043800,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1783095043801,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl index 1aab4b5493..501f3a8594 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095009899,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":1736,"outputTokens":89,"cacheReadTokens":0,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095009899,"data":{"turn":1,"step":1,"callId":"call_00_NQfQgkyjofpjsaiEUcsX0103","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095009900,"data":{"turn":1,"point":"PreToolUse","dialect":"claude","handlerId":"claude:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095009915,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session"}} +{"type":"hook/result","seq":60,"time":1783095009915,"data":{"turn":1,"point":"PreToolUse","handlerId":"claude:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by policy in this session","durationMs":14.778092000000015}} {"type":"tool/result","seq":61,"time":1783095009915,"data":{"turn":1,"step":1,"callId":"call_00_NQfQgkyjofpjsaiEUcsX0103","content":[{"type":"text","text":"Error: bash is disabled by policy in this session"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"step/end","seq":62,"time":1783095009916,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1783095009916,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl index 6205340c2a..b5f81fdaea 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-block/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook"}} +{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by policy hook","durationMs":0}} {"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by policy hook"}} {"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl index d3b341eeee..33cf2c8ba0 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"b0c9d2c7-f95b-4750-be8a-10121253b006","createdAt":1783095036603,"cwd":"/tmp/acp-snap-cwd-LW2rSZ"} {"type":"turn/start","seq":0,"time":1783095036609,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":1783095036610,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"claude","handlerId":"claude:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":1783095036623,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0}} +{"type":"hook/result","seq":2,"time":1783095036623,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"claude:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":11.874454999999898}} {"type":"user/message","seq":3,"time":1783095036623,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":4,"time":1783095036623,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783095036624,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl index f70d047033..7e584998e7 100644 --- a/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl @@ -30,7 +30,7 @@ {"type":"assistant/message","seq":28,"time":1783095185826,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}},"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],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1783095185826,"data":{"turn":1,"step":1}} {"type":"hook/invoked","seq":30,"time":1783095185827,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:1"}} -{"type":"hook/result","seq":31,"time":1783095185846,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop."}} +{"type":"hook/result","seq":31,"time":1783095185846,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":19.14465199999995}} {"type":"steering/message","seq":32,"time":1783095185846,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-claude"}},"surfaceOp":"append"} {"type":"step/start","seq":33,"time":1783095185847,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":34,"time":1783095186554,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -234,5 +234,5 @@ {"type":"assistant/message","seq":232,"time":1783095187944,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user initially asked me to reply with \"FIRST\" and stop, which I did. Now there's a plugin steering message asking me to also reply with \"SECOND\" and stop. However, the user's original instruction said \"Reply with the single word FIRST and stop.\" I already did that. The plugin is trying to add an additional instruction, but I should follow the original user instruction which said to stop after \"FIRST\".\n\nActually, looking more carefully - I already responded with \"FIRST\" in my previous turn. Now the user is sending a new message with a plugin steering. Let me consider what to do.\n\nThe plugin says \"Also reply with the single word SECOND, then stop.\" But the user's original request was to reply with \"FIRST\" and stop. I already did that. Now I have a new instruction from the plugin. I think I should follow this new instruction and reply with \"SECOND\"."},{"type":"text","text":"SECOND"}],"usage":{"inputTokens":82,"outputTokens":193,"cacheReadTokens":1664,"reasoningTokens":190}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231],"surfaceOp":"append"} {"type":"step/end","seq":233,"time":1783095187944,"data":{"turn":1,"step":2}} {"type":"hook/invoked","seq":234,"time":1783095187944,"data":{"turn":1,"point":"Stop","dialect":"claude","handlerId":"claude:Stop:2"}} -{"type":"hook/result","seq":235,"time":1783095187954,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0}} +{"type":"hook/result","seq":235,"time":1783095187954,"data":{"turn":1,"point":"Stop","handlerId":"claude:Stop:2","decision":"pass","exitCode":0,"durationMs":9.226673000000119}} {"type":"turn/end","seq":236,"time":1783095187954,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl index 6dfbd767cb..ff774c4643 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095423411,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095423412,"data":{"turn":1,"step":1,"callId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095423425,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095423432,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} +{"type":"hook/result","seq":60,"time":1783095423432,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":6.750618000000031}} {"type":"tool/result","seq":61,"time":1783095423432,"data":{"turn":1,"step":1,"callId":"call_00_NN9uJWZQDPiUfGC6VAtF0696","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"step/end","seq":62,"time":1783095423432,"data":{"turn":1,"step":1}} {"type":"step/start","seq":63,"time":1783095423433,"data":{"turn":1,"step":2}} @@ -117,7 +117,7 @@ {"type":"assistant/message","seq":115,"time":1783095424733,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The output was rejected by codex policy. Let me try again with the exact command as specified."},{"type":"tool-call","id":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}],"usage":{"inputTokens":55,"outputTokens":86,"cacheReadTokens":1792,"reasoningTokens":20}},"sourceEventSeqs":[64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114],"surfaceOp":"append"} {"type":"tool/call","seq":116,"time":1783095424733,"data":{"turn":1,"step":2,"callId":"call_00_vYk35aagabBlYGHTvJqn7418","name":"bash","arguments":"{\"description\": \"Run echo HELLO\", \"command\": \"echo HELLO\"}"}} {"type":"hook/invoked","seq":117,"time":1783095424745,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:2","matcher":"bash"}} -{"type":"hook/result","seq":118,"time":1783095424753,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} +{"type":"hook/result","seq":118,"time":1783095424753,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:2","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":7.9194109999998545}} {"type":"tool/result","seq":119,"time":1783095424753,"data":{"turn":1,"step":2,"callId":"call_00_vYk35aagabBlYGHTvJqn7418","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[116],"surfaceOp":"append"} {"type":"step/end","seq":120,"time":1783095424753,"data":{"turn":1,"step":2}} {"type":"step/start","seq":121,"time":1783095424753,"data":{"turn":1,"step":3}} @@ -175,7 +175,7 @@ {"type":"assistant/message","seq":173,"time":1783095426479,"data":{"turn":1,"step":3,"content":[{"type":"reasoning","text":"The tool output is being rejected by codex policy. Let me try a slightly different approach - maybe I need to be more careful with the description or command."},{"type":"tool-call","id":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}],"usage":{"inputTokens":163,"outputTokens":78,"cacheReadTokens":1792,"reasoningTokens":32}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":174,"time":1783095426479,"data":{"turn":1,"step":3,"callId":"call_00_XLxXjqVljRIC2KwFZCKX3369","name":"bash","arguments":"{\"command\": \"echo HELLO\"}"}} {"type":"hook/invoked","seq":175,"time":1783095426479,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:3","matcher":"bash"}} -{"type":"hook/result","seq":176,"time":1783095426488,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} +{"type":"hook/result","seq":176,"time":1783095426488,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:3","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":8.644037999999455}} {"type":"tool/result","seq":177,"time":1783095426488,"data":{"turn":1,"step":3,"callId":"call_00_XLxXjqVljRIC2KwFZCKX3369","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[174],"surfaceOp":"append"} {"type":"step/end","seq":178,"time":1783095426488,"data":{"turn":1,"step":3}} {"type":"step/start","seq":179,"time":1783095426489,"data":{"turn":1,"step":4}} @@ -222,7 +222,7 @@ {"type":"assistant/message","seq":220,"time":1783095427668,"data":{"turn":1,"step":4,"content":[{"type":"reasoning","text":"The tool is consistently rejecting the output. Let me try a different way to invoke it."},{"type":"tool-call","id":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}],"usage":{"inputTokens":135,"outputTokens":67,"cacheReadTokens":1920,"reasoningTokens":18}},"sourceEventSeqs":[180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219],"surfaceOp":"append"} {"type":"tool/call","seq":221,"time":1783095427668,"data":{"turn":1,"step":4,"callId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","name":"bash","arguments":"{\"command\": \"printf 'HELLO\\\\n'\"}"}} {"type":"hook/invoked","seq":222,"time":1783095427668,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:4","matcher":"bash"}} -{"type":"hook/result","seq":223,"time":1783095427677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} +{"type":"hook/result","seq":223,"time":1783095427677,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:4","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":8.75036799999998}} {"type":"tool/result","seq":224,"time":1783095427677,"data":{"turn":1,"step":4,"callId":"call_00_twNfzWSJ9WJn8YeoGVIi1932","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[221],"surfaceOp":"append"} {"type":"step/end","seq":225,"time":1783095427678,"data":{"turn":1,"step":4}} {"type":"step/start","seq":226,"time":1783095427678,"data":{"turn":1,"step":5}} @@ -286,7 +286,7 @@ {"type":"assistant/message","seq":284,"time":1783095429025,"data":{"turn":1,"step":5,"content":[{"type":"reasoning","text":"Every attempt is being rejected. Let me try to see what's happening by running a different command first, to check if bash works at all."},{"type":"text","text":"Let me check if bash itself is working:"},{"type":"tool-call","id":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}],"usage":{"inputTokens":96,"outputTokens":81,"cacheReadTokens":2048,"reasoningTokens":29}},"sourceEventSeqs":[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],"surfaceOp":"append"} {"type":"tool/call","seq":285,"time":1783095429025,"data":{"turn":1,"step":5,"callId":"call_00_xOnTckie072jHuPhX0Mz5115","name":"bash","arguments":"{\"command\": \"pwd\"}"}} {"type":"hook/invoked","seq":286,"time":1783095429025,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:5","matcher":"bash"}} -{"type":"hook/result","seq":287,"time":1783095429046,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead"}} +{"type":"hook/result","seq":287,"time":1783095429046,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:5","decision":"block","exitCode":2,"stderrSummary":"tool output rejected by codex policy: summarize instead","durationMs":21.25910400000066}} {"type":"tool/result","seq":288,"time":1783095429047,"data":{"turn":1,"step":5,"callId":"call_00_xOnTckie072jHuPhX0Mz5115","content":[{"type":"text","text":"tool output rejected by codex policy: summarize instead"}],"isError":true},"sourceEventSeqs":[285],"surfaceOp":"append"} {"type":"step/end","seq":289,"time":1783095429047,"data":{"turn":1,"step":5}} {"type":"step/start","seq":290,"time":1783095429047,"data":{"turn":1,"step":6}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl index 2af2c979ab..1a93694456 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl @@ -59,7 +59,7 @@ {"type":"assistant/message","seq":57,"time":1783095440719,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run `echo HELLO` using the bash tool and report the result verbatim."},{"type":"tool-call","id":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}],"usage":{"inputTokens":72,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":23}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":58,"time":1783095440719,"data":{"turn":1,"step":1,"callId":"call_00_TArPQZJxir9dawrAg0Fb9098","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Run echo HELLO\"}"}} {"type":"hook/invoked","seq":59,"time":1783095440731,"data":{"turn":1,"point":"PostToolUse","dialect":"codex","handlerId":"codex:PostToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":60,"time":1783095440739,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0}} +{"type":"hook/result","seq":60,"time":1783095440739,"data":{"turn":1,"point":"PostToolUse","handlerId":"codex:PostToolUse:1","decision":"pass","exitCode":0,"durationMs":7.645890999999665}} {"type":"tool/result","seq":61,"time":1783095440739,"data":{"turn":1,"step":1,"callId":"call_00_TArPQZJxir9dawrAg0Fb9098","content":[{"type":"text","text":"HELLO\n"}],"isError":false},"sourceEventSeqs":[58],"surfaceOp":"append"} {"type":"context/message","seq":62,"time":1783095440739,"data":{"content":[{"type":"text","text":"Note: command output has been verified against the audit log."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/end","seq":63,"time":1783095440739,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl index 1f08a5ec27..0e6d3ac407 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl @@ -54,7 +54,7 @@ {"type":"assistant/message","seq":52,"time":1783095409582,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and report the result verbatim."},{"type":"tool-call","id":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}],"usage":{"inputTokens":72,"outputTokens":84,"cacheReadTokens":1664,"reasoningTokens":17}},"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],"surfaceOp":"append"} {"type":"tool/call","seq":53,"time":1783095409582,"data":{"turn":1,"step":1,"callId":"call_00_d1KxP9oXmTPECtwtxuVc9576","name":"bash","arguments":"{\"command\": \"echo HELLO\", \"description\": \"Print HELLO to stdout\"}"}} {"type":"hook/invoked","seq":54,"time":1783095409583,"data":{"turn":1,"point":"PreToolUse","dialect":"codex","handlerId":"codex:PreToolUse:1","matcher":"bash"}} -{"type":"hook/result","seq":55,"time":1783095409598,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session"}} +{"type":"hook/result","seq":55,"time":1783095409598,"data":{"turn":1,"point":"PreToolUse","handlerId":"codex:PreToolUse:1","decision":"block","exitCode":2,"stderrSummary":"bash is disabled by codex policy in this session","durationMs":15.254155000000083}} {"type":"tool/result","seq":56,"time":1783095409599,"data":{"turn":1,"step":1,"callId":"call_00_d1KxP9oXmTPECtwtxuVc9576","content":[{"type":"text","text":"Error: bash is disabled by codex policy in this session"}],"isError":true},"sourceEventSeqs":[53],"surfaceOp":"append"} {"type":"step/end","seq":57,"time":1783095409599,"data":{"turn":1,"step":1}} {"type":"step/start","seq":58,"time":1783095409599,"data":{"turn":1,"step":2}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl index e85fc3595a..bc9144f980 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-block/session.jsonl @@ -1,6 +1,6 @@ {"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}"} {"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook"}} +{"type":"hook/result","seq":2,"time":0,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"block","exitCode":2,"stderrSummary":"blocked by codex policy hook","durationMs":0}} {"type":"prompt/blocked","seq":3,"time":0,"data":{"content":[{"type":"text","text":"Delete everything in the repo."}],"source":{"kind":"user"},"reason":"blocked by codex policy hook"}} {"type":"turn/end","seq":4,"time":0,"data":{"turn":1,"reason":{"kind":"rejected","reason":"blocked by codex policy hook"}}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl index 487e3d3769..2e37a148e9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl @@ -1,7 +1,7 @@ {"type":"session","version":0,"id":"09a657ad-72c0-4fd3-aae1-00b66dfdb3d5","createdAt":1783095399158,"cwd":"/tmp/acp-snap-cwd-rBmbYp"} {"type":"turn/start","seq":0,"time":1783095399163,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} {"type":"hook/invoked","seq":1,"time":1783095399164,"data":{"turn":1,"point":"UserPromptSubmit","dialect":"codex","handlerId":"codex:UserPromptSubmit:1"}} -{"type":"hook/result","seq":2,"time":1783095399180,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0}} +{"type":"hook/result","seq":2,"time":1783095399180,"data":{"turn":1,"point":"UserPromptSubmit","handlerId":"codex:UserPromptSubmit:1","decision":"pass","exitCode":0,"durationMs":15.786223999999947}} {"type":"user/message","seq":3,"time":1783095399180,"data":{"content":[{"type":"text","text":"What is my favorite color? Reply with just the color and stop. Do not use any tools."}],"source":{"kind":"user"}},"surfaceOp":"append"} {"type":"context/message","seq":4,"time":1783095399180,"data":{"content":[{"type":"text","text":"The user has previously stated their favorite color is teal."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/start","seq":5,"time":1783095399181,"data":{"turn":1,"step":1}} diff --git a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl index 3e9107d32b..09801dd4d9 100644 --- a/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl +++ b/examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl @@ -30,7 +30,7 @@ {"type":"assistant/message","seq":28,"time":1783095446572,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with the single word \"FIRST\" and stop."},{"type":"text","text":"FIRST"}],"usage":{"inputTokens":55,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":17}},"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],"surfaceOp":"append"} {"type":"step/end","seq":29,"time":1783095446573,"data":{"turn":1,"step":1}} {"type":"hook/invoked","seq":30,"time":1783095446573,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:1"}} -{"type":"hook/result","seq":31,"time":1783095446588,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop."}} +{"type":"hook/result","seq":31,"time":1783095446588,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:1","decision":"block","exitCode":2,"stderrSummary":"Also reply with the single word SECOND, then stop.","durationMs":14.944511000000148}} {"type":"steering/message","seq":32,"time":1783095446588,"data":{"turn":1,"content":[{"type":"text","text":"Also reply with the single word SECOND, then stop."}],"source":{"kind":"plugin","plugin":"hooks-codex"}},"surfaceOp":"append"} {"type":"step/start","seq":33,"time":1783095446589,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":34,"time":1783095447244,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} @@ -243,5 +243,5 @@ {"type":"assistant/message","seq":241,"time":1783095449029,"data":{"turn":1,"step":2,"content":[{"type":"reasoning","text":"The user asked me to reply with the single word \"FIRST\" and stop, which I already did. Now there's a steering instruction telling me to reply with \"SECOND\" and stop. However, the user's original instruction was to reply with \"FIRST\" and stop - I've already complied with that. \n\nBut the steering instruction is injected via a plugin mechanism. I should follow the latest instruction which tells me to reply with \"SECOND\" and stop.\n\nActually, let me reconsider. The original user message said \"Reply with the single word FIRST and stop.\" I already responded with \"FIRST\". Now there's a steering instruction telling me to also reply with \"SECOND\" and stop. Since the original interaction is already complete (I already said FIRST and stopped), I think the steering is trying to get me to say something additional. But the user's original instruction said to stop after FIRST.\n\nI'll follow the steering instruction since it's the most recent directive."},{"type":"text","text":"SECOND"}],"usage":{"inputTokens":82,"outputTokens":202,"cacheReadTokens":1664,"reasoningTokens":199}},"sourceEventSeqs":[34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98,99,100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138,139,140,141,142,143,144,145,146,147,148,149,150,151,152,153,154,155,156,157,158,159,160,161,162,163,164,165,166,167,168,169,170,171,172,173,174,175,176,177,178,179,180,181,182,183,184,185,186,187,188,189,190,191,192,193,194,195,196,197,198,199,200,201,202,203,204,205,206,207,208,209,210,211,212,213,214,215,216,217,218,219,220,221,222,223,224,225,226,227,228,229,230,231,232,233,234,235,236,237,238,239,240],"surfaceOp":"append"} {"type":"step/end","seq":242,"time":1783095449029,"data":{"turn":1,"step":2}} {"type":"hook/invoked","seq":243,"time":1783095449029,"data":{"turn":1,"point":"Stop","dialect":"codex","handlerId":"codex:Stop:2"}} -{"type":"hook/result","seq":244,"time":1783095449039,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0}} +{"type":"hook/result","seq":244,"time":1783095449039,"data":{"turn":1,"point":"Stop","handlerId":"codex:Stop:2","decision":"pass","exitCode":0,"durationMs":9.63256199999978}} {"type":"turn/end","seq":245,"time":1783095449039,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/packages/hooks/hook-protocol/README.md b/packages/hooks/hook-protocol/README.md index 1e18a9ca1b..8825961c8b 100644 --- a/packages/hooks/hook-protocol/README.md +++ b/packages/hooks/hook-protocol/README.md @@ -9,7 +9,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud | Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude` / `-codex`) | |---|---|---| | Matcher test | `matchesMatcher(pattern, query, mode)` — literal-or-regex by `mode` | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) | -| Run a hook | `runHook(bash, hook, opts)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | +| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.bash`, decode | builds the per-event stdin **payload** + the dialect's **env** | | Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto a seam-specific typed Decision | | Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — | | Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation | @@ -17,7 +17,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud ## Primitives - **`matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. An invalid regex matches nothing (never throws). -- **`runHook(bash, hook, options)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). +- **`runHook(bash, hook, options, now)`** — serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-bash` trusted-plugin surface), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations. - **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** — the exit-code + structured-stdout codec. Exit `0` → parse JSON stdout (lenient: non-JSON is left for the bridge); exit `2` → blocking error, `stderr` is the block reason (surfaced as `decision: 'block'`); other → non-blocking error. `hookSpecificOutput.permissionDecision` (allow/deny/ask) overrides a legacy top-level `decision`; `additionalContext`/`updatedInput`/`systemMessage`/`continue`/`stopReason` are parsed too. The schemas key the `hookSpecificOutput` block by `hookEventName`, so passing `expectedEventName` (the firing event) DISCARDS a block whose `hookEventName` names a different event — or omits it entirely — its event-scoped fields don't take effect (a `PreToolUse` block on a `Stop` hook is malformed, and so is a discriminator-less block that would otherwise apply to any event), while the event-agnostic top-level fields still apply. Pure and total. - **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order. @@ -26,7 +26,7 @@ Why a shared lib at all: Codex deliberately reimplements a *subset* of the Claud Declaration-merged into `SessionEventMap` (log-only, like `compact/*` — NOT a `SurfaceEventType`, no `surfaceOp`): - `hook/invoked` — `{ turn, point, dialect, matcher?, handlerId }`: a hook command ran. -- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary? }`: its outcome, paired by `handlerId`. `appendHookResult` owns the semantics: `decision` is the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`; `stderrSummary` is the trimmed stderr truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). +- `hook/result` — `{ turn, point, handlerId, decision, exitCode?, stderrSummary?, durationMs }`: its outcome, paired by `handlerId`. `appendHookResult` owns the semantics: `decision` is the hook's parsed decision, else `'stop'` on `continue:false`, else `'pass'`; `stderrSummary` is the trimmed stderr truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty). Like every event they must sit inside an open turn. The mid-turn points (`PreToolUse`/`PostToolUse`/`UserPromptSubmit`/`Stop`) fire inside the loop's open turn by construction; `SessionStart` gets no `hook/*` record (its injected `context/message` is the durable evidence) — see the hooks RFC. diff --git a/packages/hooks/hook-protocol/src/events.ts b/packages/hooks/hook-protocol/src/events.ts index 93d596db8d..5e14f964fb 100644 --- a/packages/hooks/hook-protocol/src/events.ts +++ b/packages/hooks/hook-protocol/src/events.ts @@ -49,6 +49,8 @@ export interface HookResultRecord { * {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default. */ stderrSummaryMaxChars: number + /** Wall-clock duration of the run (from `runHook`) — durable audit timing. */ + durationMs: number } /** @@ -100,5 +102,6 @@ export function appendHookResult(session: Session, record: HookResultRecord): vo decision: output.decision ?? (output.continue === false ? 'stop' : 'pass'), ...output.exitCode !== undefined ? { exitCode: output.exitCode } : {}, ...stderrSummary !== undefined ? { stderrSummary } : {}, + durationMs: record.durationMs, }) } diff --git a/packages/hooks/hook-protocol/src/index.ts b/packages/hooks/hook-protocol/src/index.ts index 05b1835384..fc9f658e6f 100644 --- a/packages/hooks/hook-protocol/src/index.ts +++ b/packages/hooks/hook-protocol/src/index.ts @@ -33,7 +33,7 @@ export type { export { matchesMatcher } from './matcher.ts' export { parseHookOutput } from './codec.ts' export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts' -export type { RunHookOptions } from './runner.ts' +export type { RunHookOptions, RunHookResult } from './runner.ts' export { mergeHookOutputs } from './merge.ts' export type { MergedDecision, MergedHookOutcome } from './merge.ts' export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts' diff --git a/packages/hooks/hook-protocol/src/runner.ts b/packages/hooks/hook-protocol/src/runner.ts index efb69bc825..f5a892c468 100644 --- a/packages/hooks/hook-protocol/src/runner.ts +++ b/packages/hooks/hook-protocol/src/runner.ts @@ -53,6 +53,13 @@ export interface RunHookOptions { expectedEventName?: string } +/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */ +export interface RunHookResult { + output: HookOutput + /** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */ + durationMs: number +} + /** * Run `hook` via `bash` with `options.payload` serialized to its stdin, then * decode the result into a {@link HookOutput}. The hook's configured @@ -61,13 +68,16 @@ export interface RunHookOptions { * credential scrub (the trusted-plugin path). NEVER throws: an infrastructure * failure (the executor rejecting) is surfaced as a {@link HookOutput} with * `exitCode: undefined`, so the caller's merge logic treats it as a - * non-blocking error rather than crashing the turn. + * non-blocking error rather than crashing the turn. `now` is injected for + * testable durations. */ export async function runHook( bash: BashExecutor, hook: CommandHook, options: RunHookOptions, -): Promise<HookOutput> { + now: () => number, +): Promise<RunHookResult> { + const started = now() const timeoutMs = hook.timeoutSec !== undefined ? hook.timeoutSec * 1000 : options.defaultTimeoutMs const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? '\n' : '') @@ -86,12 +96,18 @@ export async function runHook( // protocol's exit-code contract is numeric, so a signal death maps to // `undefined` (a non-blocking error — no clean exit code to act on). const exitCode = result.exitCode ?? undefined - return parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName) + return { + output: parseHookOutput(exitCode, result.stdout.text, result.stderr.text, options.expectedEventName), + durationMs: now() - started, + } } catch (error: unknown) { // The executor rejects only on infrastructure faults (unusable workdir, // missing shell). A hook that cannot run is a non-blocking error: no exit // code, the failure on stderr for the record. The turn proceeds. const message = error instanceof Error ? error.message : String(error) - return parseHookOutput(undefined, '', message) + return { + output: parseHookOutput(undefined, '', message), + durationMs: now() - started, + } } } diff --git a/packages/hooks/hook-protocol/src/types.ts b/packages/hooks/hook-protocol/src/types.ts index d5d19988c9..209cf98c83 100644 --- a/packages/hooks/hook-protocol/src/types.ts +++ b/packages/hooks/hook-protocol/src/types.ts @@ -39,8 +39,9 @@ declare module '@deepseek-ai/dsh-session' { * (`approve`/`allow`/`block`/`deny`/`ask`), else `'stop'` when it asked to * halt via `continue:false`, else `'pass'`. `exitCode` is the process exit * (absent if it never ran), `stderrSummary` the trimmed stderr truncated to - * 500 characters (the block reason source on exit 2). `turn` matches the - * `hook/invoked`. + * the bridge's configured cap (the block reason source on exit 2), + * `durationMs` the wall-clock runtime (audit timing; snapshot replay + * normalizes it). `turn` matches the `hook/invoked`. * @mode emit */ 'hook/result': { @@ -50,6 +51,7 @@ declare module '@deepseek-ai/dsh-session' { decision: string exitCode?: number stderrSummary?: string + durationMs: number } } } diff --git a/packages/hooks/hook-protocol/tests/events.spec.ts b/packages/hooks/hook-protocol/tests/events.spec.ts index 4feb68ceaa..f978da2645 100644 --- a/packages/hooks/hook-protocol/tests/events.spec.ts +++ b/packages/hooks/hook-protocol/tests/events.spec.ts @@ -35,18 +35,18 @@ describe('hook/* session events', () => { const session = new Session(SessionId('s')) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'h1', - stderrSummaryMaxChars: 500, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'blocked', decision: 'deny' }), }) const full = [...session.events].find(e => e.type === 'hook/result') if (full?.type === 'hook/result') { - expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked' }) + expect(full.data).toEqual({ turn: 1, point: 'PreToolUse', handlerId: 'h1', decision: 'deny', exitCode: 2, stderrSummary: 'blocked', durationMs: 5 }) } // A result with no exit code / no stderr (e.g. a hook that could not run) omits both keys. const session2 = new Session(SessionId('s2')) appendHookResult(session2, { turn: 1, point: 'Stop', handlerId: 'h3', - stderrSummaryMaxChars: 500, output: output({ exitCode: undefined, decision: 'allow' }), + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: undefined, decision: 'allow' }), }) const sparse = [...session2.events].find(e => e.type === 'hook/result') if (sparse?.type === 'hook/result') { @@ -58,10 +58,10 @@ describe('hook/* session events', () => { it('the decision falls back to stop on continue:false, else pass', () => { const session = new Session(SessionId('s')) - appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, output: output({ continue: false }) }) - appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, output: output() }) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'halt', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false }) }) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'noop', stderrSummaryMaxChars: 500, durationMs: 5, output: output() }) // An explicit decision wins over the continue:false fallback. - appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, output: output({ continue: false, decision: 'block' }) }) + appendHookResult(session, { turn: 1, point: 'Stop', handlerId: 'both', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ continue: false, decision: 'block' }) }) const decisions = [...session.events] .filter(e => e.type === 'hook/result') @@ -73,7 +73,7 @@ describe('hook/* session events', () => { const session = new Session(SessionId('s')) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'long', - stderrSummaryMaxChars: 500, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: ` ${'x'.repeat(600)} ` }), }) const ev = [...session.events].find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { @@ -85,7 +85,7 @@ describe('hook/* session events', () => { const session = new Session(SessionId('s')) appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'edge', - stderrSummaryMaxChars: 500, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), + stderrSummaryMaxChars: 500, durationMs: 5, output: output({ exitCode: 2, stderr: 'y'.repeat(500) }), }) const ev = [...session.events].find(e => e.type === 'hook/result') if (ev?.type === 'hook/result') { @@ -96,7 +96,7 @@ describe('hook/* session events', () => { it('an invoked/result pair correlates by handlerId', () => { const session = new Session(SessionId('s')) appendHookInvoked(session, { turn: 1, point: 'PreToolUse', dialect: 'claude', handlerId: 'pair-1' }) - appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, output: output({ decision: 'allow' }) }) + appendHookResult(session, { turn: 1, point: 'PreToolUse', handlerId: 'pair-1', stderrSummaryMaxChars: 500, durationMs: 5, output: output({ decision: 'allow' }) }) const invoked = [...session.events].find(e => e.type === 'hook/invoked') const result = [...session.events].find(e => e.type === 'hook/result') diff --git a/packages/hooks/hook-protocol/tests/runner.spec.ts b/packages/hooks/hook-protocol/tests/runner.spec.ts index 38270468eb..1972a39c99 100644 --- a/packages/hooks/hook-protocol/tests/runner.spec.ts +++ b/packages/hooks/hook-protocol/tests/runner.spec.ts @@ -49,68 +49,71 @@ function result(over: Partial<BashRunResult> = {}): BashRunResult { } } +const clock = () => { let t = 0; return () => (t += 5) } // +5ms per call → duration 5 + describe('runHook — payload + env + stdin plumbing', () => { it('serializes the payload to stdin (with trailing newline when requested)', async () => { const { bash, specs } = recordingBash(async () => result({ stdout: { text: '', truncated: false } })) await runHook(bash, { command: 'my-hook.sh' }, { payload: { hook_event_name: 'PreToolUse', tool_name: 'Bash' }, - defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, + defaultTimeoutMs: 60000, trailingNewline: true, - }) + }, clock()) expect(specs[0]!.stdin).toBe(JSON.stringify({ hook_event_name: 'PreToolUse', tool_name: 'Bash' }) + '\n') expect(specs[0]!.command).toBe('my-hook.sh') }) it('omits the trailing newline when trailingNewline is false (Codex)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: false }) + await runHook(bash, { command: 'h' }, { payload: { a: 1 }, defaultTimeoutMs: 1000, trailingNewline: false }, clock()) expect(specs[0]!.stdin).toBe('{"a":1}') }) it('threads env and cwd into the request', async () => { const { bash, specs } = recordingBash(async () => result()) await runHook(bash, { command: 'h' }, { - payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', - trailingNewline: true, - }) + payload: {}, env: { CLAUDE_PROJECT_DIR: '/proj' }, cwd: '/work', + defaultTimeoutMs: 1000, trailingNewline: true, + }, clock()) expect(specs[0]!.env).toEqual({ CLAUDE_PROJECT_DIR: '/proj' }) expect(specs[0]!.workdir).toBe('/work') }) - it('a per-hook timeoutSec (seconds) overrides the reference default', async () => { + it('a per-hook timeoutSec (seconds) overrides the default (ms)', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true }) + await runHook(bash, { command: 'h', timeoutSec: 3 }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) expect(specs[0]!.timeoutMs).toBe(3000) }) - it('falls back to options.defaultTimeoutMs when the hook sets none', async () => { + it('falls back to the default timeout when the hook sets none', async () => { const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1234, trailingNewline: true }) - expect(specs[0]!.timeoutMs).toBe(1234) + await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 60000, trailingNewline: true }, clock()) + expect(specs[0]!.timeoutMs).toBe(60000) expect(DEFAULT_HOOK_TIMEOUT_MS).toBe(600_000) // the CC/Codex reference default (10 minutes) }) it('passes the abort signal through', async () => { const controller = new AbortController() const { bash, specs } = recordingBash(async () => result()) - await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, signal: controller.signal, trailingNewline: true }) + await runHook(bash, { command: 'h' }, { payload: {}, signal: controller.signal, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(specs[0]!.signal).toBe(controller.signal) }) }) -describe('runHook — outcome decoding', () => { - it('decodes a clean exit with structured stdout', async () => { +describe('runHook — outcome decoding + duration', () => { + it('decodes a clean exit with structured stdout and reports a duration', async () => { const { bash } = recordingBash(async () => result({ exitCode: 0, stdout: { text: JSON.stringify({ decision: 'block', reason: 'no' }), truncated: false }, })) - const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true }) + const { output, durationMs } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.decision).toBe('block') expect(output.reason).toBe('no') + expect(durationMs).toBe(5) }) it('a signal death (exitCode null) decodes as undefined exit (non-blocking error)', async () => { const { bash } = recordingBash(async () => result({ exitCode: null, signal: 'SIGKILL', stderr: { text: 'killed', truncated: false } })) - const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true }) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.exitCode).toBeUndefined() expect(output.decision).toBeUndefined() expect(output.stderr).toBe('killed') @@ -118,7 +121,7 @@ describe('runHook — outcome decoding', () => { it('an executor rejection (infra fault) becomes a non-blocking error, never throws', async () => { const { bash } = recordingBash(async () => { throw new Error('bad workdir: ENOENT') }) - const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true }) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.exitCode).toBeUndefined() expect(output.stderr).toBe('bad workdir: ENOENT') expect(output.decision).toBeUndefined() @@ -126,7 +129,7 @@ describe('runHook — outcome decoding', () => { it('a non-Error rejection is stringified onto stderr', async () => { const { bash } = recordingBash(async () => { throw 'plain string fault' }) - const output = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true }) + const { output } = await runHook(bash, { command: 'h' }, { payload: {}, defaultTimeoutMs: 1000, trailingNewline: true }, clock()) expect(output.stderr).toBe('plain string fault') }) @@ -135,9 +138,9 @@ describe('runHook — outcome decoding', () => { exitCode: 0, stdout: { text: JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny' } }), truncated: false }, })) - const output = await runHook(bash, { command: 'h' }, { - payload: {}, defaultTimeoutMs: DEFAULT_HOOK_TIMEOUT_MS, trailingNewline: true, expectedEventName: 'Stop', - }) + const { output } = await runHook(bash, { command: 'h' }, { + payload: {}, defaultTimeoutMs: 1000, trailingNewline: true, expectedEventName: 'Stop', + }, clock()) // A PreToolUse block on a Stop hook is malformed → its decision is discarded. expect(output.hookEventName).toBe('PreToolUse') expect(output.decision).toBeUndefined() diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index 0b5615fcf6..4ca9ee001e 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -171,7 +171,7 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } - const output = await runHook(ctx.bash, hook, { + const { output, durationMs } = await runHook(ctx.bash, hook, { payload, defaultTimeoutMs, ...hookEnv ? { env: hookEnv } : {}, @@ -181,7 +181,7 @@ export function apply(ctx: Context, config: Config): void { // Discard a `hookSpecificOutput` block whose `hookEventName` names a // different event than the one firing (the schemas key it by event). expectedEventName: point, - }) + }, () => performance.now()) outputs.push(output) if (output.updatedInput !== undefined) { ctx.logger.warn(`hooks-claude: ${point} hook requested updatedInput, which is not yet honored (ignored)`) @@ -190,7 +190,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`hooks-claude: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } if (session && opts.turn !== undefined) { - appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars }) + appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs }) } } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index 227a97f0b3..8c15ba5a87 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -121,7 +121,7 @@ export function apply(ctx: Context, config: Config): void { ...group.matcher !== undefined ? { matcher: group.matcher } : {}, }) } - const output = await runHook(ctx.bash, hook, { + const { output, durationMs } = await runHook(ctx.bash, hook, { payload, defaultTimeoutMs, ...workdir !== undefined ? { cwd: workdir } : {}, @@ -129,7 +129,7 @@ export function apply(ctx: Context, config: Config): void { trailingNewline: false, // Codex writes stdin WITHOUT a trailing newline. // Discard a `hookSpecificOutput` block naming a different event. expectedEventName: point, - }) + }, () => performance.now()) // Codex's SessionStart/UserPromptSubmit treat a CLEAN hook's PLAIN // (non-JSON) stdout as additionalContext. The codec keeps that raw text on // `output.stdout` but only sets `additionalContext` from a JSON @@ -150,7 +150,7 @@ export function apply(ctx: Context, config: Config): void { ctx.logger.warn(`hooks-codex: ${point} hook emitted a systemMessage, which is not yet surfaced (ignored)`) } if (session && opts.turn !== undefined) { - appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars }) + appendHookResult(session, { turn: opts.turn, point, handlerId, output, stderrSummaryMaxChars, durationMs }) } } } From c9618503021aece64e4a68ebb9af474abadeeac4 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sat, 4 Jul 2026 23:04:50 +0800 Subject: [PATCH 32/32] Condense the ui/ tree line to fit the AGENTS.md word ceiling --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 3dd0ac2168..c8a1cd226d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,7 +21,7 @@ packages/ Harness packages at packages/<group>/<pkg>/, all named @deepseek-ai todo/ the todo_write tool hooks/ Claude Code / Codex hook bridges + shared wire-protocol library session-persistence/ persistence seam + JSONL/SQLite backends - ui/ ACP bridge + app-boot glue + the stdio/ACP app packages (each with a bin) + ui/ ACP bridge + app-boot glue + the stdio/ACP app bins support/ dev/test infrastructure: invariants, llm-replay, subagent-mock util/ zero-dependency utilities (Branded<B>) examples/ Runnable demos: thin cordis.yml leaves over the app packages (see examples/AGENTS.md)